Mybatis兩級(jí)緩存可能導(dǎo)致的問題詳細(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í)緩存被全部清空了。

開關(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ō)明緩存沒有起作用。

二級(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。除此之外還需要在要開啟二級(jí)緩存的 Mapper.xml 中添加 <cache/> 表情才能開啟對(duì)應(yīng) Mapper 的二級(jí)緩存。
下面是在關(guān)閉一級(jí)緩存,且只開啟 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 的輸出:
由于沒有開啟 TeacherMapper.xml 的二級(jí)緩存,所以每次查詢 teacher 都打印了 SQL,且 hashCode 不相同,說(shuō)明 teacher 的緩存沒起作用。
第 ① 次查詢 student 打印了 SQL,直接查詢了數(shù)據(jù)庫(kù),這是正常的,因?yàn)榇藭r(shí)緩存中沒有數(shù)據(jù)。但第 ② 次查詢 student 也沒有走緩存,也直接查詢了數(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 的緩存還是沒起作用。
只有第一次查詢 student 時(shí)直接查詢了數(shù)據(jù)庫(kù),其他三次都命中了緩存。

兩級(jí)緩存可能導(dǎo)致的問題
分布式環(huán)境下查詢到過(guò)期數(shù)據(jù)
假設(shè)支付服務(wù) A 有兩個(gè)實(shí)例 A1、A2,負(fù)載均衡采用輪訓(xùn)策略,第一次查詢余額訪問 A1 返回 100000,第二次消費(fèi) 100 訪問 A2 返回余額 99900,第三次查詢余額訪問 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è)問題很簡(jiǎn)單,就是不使用緩存,比如 mybatis.configuration.cache-enabled=false 或者將 AccountMapper.xml 中的 <cache/> 標(biāo)簽刪除。
事務(wù)隔離級(jí)別失效
讀已提交失效
在開發(fā)中經(jīng)常有這種場(chǎng)景:先判斷是否存在,如果不存在再插入。這種判斷再插入的操作不是原子的,多線程會(huì)有問題,所以需要加鎖保證操作的安全性。在讀多寫少的場(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ā)問題
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í)別沒起作用,第二個(gè)請(qǐng)求沒有讀到最新的數(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)致沒有查詢到第一個(gè)請(qǐng)求插入的最新數(shù)據(jù),才導(dǎo)致的后來(lái)的報(bào)錯(cuò)。

解決辦法
最簡(jiǎn)單辦法就是修改
mybatis.configuration.local-cache-scope=statement,直接關(guān)閉一級(jí)緩存。直接去掉@Transactional注解肯定能解決問題,但如果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ā)問題 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ā)問題 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í)別失效。要解決失效的問題,最簡(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)致其他問題。我個(gè)人建議在分布式、多實(shí)例環(huán)境中應(yīng)該設(shè)置 mybatis.configuration.cache-enabled=false 來(lái)關(guān)閉二級(jí)緩存,從根源上杜絕這種問題。
到此這篇關(guān)于Mybatis兩級(jí)緩存可能導(dǎo)致問題的文章就介紹到這了,更多相關(guān)Mybatis兩級(jí)緩存問題內(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-12
springboot如何使用thymeleaf模板訪問html頁(yè)面
springboot中推薦使用thymeleaf模板,使用html作為頁(yè)面展示。那么如何通過(guò)Controller來(lái)訪問來(lái)訪問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-05
SpringBoot開發(fā)案例 分布式集群共享Session詳解
這篇文章主要介紹了SpringBoot開發(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方法遇到的問題及解決方法(添加時(shí)id值不存在異常)
這篇文章主要介紹了使用mybatis-plus的insert方法遇到的問題及解決方法(添加時(shí)id值不存在異常),本文給大家介紹的非常詳細(xì),對(duì)大家的學(xué)習(xí)或工作具有一定的參考借鑒價(jià)值,需要的朋友可以參考下2020-08-08
Java 互相關(guān)聯(lián)的實(shí)體無(wú)限遞歸問題的解決
這篇文章主要介紹了Java 互相關(guān)聯(lián)的實(shí)體無(wú)限遞歸問題的解決,具有很好的參考價(jià)值,希望對(duì)大家有所幫助。如有錯(cuò)誤或未考慮完全的地方,望不吝賜教2021-10-10

