Mybatis兩級緩存可能導(dǎo)致的問題詳細(xì)講解
兩級緩存簡介
一級緩存 localCache
效果
一級緩存是 session 或者說事務(wù)級別的,只在同一事務(wù)內(nèi)有效,在以相同的參數(shù)執(zhí)行多次同一個查詢方法時,實際只會在第一次時進行數(shù)據(jù)庫 select 查詢,后續(xù)會直接從緩存中返回。如下:
@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 都只打印了一遍,說明分別只執(zhí)行了一次數(shù)據(jù)庫查詢。且兩個 teacher、student 的 hashCode 分別是一樣的,說明是同一個對象。第二次調(diào)用的輸出和第一次的相似,都重新執(zhí)行了一次數(shù)據(jù)庫查詢,說明一級緩存只在同一事務(wù)內(nèi)有效,不能跨事務(wù)。

如果事務(wù)中有 DML 語句的話,會清空所有的緩存。不管 DML 語句中的表是否與緩存中的表相同,都會無條件的清空所有緩存。
@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 時,打印了 SQL,且與上半部分的 hashCode 不相同,說明執(zhí)行 insertScore 時緩存被全部清空了。

開關(guān)
一級緩存在 mybatis 源碼中被稱為 localCache,springboot 可使用 mybatis.configuration.local-cache-scope 來控制其行為,默認(rèn)值是 session,也就是事務(wù)級別的緩存??蓪⑵渑渲脼?statement 以關(guān)閉 localCache 功能。
下面是將 mybatis.configuration.local-cache-scope 配置為 statement 后再執(zhí)行 test1 的輸出,每次都打印了 SQL,且 hashCode 都不一樣,說明緩存沒有起作用。

二級緩存
二級緩存是 namespace 級別的(或者說是 Mapper 級別的,如下 xml),與一級緩存類似,在以相同的參數(shù)執(zhí)行多次同一個查詢方法時,實際只會在第一次時進行數(shù)據(jù)庫 select 查詢,后續(xù)會直接從緩存中返回。如果執(zhí)行同一個 namespace 中的 DML 語句(比如 delete、insert、update)的話,會清空 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>
二級緩存由 mybatis.configuration.cache-enabled 控制,默認(rèn)為 true。除此之外還需要在要開啟二級緩存的 Mapper.xml 中添加 <cache/> 表情才能開啟對應(yīng) Mapper 的二級緩存。
下面是在關(guān)閉一級緩存,且只開啟 StudentMapper.xml 二級緩存的情況下的測試:
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 的二級緩存,所以每次查詢 teacher 都打印了 SQL,且 hashCode 不相同,說明 teacher 的緩存沒起作用。
第 ① 次查詢 student 打印了 SQL,直接查詢了數(shù)據(jù)庫,這是正常的,因為此時緩存中沒有數(shù)據(jù)。但第 ② 次查詢 student 也沒有走緩存,也直接查詢了數(shù)據(jù)庫,這是為啥?是因為二級緩存不是在執(zhí)行完 select 后立即填充的,是要等到事務(wù)提交之后才會填充緩存。
從最后幾行的輸出能看出最后兩次查詢 student 確實走了緩存,并且還打印了緩存命中率。這是因為第一次調(diào)用 test1 結(jié)束后事務(wù)提交了,數(shù)據(jù)被填充到了緩存里。

測試無事務(wù)時的效果
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ù)據(jù)庫,其他三次都命中了緩存。

兩級緩存可能導(dǎo)致的問題
分布式環(huán)境下查詢到過期數(shù)據(jù)
假設(shè)支付服務(wù) A 有兩個實例 A1、A2,負(fù)載均衡采用輪訓(xùn)策略,第一次查詢余額訪問 A1 返回 100000,第二次消費 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 啟動兩個實例,如下輸出:

要解決這個問題很簡單,就是不使用緩存,比如 mybatis.configuration.cache-enabled=false 或者將 AccountMapper.xml 中的 <cache/> 標(biāo)簽刪除。
事務(wù)隔離級別失效
讀已提交失效
在開發(fā)中經(jīng)常有這種場景:先判斷是否存在,如果不存在再插入。這種判斷再插入的操作不是原子的,多線程會有問題,所以需要加鎖保證操作的安全性。在讀多寫少的場景中,會使用 double check 來盡可能的減少用鎖的使用,偽代碼如下:
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 的測試
application.properties
還原成默認(rèn)值,且刪除 AccountMapper.xml 中的 <cache/> 標(biāo)簽,用以關(guān)閉 AccountMapper 的二級緩存。
... mybatis.configuration.local-cache-scope=session mybatis.configuration.cache-enabled=true
注意這里使用的隔離級別為讀已提交
@PutMapping("/accounts/{id}")
// double check 需要使用讀已提交隔離級別才能讀到最新數(shù)據(jù)
@Transactional(rollbackFor = Exception.class, isolation = Isolation.READ_COMMITTED)
public Account createAccount(@PathVariable("id") Integer id) throws InterruptedException {
Account account = accountMapper.selectByPrimaryKey(id);
// 等待多個請求到達(dá)
TimeUnit.SECONDS.sleep(5);
// 如果賬戶不存在,需要加分布式鎖后進行 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;
}
同時發(fā)起兩個 Put 請求 http://localhost:8080/accounts/2。一個正常返回,另一個在 insert 時報錯 Duplicate entry ‘2’ for key ‘account.PRIMARY’,說明讀已提交的隔離級別沒起作用,第二個請求沒有讀到最新的數(shù)據(jù)。
一級緩存實際起到了類似可重復(fù)讀的效果。

兩個請求(線程分別為 nio-8080-exec-3、nio-8080-exec-4)執(zhí)行了 3 次(第一個請求 1 次,第二個請求 2 次) accountMapper.selectByPrimaryKey(id),但每個線程都只打印了 1 次 SQL,說明第二個請求的第 2 次查詢走了緩存,導(dǎo)致沒有查詢到第一個請求插入的最新數(shù)據(jù),才導(dǎo)致的后來的報錯。

解決辦法
最簡單辦法就是修改
mybatis.configuration.local-cache-scope=statement,直接關(guān)閉一級緩存。直接去掉@Transactional注解肯定能解決問題,但如果createAccount0方法中操作多張表的話,如果部分失敗事務(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); // 等待多個請求到達(dá) TimeUnit.SECONDS.sleep(5); // 如果賬戶不存在,需要加分布式鎖后進行 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ù)的話,由于一級緩存只有在同一個事務(wù)中才會生效,所以可以將兩個
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); // 等待多個請求到達(dá) TimeUnit.SECONDS.sleep(5); // 如果賬戶不存在,需要加分布式鎖后進行 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); }
讀未提交失效
同樣的由于一級緩存的存在,讀未提交也讀不到最新的未提交數(shù)據(jù)。
讀未提交 查詢 Account 的測試
application.properties
還原成默認(rèn)值,且刪除 AccountMapper.xml 中的 <cache/> 標(biāo)簽,用以關(guān)閉 AccountMapper 的二級緩存。
... 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;
}
先請求 getAccount 再請求 createAccount,從輸出中可以看出,在使用讀未提交的情況下,account2 依舊為 null,走了緩存,導(dǎo)致讀未提交失效。

解決辦法
最簡單辦法就是修改
mybatis.configuration.local-cache-scope=statement,直接關(guān)閉一級緩存。由于一級緩存只有在同一個事務(wù)中才會生效,所以可以將兩個
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é)
一級緩存是事務(wù)級別的,實際起到了類似可重復(fù)讀的效果,而且比可重復(fù)讀的性能更好,因為多次查詢的話不會請求數(shù)據(jù)庫了。在事務(wù)隔離級別是可重復(fù)讀時使用一級緩存能提高性能。但就因為其類似可重復(fù)讀的效果會導(dǎo)致其他的隔離級別失效。要解決失效的問題,最簡單方式就是關(guān)閉一級緩存,但這樣會損失性能。另一個解決辦法是將需要使用其他隔離級別的方法使用 propagation = Propagation.REQUIRES_NEW 拆分到新的事務(wù)中。如果是讀已提交的話可通過縮小事務(wù)范圍的方式解決。
一級緩存是事務(wù)級別的,緩存的生命周期較短,但二級緩存是 namespace (Mapper)級別的,生命周期可能很長,在分布式、多實例環(huán)境中很容易查詢到過期的數(shù)據(jù),導(dǎo)致其他問題。我個人建議在分布式、多實例環(huán)境中應(yīng)該設(shè)置 mybatis.configuration.cache-enabled=false 來關(guān)閉二級緩存,從根源上杜絕這種問題。
到此這篇關(guān)于Mybatis兩級緩存可能導(dǎo)致問題的文章就介紹到這了,更多相關(guān)Mybatis兩級緩存問題內(nèi)容請搜索腳本之家以前的文章或繼續(xù)瀏覽下面的相關(guān)文章希望大家以后多多支持腳本之家!
相關(guān)文章
Spring boot通過AOP防止API重復(fù)請求代碼實例
這篇文章主要介紹了Spring boot通過AOP防止API重復(fù)請求代碼實例,文中通過示例代碼介紹的非常詳細(xì),對大家的學(xué)習(xí)或者工作具有一定的參考學(xué)習(xí)價值,需要的朋友可以參考下2019-12-12
springboot如何使用thymeleaf模板訪問html頁面
springboot中推薦使用thymeleaf模板,使用html作為頁面展示。那么如何通過Controller來訪問來訪問html頁面呢?下面通過本文給大家詳細(xì)介紹,感興趣的朋友跟隨腳本之家小編一起看看吧2018-05-05
超詳細(xì)講解SpringBoot參數(shù)校驗實例
經(jīng)常需要提供接口與用戶交互(獲取數(shù)據(jù)、上傳數(shù)據(jù)等),由于這個過程需要用戶進行相關(guān)的操作,為了避免出現(xiàn)一些錯誤的數(shù)據(jù)等,一般需要對數(shù)據(jù)進行校驗,下面這篇文章主要給大家介紹了關(guān)于SpringBoot各種參數(shù)校驗的相關(guān)資料,需要的朋友可以參考下2022-05-05
SpringBoot開發(fā)案例 分布式集群共享Session詳解
這篇文章主要介紹了SpringBoot開發(fā)案例 分布式集群共享Session詳解,在分布式系統(tǒng)中,為了提升系統(tǒng)性能,通常會對單體項目進行拆分,分解成多個基于功能的微服務(wù),可能還會對單個微服務(wù)進行水平擴展,保證服務(wù)高可用,需要的朋友可以參考下2019-07-07
使用mybatis-plus的insert方法遇到的問題及解決方法(添加時id值不存在異常)
這篇文章主要介紹了使用mybatis-plus的insert方法遇到的問題及解決方法(添加時id值不存在異常),本文給大家介紹的非常詳細(xì),對大家的學(xué)習(xí)或工作具有一定的參考借鑒價值,需要的朋友可以參考下2020-08-08
Java 互相關(guān)聯(lián)的實體無限遞歸問題的解決
這篇文章主要介紹了Java 互相關(guān)聯(lián)的實體無限遞歸問題的解決,具有很好的參考價值,希望對大家有所幫助。如有錯誤或未考慮完全的地方,望不吝賜教2021-10-10

