【「不懂就問」,是一個新的系列,主要整理個人小冊羣裏遇到的一些比較有意思的 / 有難度的 / 容易被討論起來的問題,並給出問題的解析和方案等等。喜歡的小夥伴們能夠點贊關注我鴨 ~ ~ 學習源碼能夠看看個人小冊 ~ ~】java
端午假期相信很多小夥伴都在偷偷學習吧(說好了放假一塊兒玩耍呢,結果又揹着我學習),這不,剛過了端午,個人一個沙雕程序猿圈子裏就有人討論起來問題了,這個問題聊起來好像挺麻煩,但實際上問題是很簡單的,下面咱來討論下這個問題。git
MyBatis 一級緩存與 SpringFramework 的聲明式事務有衝突嗎?在 Service 中開啓事務,連續查詢兩次一樣的數據,結果兩次查詢的結果不一致。github
—— 使用 Mapper 的 selectById
查出來實體,而後修改實體的屬性值,而後再 selectById
一下查出來實體,對比一下以前查出來的,發現查出來的是剛纔修改過的實體,不是從數據庫查出來的。web
—— 若是不開啓事務,則兩次請求查詢的結果是相同的,控制檯打印了兩次 SQL 。spring
講道理,看到這個問題,我一會兒就猜到是 MyBatis 一級緩存重複讀取的問題了。sql
MyBatis 的一級緩存默認開啓,屬於
SqlSession
做用範圍。在事務開啓的期間,一樣的數據庫查詢請求只會查詢一次數據庫,以後重複查詢會從一級緩存中獲取。當不開啓事務時,一樣的屢次數據庫查詢都會發送數據庫請求。數據庫
上面的都屬於基礎知識了,很少解釋。重點是,他修改的實體是直接從 MyBatis 的一級緩存中查詢出來的。咱都知道,查詢出來的這些實體確定屬於對象,拿到的是對象的引用,咱在 Service 裏修改了,一級緩存中相應的也就會被影響。因而可知,這個問題的核心緣由也就很容易找到了。瀏覽器
爲了展現這個問題,咱仍是簡單復現一下場景吧。緩存
咱使用 SpringBoot + mybatis-spring-boot-starter
快速構建出工程,此處 SpringBoot 版本爲 2.2.8 ,mybatis-spring-boot-starter
的版本爲 2.1.2 。markdown
核心的 pom 依賴有 3 個:
<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>org.mybatis.spring.boot</groupId> <artifactId>mybatis-spring-boot-starter</artifactId> <version>2.1.2</version> </dependency> <dependency> <groupId>com.h2database</groupId> <artifactId>h2</artifactId> <version>1.4.199</version> </dependency> 複製代碼
數據庫我們依然選用 h2 做爲快速問題復現的數據庫,只須要在 application.properties 中添加以下配置,便可初始化一個 h2 數據庫。順便的,咱把 MyBatis 的配置也簡單配置好:
spring.datasource.driver-class-name=org.h2.Driver
spring.datasource.url=jdbc:h2:mem:mybatis-transaction-cache
spring.datasource.username=sa
spring.datasource.password=sa
spring.datasource.platform=h2
spring.datasource.schema=classpath:sql/schema.sql
spring.datasource.data=classpath:sql/data.sql
spring.h2.console.settings.web-allow-others=true
spring.h2.console.path=/h2
spring.h2.console.enabled=true
mybatis.type-aliases-package=com.linkedbear.demo.entity
mybatis.mapper-locations=classpath:mapper/*.xml
複製代碼
上面咱使用了 datasource
的 schema 和 data 初始化數據庫,那天然的就應該有這兩個 .sql 文件。
schema.sql
:
create table if not exists sys_department ( id varchar(32) not null primary key, name varchar(32) not null ); 複製代碼
data.sql
:
insert into sys_department (id, name) values ('idaaa', 'testaaa'); insert into sys_department (id, name) values ('idbbb', 'testbbb'); insert into sys_department (id, name) values ('idccc', 'testccc'); insert into sys_department (id, name) values ('idddd', 'testddd'); 複製代碼
咱使用一個最簡單的單表模型,快速復現場景。
新建一個 Department 類,並聲明 id 和 name 屬性:
public class Department { private String id; private String name; // getter setter toString ...... } 複製代碼
MyBatis 的接口動態代理方式能夠快速聲明查詢的 statement ,咱只須要聲明一個 findById
便可:
@Mapper public interface DepartmentMapper { Department findById(String id); } 複製代碼
對應的,接口須要 xml 做爲照應:(此處並無使用註解式 Mapper )
<?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="com.linkedbear.demo.mapper.DepartmentMapper"> <select id="findById" parameterType="string" resultType="department"> select * from sys_department where id = #{id} </select> </mapper> 複製代碼
Service 中注入 Mapper ,並編寫一個須要事務的 update
方法,模擬更新動做:
@Service public class DepartmentService { @Autowired DepartmentMapper departmentMapper; @Transactional(rollbackFor = Exception.class) public Department update(Department department) { Department temp = departmentMapper.findById(department.getId()); temp.setName(department.getName()); Department temp2 = departmentMapper.findById(department.getId()); System.out.println("兩次查詢的結果是不是同一個對象:" + temp == temp2); return temp; } } 複製代碼
Controller 中注入 Service ,並調用 Service 的 update
方法來觸發測試:
@RestController public class DepartmentController { @Autowired DepartmentService departmentService; @GetMapping("/department/{id}") public Department findById(@PathVariable("id") String id) { Department department = new Department(); department.setId(id); department.setName(UUID.randomUUID().toString().replaceAll("-", "")); return departmentService.update(department); } } 複製代碼
主啓動類中不須要什麼特別的內容,只須要記得開啓事務就好:
@EnableTransactionManagement @SpringBootApplication public class MyBatisTransactionCacheApplication { public static void main(String[] args) { SpringApplication.run(MyBatisTransactionCacheApplication.class, args); } } 複製代碼
以 Debug 方式運行 SpringBoot 的主啓動類,在瀏覽器中輸入 http://localhost:8080/h2
輸入剛纔在 application.properties
中聲明的配置,便可打開 h2 數據庫的管理臺。
執行 SELECT * FROM SYS_DEPARTMENT
,能夠發現數據已經成功初始化了:
下面測試效果,在瀏覽器中輸入 http://localhost:8080/department/idaaa
,控制檯中打印的結果爲 true ,證實 MyBatis 的一級緩存生效,兩次查詢最終獲得的實體類對象一致。
對於這個問題的解決方案,其實說白了,就是關閉一級緩存。最多見的幾種方案列舉一下:
mybatis.configuration.local-cache-scope=statement
mapper.xml
的指定 statement 上標註 flushCache="true"
select * from sys_department where #{random} = #{random}
其實到這裏,問題就已經解決了,但先不要着急,思考一個問題:爲何聲明瞭 local-cache-scope
爲 statement
,或者mapper 的 statement 標籤中設置 flushCache=true
,一級緩存就被禁用了呢?下面咱來了解下這背後的原理。
在 DepartmentService
中,執行 mapper.findById
的動做,最終會進入到 DefaultSqlSession
的 selectOne
中:
public <T> T selectOne(String statement) { return this.selectOne(statement, null); } @Override public <T> T selectOne(String statement, Object parameter) { // Popular vote was to return null on 0 results and throw exception on too many. List<T> list = this.selectList(statement, parameter); if (list.size() == 1) { return list.get(0); } else if (list.size() > 1) { throw new TooManyResultsException("Expected one result (or null) to be returned by selectOne(), but found: " + list.size()); } else { return null; } } 複製代碼
可見 selectOne
的底層是調用的 selectList
,以後 get(0)
取出第一條數據返回。
selectList
的底層會有兩個步驟:獲取 MappedStatement
→ 執行查詢,以下代碼中的 try
部分:
public <E> List<E> selectList(String statement, Object parameter, RowBounds rowBounds) { try { MappedStatement ms = configuration.getMappedStatement(statement); return executor.query(ms, wrapCollection(parameter), rowBounds, Executor.NO_RESULT_HANDLER); } catch (Exception e) { throw ExceptionFactory.wrapException("Error querying database. Cause: " + e, e); } finally { ErrorContext.instance().reset(); } } 複製代碼
執行 query
方法,來到 BaseExecutor
中,它會執行三個步驟:獲取預編譯的 SQL → 建立緩存鍵 → 真正查詢。
public <E> List<E> query(MappedStatement ms, Object parameter, RowBounds rowBounds, ResultHandler resultHandler) throws SQLException { BoundSql boundSql = ms.getBoundSql(parameter); CacheKey key = createCacheKey(ms, parameter, rowBounds, boundSql); return query(ms, parameter, rowBounds, resultHandler, key, boundSql); } 複製代碼
這裏面的緩存鍵是有必定設計的,它的結構能夠簡單的當作 「 statementId + SQL + 參數 」 的形式,根據這三個要素,就能夠惟一的肯定出一個查詢結果。
到了這裏面的 query
方法,它就帶着這個緩存鍵,執行真正的查詢動做了,以下面的這段長源碼:(注意看源碼中的註釋)
public <E> List<E> query(MappedStatement ms, Object parameter, RowBounds rowBounds, ResultHandler resultHandler, CacheKey key, BoundSql boundSql) throws SQLException { ErrorContext.instance().resource(ms.getResource()).activity("executing a query").object(ms.getId()); if (closed) { throw new ExecutorException("Executor was closed."); } // 若是statement有設置flushCache="true",則查詢以前先清理一級緩存 if (queryStack == 0 && ms.isFlushCacheRequired()) { clearLocalCache(); } List<E> list; try { queryStack++; // 先檢查一級緩存 list = resultHandler == null ? (List<E>) localCache.getObject(key) : null; if (list != null) { // 若是一級緩存中有,則直接取出 handleLocallyCachedOutputParameters(ms, key, parameter, boundSql); } else { // 一級緩存沒有,則查詢數據庫 list = queryFromDatabase(ms, parameter, rowBounds, resultHandler, key, boundSql); } } finally { queryStack--; } if (queryStack == 0) { for (DeferredLoad deferredLoad : deferredLoads) { deferredLoad.load(); } // issue #601 deferredLoads.clear(); // 若是全局配置中有設置local-cache-scope=statement,則清除一級緩存 if (configuration.getLocalCacheScope() == LocalCacheScope.STATEMENT) { // issue #482 clearLocalCache(); } } return list; } 複製代碼
上面的註釋中,能夠發現,只要上面的三個解決方案,任選一個配置,則一級緩存就會失效,分別分析下:
local-cache-scope=statement
,則查詢以後即使放入了一級緩存,但存放完立馬就給清了,下一次仍是要查數據庫;flushCache="true"
,則查詢以前先清空一級緩存,仍是得查數據庫;本文涉及到的全部源碼能夠從 GitHub 中找到:github.com/LinkedBear/…
【都看到這裏了,小夥伴們要不要關注點贊一下呀,有源碼學習須要的能夠看我小冊哦,學習起來 ~ 奧利給】