【不懂就問】MyBatis的一級緩存居然還會引來麻煩?

「不懂就問」,是一個新的系列,主要整理個人小冊羣裏遇到的一些比較有意思的 / 有難度的 / 容易被討論起來的問題,並給出問題的解析和方案等等。喜歡的小夥伴們能夠點贊關注我鴨 ~ ~ 學習源碼能夠看看個人小冊 ~ ~】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

核心的 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');
複製代碼

編寫測試代碼

咱使用一個最簡單的單表模型,快速復現場景。

entity

新建一個 Department 類,並聲明 id 和 name 屬性:

public class Department {
    
    private String id;
    private String name;
    
    // getter setter toString ......
}
複製代碼
mapper

MyBatis 的接口動態代理方式能夠快速聲明查詢的 statement ,咱只須要聲明一個 findById 便可:

@Mapper
public interface DepartmentMapper {
    Department findById(String id);
}
複製代碼
mapper.xml

對應的,接口須要 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

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

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 關閉:在 mapper.xml 的指定 statement 上標註 flushCache="true"
  • 另類的辦法:在 statement 的 SQL 上添加一串隨機數(過於非主流。。。)

    select * from sys_department where #{random} = #{random}


原理擴展

其實到這裏,問題就已經解決了,但先不要着急,思考一個問題:爲何聲明瞭 local-cache-scopestatement ,或者mapper 的 statement 標籤中設置 flushCache=true ,一級緩存就被禁用了呢?下面咱來了解下這背後的原理。

一級緩存失效的原理

DepartmentService 中,執行 mapper.findById 的動做,最終會進入到 DefaultSqlSessionselectOne 中:

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 ,則查詢以後即使放入了一級緩存,但存放完立馬就給清了,下一次仍是要查數據庫;
  • statement 設置 flushCache="true" ,則查詢以前先清空一級緩存,仍是得查數據庫;
  • 設置隨機數,若是隨機數的上限足夠大,那隨機到相同數的機率就足夠低,也能相似的當作不一樣的數據庫請求,那緩存的 key 都不同,天然就不會匹配到緩存。

本文涉及到的全部源碼能夠從 GitHub 中找到:github.com/LinkedBear/…

【都看到這裏了,小夥伴們要不要關注點贊一下呀,有源碼學習須要的能夠看我小冊哦,學習起來 ~ 奧利給】

相關文章
相關標籤/搜索