Mybatis兩級(jí)緩存可能導(dǎo)致的問(wèn)題詳細(xì)講解
兩級(jí)緩存簡(jiǎn)介
一級(jí)緩存 localCache
效果
一級(jí)緩存是 session 或者說(shuō)事務(wù)級(jí)別的,只在同一事務(wù)內(nèi)有效,在以相同的參數(shù)執(zhí)行多次同一個(gè)查詢方法時(shí),實(shí)際只會(huì)在第一次時(shí)進(jìn)行數(shù)據(jù)庫(kù) select 查詢,后續(xù)會(huì)直接從緩存中返回。如下:
@GetMapping("/test1") @Transactional(rollbackFor = Exception.class) public String test1() { log.info("---------------------------------------------------------------------------"); Teacher teacher1 = teacherMapper.selectByPrimaryKey("01"); log.info("teacher1: {}, hashCode: {} \n", teacher1, System.identityHashCode(teacher1)); Teacher teacher2 = teacherMapper.selectByPrimaryKey("01"); log.info("teacher2: {}, hashCode: {} \n", teacher2, System.identityHashCode(teacher2)); Student student1 = studentMapper.selectByPrimaryKey("01"); log.info("student1: {}, hashCode: {} \n", student1, System.identityHashCode(student1)); Student student2 = studentMapper.selectByPrimaryKey("01"); log.info("student2: {}, hashCode: {} \n", student2, System.identityHashCode(student2)); return "test1"; }
下圖中是調(diào)用了兩次的輸出,從第一次輸出中可以看出查詢 teacher、student 的 SQL 都只打印了一遍,說(shuō)明分別只執(zhí)行了一次數(shù)據(jù)庫(kù)查詢。且兩個(gè) teacher、student 的 hashCode 分別是一樣的,說(shuō)明是同一個(gè)對(duì)象。第二次調(diào)用的輸出和第一次的相似,都重新執(zhí)行了一次數(shù)據(jù)庫(kù)查詢,說(shuō)明一級(jí)緩存只在同一事務(wù)內(nèi)有效,不能跨事務(wù)。
如果事務(wù)中有 DML 語(yǔ)句的話,會(huì)清空所有的緩存。不管 DML 語(yǔ)句中的表是否與緩存中的表相同,都會(huì)無(wú)條件的清空所有緩存。
@GetMapping("/test2") @Transactional(rollbackFor = Exception.class) public String test2() { log.info("---------------------------------------------------------------------------"); Teacher teacher1 = teacherMapper.selectByPrimaryKey("01"); log.info("teacher1: {}, hashCode: {} \n", teacher1, System.identityHashCode(teacher1)); Teacher teacher2 = teacherMapper.selectByPrimaryKey("01"); log.info("teacher2: {}, hashCode: {} \n", teacher2, System.identityHashCode(teacher2)); Student student1 = studentMapper.selectByPrimaryKey("01"); log.info("student1: {}, hashCode: {} \n", student1, System.identityHashCode(student1)); Student student2 = studentMapper.selectByPrimaryKey("01"); log.info("student2: {}, hashCode: {} \n", student2, System.identityHashCode(student2)); insertScore(); log.info("insertScore\n"); Teacher teacher3 = teacherMapper.selectByPrimaryKey("01"); log.info("teacher3: {}, hashCode: {} \n", teacher3, System.identityHashCode(teacher3)); Student student3 = studentMapper.selectByPrimaryKey("01"); log.info("student3: {}, hashCode: {} \n", student3, System.identityHashCode(student3)); return "test2"; } private void insertScore() { Score score = new Score(); score.setSId("08"); score.setCId("01"); score.setSScore(100); scoreMapper.insert(score); }
前半部分的輸出與 test1 相同,當(dāng)插入 score 后再次查詢 teacher、student 時(shí),打印了 SQL,且與上半部分的 hashCode 不相同,說(shuō)明執(zhí)行 insertScore
時(shí)緩存被全部清空了。
開(kāi)關(guān)
一級(jí)緩存在 mybatis 源碼中被稱為 localCache
,springboot 可使用 mybatis.configuration.local-cache-scope
來(lái)控制其行為,默認(rèn)值是 session
,也就是事務(wù)級(jí)別的緩存??蓪⑵渑渲脼?statement
以關(guān)閉 localCache
功能。
下面是將 mybatis.configuration.local-cache-scope
配置為 statement
后再執(zhí)行 test1 的輸出,每次都打印了 SQL,且 hashCode 都不一樣,說(shuō)明緩存沒(méi)有起作用。
二級(jí)緩存
二級(jí)緩存是 namespace 級(jí)別的(或者說(shuō)是 Mapper 級(jí)別的,如下 xml),與一級(jí)緩存類似,在以相同的參數(shù)執(zhí)行多次同一個(gè)查詢方法時(shí),實(shí)際只會(huì)在第一次時(shí)進(jìn)行數(shù)據(jù)庫(kù) select 查詢,后續(xù)會(huì)直接從緩存中返回。如果執(zhí)行同一個(gè) namespace 中的 DML 語(yǔ)句(比如 delete、insert、update)的話,會(huì)清空 namespace 相關(guān)的所有 select 的緩存。
<?xml version="1.0" encoding="UTF-8"?> <!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd"> <mapper namespace="org.example.mybatis.mapper.StudentMapper"> <select> ... </select> <delete> ... </delete> <insert> ... </insert> ... </mapper>
二級(jí)緩存由 mybatis.configuration.cache-enabled
控制,默認(rèn)為 true。除此之外還需要在要開(kāi)啟二級(jí)緩存的 Mapper.xml 中添加 <cache/>
表情才能開(kāi)啟對(duì)應(yīng) Mapper 的二級(jí)緩存。
下面是在關(guān)閉一級(jí)緩存,且只開(kāi)啟 StudentMapper.xml
二級(jí)緩存的情況下的測(cè)試:
application.properties
... mybatis.configuration.local-cache-scope=statement mybatis.configuration.cache-enabled=true
StudentMapper.xml
<?xml version="1.0" encoding="UTF-8"?> <!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd"> <mapper namespace="org.example.mybatis.mapper.StudentMapper"> <resultMap id="BaseResultMap" type="org.example.mybatis.entity.Student"> <!--@mbg.generated--> <!--@Table student--> <id column="s_id" jdbcType="VARCHAR" property="sId" /> <result column="s_name" jdbcType="VARCHAR" property="sName" /> <result column="s_birth" jdbcType="VARCHAR" property="sBirth" /> <result column="s_sex" jdbcType="VARCHAR" property="sSex" /> </resultMap> <cache readOnly="true"/> ... </mapper>
這是執(zhí)行了兩次 test1 的輸出:
由于沒(méi)有開(kāi)啟 TeacherMapper.xml
的二級(jí)緩存,所以每次查詢 teacher 都打印了 SQL,且 hashCode 不相同,說(shuō)明 teacher 的緩存沒(méi)起作用。
第 ① 次查詢 student 打印了 SQL,直接查詢了數(shù)據(jù)庫(kù),這是正常的,因?yàn)榇藭r(shí)緩存中沒(méi)有數(shù)據(jù)。但第 ② 次查詢 student 也沒(méi)有走緩存,也直接查詢了數(shù)據(jù)庫(kù),這是為啥?是因?yàn)槎?jí)緩存不是在執(zhí)行完 select 后立即填充的,是要等到事務(wù)提交之后才會(huì)填充緩存。
從最后幾行的輸出能看出最后兩次查詢 student 確實(shí)走了緩存,并且還打印了緩存命中率。這是因?yàn)榈谝淮握{(diào)用 test1 結(jié)束后事務(wù)提交了,數(shù)據(jù)被填充到了緩存里。
測(cè)試無(wú)事務(wù)時(shí)的效果
test3 是在 test1 的基礎(chǔ)上刪除了 @Transactional
注解
@GetMapping("/test3") public String test3() { log.info("---------------------------------------------------------------------------"); Teacher teacher1 = teacherMapper.selectByPrimaryKey("01"); log.info("teacher1: {}, hashCode: {} \n", teacher1, System.identityHashCode(teacher1)); Teacher teacher2 = teacherMapper.selectByPrimaryKey("01"); log.info("teacher2: {}, hashCode: {} \n", teacher2, System.identityHashCode(teacher2)); Student student1 = studentMapper.selectByPrimaryKey("01"); log.info("student1: {}, hashCode: {} \n", student1, System.identityHashCode(student1)); Student student2 = studentMapper.selectByPrimaryKey("01"); log.info("student2: {}, hashCode: {} \n", student2, System.identityHashCode(student2)); return "test3"; }
teacher 的緩存還是沒(méi)起作用。
只有第一次查詢 student 時(shí)直接查詢了數(shù)據(jù)庫(kù),其他三次都命中了緩存。
兩級(jí)緩存可能導(dǎo)致的問(wèn)題
分布式環(huán)境下查詢到過(guò)期數(shù)據(jù)
假設(shè)支付服務(wù) A 有兩個(gè)實(shí)例 A1、A2,負(fù)載均衡采用輪訓(xùn)策略,第一次查詢余額訪問(wèn) A1 返回 100000,第二次消費(fèi) 100 訪問(wèn) A2 返回余額 99900,第三次查詢余額訪問(wèn) A1 返回的還是 100000。如下的模擬
application.properties
... mybatis.configuration.local-cache-scope=statement mybatis.configuration.cache-enabled=true
AccountMapper.xml
<?xml version="1.0" encoding="UTF-8"?> <!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd"> <mapper namespace="org.example.mybatis.mapper.AccountMapper"> ... <cache readOnly="true"/> <update id="pay"> update account set balance = balance - #{amount} where id = #{id} </update> </mapper>
@GetMapping("/balance") public Long queryBalance() { return accountMapper.selectByPrimaryKey(1).getBalance(); } @GetMapping("/pay") public Long pay() { accountMapper.pay(1, 100); return accountMapper.selectByPrimaryKey(1).getBalance(); }
分別在 8080、8081 啟動(dòng)兩個(gè)實(shí)例,如下輸出:
要解決這個(gè)問(wèn)題很簡(jiǎn)單,就是不使用緩存,比如 mybatis.configuration.cache-enabled=false
或者將 AccountMapper.xml 中的 <cache/>
標(biāo)簽刪除。
事務(wù)隔離級(jí)別失效
讀已提交失效
在開(kāi)發(fā)中經(jīng)常有這種場(chǎng)景:先判斷是否存在,如果不存在再插入。這種判斷再插入的操作不是原子的,多線程會(huì)有問(wèn)題,所以需要加鎖保證操作的安全性。在讀多寫(xiě)少的場(chǎng)景中,會(huì)使用 double check 來(lái)盡可能的減少用鎖的使用,偽代碼如下:
def doubleCheck(id) { o = select(id); if (o == null) { lock.lock(); try { o = select(id); if (o == null) { o = create(id); } } finally { lock.unlock(); } } return o; }
創(chuàng)建 Account 的測(cè)試
application.properties
還原成默認(rèn)值,且刪除 AccountMapper.xml 中的 <cache/>
標(biāo)簽,用以關(guān)閉 AccountMapper
的二級(jí)緩存。
... mybatis.configuration.local-cache-scope=session mybatis.configuration.cache-enabled=true
注意這里使用的隔離級(jí)別為讀已提交
@PutMapping("/accounts/{id}") // double check 需要使用讀已提交隔離級(jí)別才能讀到最新數(shù)據(jù) @Transactional(rollbackFor = Exception.class, isolation = Isolation.READ_COMMITTED) public Account createAccount(@PathVariable("id") Integer id) throws InterruptedException { Account account = accountMapper.selectByPrimaryKey(id); // 等待多個(gè)請(qǐng)求到達(dá) TimeUnit.SECONDS.sleep(5); // 如果賬戶不存在,需要加分布式鎖后進(jìn)行 double check,防止并發(fā)問(wèn)題 if (account == null) { RLock lock = redissonClient.getLock("lock:account:create:" + id); boolean locked = lock.tryLock(10, TimeUnit.SECONDS); if (locked) { try { account = accountMapper.selectByPrimaryKey(id); if (account == null) { // 創(chuàng)建賬戶 account = createAccount0(id); } } finally { lock.unlock(); } } } return account; } public Account createAccount0(Integer id) { Account account = new Account(); account.setId(id); account.setBalance(0L); accountMapper.insertSelective(account); // 操作其他表 return account; }
同時(shí)發(fā)起兩個(gè) Put 請(qǐng)求 http://localhost:8080/accounts/2
。一個(gè)正常返回,另一個(gè)在 insert 時(shí)報(bào)錯(cuò) Duplicate entry ‘2’ for key ‘account.PRIMARY’,說(shuō)明讀已提交的隔離級(jí)別沒(méi)起作用,第二個(gè)請(qǐng)求沒(méi)有讀到最新的數(shù)據(jù)。
一級(jí)緩存實(shí)際起到了類似可重復(fù)讀的效果。
兩個(gè)請(qǐng)求(線程分別為 nio-8080-exec-3、nio-8080-exec-4)執(zhí)行了 3 次(第一個(gè)請(qǐng)求 1 次,第二個(gè)請(qǐng)求 2 次) accountMapper.selectByPrimaryKey(id)
,但每個(gè)線程都只打印了 1 次 SQL,說(shuō)明第二個(gè)請(qǐng)求的第 2 次查詢走了緩存,導(dǎo)致沒(méi)有查詢到第一個(gè)請(qǐng)求插入的最新數(shù)據(jù),才導(dǎo)致的后來(lái)的報(bào)錯(cuò)。
解決辦法
最簡(jiǎn)單辦法就是修改
mybatis.configuration.local-cache-scope=statement
,直接關(guān)閉一級(jí)緩存。直接去掉@Transactional
注解肯定能解決問(wèn)題,但如果createAccount0
方法中操作多張表的話,如果部分失敗事務(wù)將無(wú)法回滾。不能直接去掉
@Transactional
注解,但可以縮小事務(wù)的范圍,將兩次查詢放到事務(wù)外,只將createAccount0
方法放到事務(wù)內(nèi)。@Lazy @Autowired private TestController self; @PutMapping("/accounts/{id}") public Account createAccount(@PathVariable("id") Integer id) throws InterruptedException { Account account = accountMapper.selectByPrimaryKey(id); // 等待多個(gè)請(qǐng)求到達(dá) TimeUnit.SECONDS.sleep(5); // 如果賬戶不存在,需要加分布式鎖后進(jìn)行 double check,防止并發(fā)問(wèn)題 if (account == null) { RLock lock = redissonClient.getLock("lock:account:create:" + id); boolean locked = lock.tryLock(10, TimeUnit.SECONDS); if (locked) { try { account = accountMapper.selectByPrimaryKey(id); if (account == null) { // 創(chuàng)建賬戶 account = self.createAccount0(id); } } finally { lock.unlock(); } } } return account; } @Transactional(rollbackFor = Exception.class) public Account createAccount0(Integer id) { Account account = new Account(); account.setId(id); account.setBalance(0L); accountMapper.insertSelective(account); // 操作其他表 return account; }
如果外層有其他事務(wù)的話,由于一級(jí)緩存只有在同一個(gè)事務(wù)中才會(huì)生效,所以可以將兩個(gè)
accountMapper.selectByPrimaryKey(id)
拆分到不同的事務(wù)中,propagation
必須是Propagation.REQUIRES_NEW
。@Lazy @Autowired private TestController self; @PutMapping("/accounts/{id}") public Account createAccount(@PathVariable("id") Integer id) throws InterruptedException { Account account = self.getAccount0(id); // 等待多個(gè)請(qǐng)求到達(dá) TimeUnit.SECONDS.sleep(5); // 如果賬戶不存在,需要加分布式鎖后進(jìn)行 double check,防止并發(fā)問(wèn)題 if (account == null) { RLock lock = redissonClient.getLock("lock:account:create:" + id); boolean locked = lock.tryLock(10, TimeUnit.SECONDS); if (locked) { try { account = self.getAccount0(id); if (account == null) { // 創(chuàng)建賬戶 // account = self.createAccount0(id); } } finally { lock.unlock(); } } } return account; } // 讀已提交 REQUIRES_NEW @Transactional(rollbackFor = Exception.class, isolation = Isolation.READ_COMMITTED, propagation = Propagation.REQUIRES_NEW) public Account getAccount0(Integer id) { return accountMapper.selectByPrimaryKey(id); }
讀未提交失效
同樣的由于一級(jí)緩存的存在,讀未提交也讀不到最新的未提交數(shù)據(jù)。
讀未提交 查詢 Account 的測(cè)試
application.properties
還原成默認(rèn)值,且刪除 AccountMapper.xml 中的 <cache/>
標(biāo)簽,用以關(guān)閉 AccountMapper
的二級(jí)緩存。
... mybatis.configuration.local-cache-scope=session mybatis.configuration.cache-enabled=true
@GetMapping("/accounts/{id}") // 讀未提交 @Transactional(rollbackFor = Exception.class, isolation = Isolation.READ_UNCOMMITTED) public Account getAccount(@PathVariable("id") Integer id) throws InterruptedException { Account account = accountMapper.selectByPrimaryKey(id); log.info("account1: {}\n", account); // 若不存在,則等待幾秒再查 if (account == null) { TimeUnit.SECONDS.sleep(10); } account = accountMapper.selectByPrimaryKey(id); log.info("account2: {}\n", account); return account; } @PutMapping("/accounts/{id}") @Transactional(rollbackFor = Exception.class) public Account createAccount(@PathVariable("id") Integer id) throws InterruptedException { Account account = new Account(); account.setId(id); account.setBalance(0L); accountMapper.insertSelective(account); log.info("insert account: {}\n", account); // 延遲提交事務(wù) TimeUnit.SECONDS.sleep(15); // 操作其他表 return account; }
先請(qǐng)求 getAccount
再請(qǐng)求 createAccount
,從輸出中可以看出,在使用讀未提交的情況下,account2 依舊為 null,走了緩存,導(dǎo)致讀未提交失效。
解決辦法
最簡(jiǎn)單辦法就是修改
mybatis.configuration.local-cache-scope=statement
,直接關(guān)閉一級(jí)緩存。由于一級(jí)緩存只有在同一個(gè)事務(wù)中才會(huì)生效,所以可以將兩個(gè)
accountMapper.selectByPrimaryKey(id)
拆分到不同的事務(wù)中,propagation
必須是Propagation.REQUIRES_NEW
。@Lazy @Autowired private TestController self; @GetMapping("/accounts/{id}") public Account getAccount(@PathVariable("id") Integer id) throws InterruptedException { Account account = self.getAccount0(id); log.info("account1: {}\n", account); // 若不存在,則等待幾秒再查 if (account == null) { TimeUnit.SECONDS.sleep(10); } account = self.getAccount0(id); log.info("account2: {}\n", account); return account; } // 讀未提交 REQUIRES_NEW @Transactional(rollbackFor = Exception.class, isolation = Isolation.READ_UNCOMMITTED, propagation = Propagation.REQUIRES_NEW) public Account getAccount0(Integer id) { return accountMapper.selectByPrimaryKey(id); }
總結(jié)
一級(jí)緩存是事務(wù)級(jí)別的,實(shí)際起到了類似可重復(fù)讀的效果,而且比可重復(fù)讀的性能更好,因?yàn)槎啻尾樵兊脑挷粫?huì)請(qǐng)求數(shù)據(jù)庫(kù)了。在事務(wù)隔離級(jí)別是可重復(fù)讀時(shí)使用一級(jí)緩存能提高性能。但就因?yàn)槠漕愃瓶芍貜?fù)讀的效果會(huì)導(dǎo)致其他的隔離級(jí)別失效。要解決失效的問(wèn)題,最簡(jiǎn)單方式就是關(guān)閉一級(jí)緩存,但這樣會(huì)損失性能。另一個(gè)解決辦法是將需要使用其他隔離級(jí)別的方法使用 propagation = Propagation.REQUIRES_NEW
拆分到新的事務(wù)中。如果是讀已提交的話可通過(guò)縮小事務(wù)范圍的方式解決。
一級(jí)緩存是事務(wù)級(jí)別的,緩存的生命周期較短,但二級(jí)緩存是 namespace (Mapper)級(jí)別的,生命周期可能很長(zhǎng),在分布式、多實(shí)例環(huán)境中很容易查詢到過(guò)期的數(shù)據(jù),導(dǎo)致其他問(wèn)題。我個(gè)人建議在分布式、多實(shí)例環(huán)境中應(yīng)該設(shè)置 mybatis.configuration.cache-enabled=false
來(lái)關(guān)閉二級(jí)緩存,從根源上杜絕這種問(wèn)題。
到此這篇關(guān)于Mybatis兩級(jí)緩存可能導(dǎo)致問(wèn)題的文章就介紹到這了,更多相關(guān)Mybatis兩級(jí)緩存問(wèn)題內(nèi)容請(qǐng)搜索腳本之家以前的文章或繼續(xù)瀏覽下面的相關(guān)文章希望大家以后多多支持腳本之家!
相關(guān)文章
Spring boot通過(guò)AOP防止API重復(fù)請(qǐng)求代碼實(shí)例
這篇文章主要介紹了Spring boot通過(guò)AOP防止API重復(fù)請(qǐng)求代碼實(shí)例,文中通過(guò)示例代碼介紹的非常詳細(xì),對(duì)大家的學(xué)習(xí)或者工作具有一定的參考學(xué)習(xí)價(jià)值,需要的朋友可以參考下2019-12-12springboot如何使用thymeleaf模板訪問(wèn)html頁(yè)面
springboot中推薦使用thymeleaf模板,使用html作為頁(yè)面展示。那么如何通過(guò)Controller來(lái)訪問(wèn)來(lái)訪問(wèn)html頁(yè)面呢?下面通過(guò)本文給大家詳細(xì)介紹,感興趣的朋友跟隨腳本之家小編一起看看吧2018-05-05超詳細(xì)講解SpringBoot參數(shù)校驗(yàn)實(shí)例
經(jīng)常需要提供接口與用戶交互(獲取數(shù)據(jù)、上傳數(shù)據(jù)等),由于這個(gè)過(guò)程需要用戶進(jìn)行相關(guān)的操作,為了避免出現(xiàn)一些錯(cuò)誤的數(shù)據(jù)等,一般需要對(duì)數(shù)據(jù)進(jìn)行校驗(yàn),下面這篇文章主要給大家介紹了關(guān)于SpringBoot各種參數(shù)校驗(yàn)的相關(guān)資料,需要的朋友可以參考下2022-05-05SpringBoot開(kāi)發(fā)案例 分布式集群共享Session詳解
這篇文章主要介紹了SpringBoot開(kāi)發(fā)案例 分布式集群共享Session詳解,在分布式系統(tǒng)中,為了提升系統(tǒng)性能,通常會(huì)對(duì)單體項(xiàng)目進(jìn)行拆分,分解成多個(gè)基于功能的微服務(wù),可能還會(huì)對(duì)單個(gè)微服務(wù)進(jìn)行水平擴(kuò)展,保證服務(wù)高可用,需要的朋友可以參考下2019-07-07使用mybatis-plus的insert方法遇到的問(wèn)題及解決方法(添加時(shí)id值不存在異常)
這篇文章主要介紹了使用mybatis-plus的insert方法遇到的問(wèn)題及解決方法(添加時(shí)id值不存在異常),本文給大家介紹的非常詳細(xì),對(duì)大家的學(xué)習(xí)或工作具有一定的參考借鑒價(jià)值,需要的朋友可以參考下2020-08-08Java 互相關(guān)聯(lián)的實(shí)體無(wú)限遞歸問(wèn)題的解決
這篇文章主要介紹了Java 互相關(guān)聯(lián)的實(shí)體無(wú)限遞歸問(wèn)題的解決,具有很好的參考價(jià)值,希望對(duì)大家有所幫助。如有錯(cuò)誤或未考慮完全的地方,望不吝賜教2021-10-10