本篇我們來講 一級緩存,重點關注它的實現原理:何時生效、生效範圍和何時失效,在未來設計緩存使用時,提供一些借鑑和參考。
1. 準備工作
定義實體
public class Department {
public Department(String id) {
this.id = id;
}
private String id;
/**
* 部門名稱
*/
private String name;
/**
* 部門電話
*/
private String tel;
/**
* 部門成員
*/
private Set<User> users;
}
<!---->
public class User {
private String id;
private String name;
private Integer age;
private LocalDateTime birthday;
private Department department;
}
定義 Mapper.xml
DepartmentMapper.xml,兩條 SQL:一條根據 ID 查詢;一條清除緩存,標記了 fulshCache 標籤,將其設置為 true 後,只要語句被調用,都會將本地緩存和二級緩存清空(默認值為 false)
<select id="findById" resultType="Department">
select * from department
where id = #{id}
</select>
<select id="cleanCathe" resultType="int" flushCache="true">
select count(department.id) from department;
</select>
UserMapper.xml,聯表查詢用户信息:
<select id="findAll" resultMap="userMap">
select u.*, td.id, td.name as department_name
from user u
left join department td
on u.department_id = td.id
</select>
2. 一級緩存
一級緩存的生效範圍 SqlSession 級別的,不同 SqlSession 間不共享緩存,它默認情況下是啓用的。主要作用是減少在同一個查詢 SQL 會話中對數據庫的重複查詢,從而提高性能。以如下用例為例:
public static void main(String[] args) throws IOException {
InputStream xml = Resources.getResourceAsStream("mybatis-config.xml");
SqlSessionFactoryBuilder sqlSessionFactoryBuilder = new SqlSessionFactoryBuilder();
// 開啓二級緩存需要在同一個SqlSessionFactory下,二級緩存存在於 SqlSessionFactory 生命週期,如此才能命中二級緩存
SqlSessionFactory sqlSessionFactory = sqlSessionFactoryBuilder.build(xml);
SqlSession sqlSession = sqlSessionFactory.openSession();
DepartmentMapper departmentMapper = sqlSession.getMapper(DepartmentMapper.class);
System.out.println("----------department第一次查詢 ↓------------");
departmentMapper.findById("18ec781fbefd727923b0d35740b177ab");
System.out.println("----------department一級緩存生效,控制枱看不見SQL ↓------------");
departmentMapper.findById("18ec781fbefd727923b0d35740b177ab");
}
可以發現在第二次查詢時,一級緩存生效,控制枱沒有出現SQL:
而我們清空下一級緩存再試試:
public static void main(String[] args) throws IOException {
InputStream xml = Resources.getResourceAsStream("mybatis-config.xml");
SqlSessionFactoryBuilder sqlSessionFactoryBuilder = new SqlSessionFactoryBuilder();
// 開啓二級緩存需要在同一個SqlSessionFactory下,二級緩存存在於 SqlSessionFactory 生命週期,如此才能命中二級緩存
SqlSessionFactory sqlSessionFactory = sqlSessionFactoryBuilder.build(xml);
SqlSession sqlSession = sqlSessionFactory.openSession();
DepartmentMapper departmentMapper = sqlSession.getMapper(DepartmentMapper.class);
System.out.println("----------department第一次查詢 ↓------------");
departmentMapper.findById("18ec781fbefd727923b0d35740b177ab");
System.out.println("----------department一級緩存生效,控制枱看不見SQL ↓------------");
departmentMapper.findById("18ec781fbefd727923b0d35740b177ab");
System.out.println("----------清除一級緩存 ↓------------");
departmentMapper.cleanCathe();
System.out.println("----------清除後department再一次查詢,SQL再次出現 ↓------------");
departmentMapper.findById("18ec781fbefd727923b0d35740b177ab");
}
控制枱日誌很清晰,清除緩存後又重新查了一遍:
接下來我們看一下不同 SqlSession 間一級緩存是否共享,創建一個新的 SqlSession sqlSession1 執行相同的SQL:
public static void main(String[] args) throws IOException {
SqlSession sqlSession = sqlSessionFactory.openSession();
SqlSession sqlSession1 = sqlSessionFactory.openSession();
DepartmentMapper departmentMapper = sqlSession.getMapper(DepartmentMapper.class);
DepartmentMapper departmentMapper1 = sqlSession1.getMapper(DepartmentMapper.class);
System.out.println("----------department第一次查詢 ↓------------");
departmentMapper.findById("18ec781fbefd727923b0d35740b177ab");
System.out.println("----------sqlSession1下department執行相同的SQL,控制枱出現SQL ↓------------");
departmentMapper1.findById("18ec781fbefd727923b0d35740b177ab");
}
如控制枱日誌所示,可以發現在不同的 SqlSession 下不共享一級緩存:
3. 一級緩存原理
一級緩存在查詢方法 org.apache.ibatis.executor.BaseExecutor#query 中生效,如下所示:
public abstract class BaseExecutor implements Executor {
// ...
// 一級緩存
protected PerpetualCache localCache;
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.");
}
// 判斷是否刷新本地緩存
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();
if (configuration.getLocalCacheScope() == LocalCacheScope.STATEMENT) {
// issue #482
clearLocalCache();
}
}
return list;
}
private <E> List<E> queryFromDatabase(MappedStatement ms, Object parameter, RowBounds rowBounds,
ResultHandler resultHandler, CacheKey key, BoundSql boundSql) throws SQLException {
List<E> list;
// 一級緩存佔位
localCache.putObject(key, EXECUTION_PLACEHOLDER);
try {
list = doQuery(ms, parameter, rowBounds, resultHandler, boundSql);
} finally {
// 查詢完成後清除一級緩存
localCache.removeObject(key);
}
// 添加到一級緩存中
localCache.putObject(key, list);
// 存儲過程相關邏輯
if (ms.getStatementType() == StatementType.CALLABLE) {
localOutputParameterCache.putObject(key, parameter);
}
return list;
}
}
其中 PerpetualCache localCache 便是一級緩存,它的實現藉助了 HashMap:
public class PerpetualCache implements Cache {
private final String id;
private final Map<Object, Object> cache = new HashMap<>();
// ...
}
一級緩存生效的邏輯也非常簡單,如下所示:
queryFromDatabase 方法中有一段蠻有意思的邏輯 localCache.putObject(key, EXECUTION_PLACEHOLDER);:在添加一級緩存前會先添加緩存 佔位符 EXECUTION\_PLACEHOLDER,但是這個佔位符並沒有被用作一個明確的同步機制來阻止其他線程的查詢執行,所以它只是標記一個查詢正在進行,提供了 防止在同一事務上下文中重複執行相同的查詢的 基礎,這種設計可能是 MyBatis 開發者認為在多數情況下,數據庫查詢的開銷相對較小或同一事務中幾乎不執行多次相同的查詢,而不是為了在多線程環境下保證不擊穿數據庫,降低數據庫的壓力。
這種設計模式在分佈式緩存系統中很常見,一般用於 解決 ”緩存擊穿“ 問題,幫助系統在高併發環境下保持穩定性。
一級緩存失效場景
- 兩次相同查詢SQL間有 Insert、Delete、Update 語句執行時:Insert、Delete、Update 的 flushCache標籤 默認為 true ,執行它們時,會將一級緩存清空
- 調用
sqlSession#clearCache方法 SqlSession被關閉時,一級緩存也會被清空
緩存的是對象的引用
以如下代碼為例,第一次查詢結果中 name 字段的值為 null,將其賦值再進行第二次查詢:
System.out.println("----------department第一次查詢 ↓------------");
Department department = departmentMapper.findById("18ec781fbefd727923b0d35740b177ab");
System.out.println(department);
department.setName(" 把名字改了");
System.out.println("----------department一級緩存生效,控制枱看不見SQL ↓------------");
System.out.println(departmentMapper.findById("18ec781fbefd727923b0d35740b177ab"));
可以發現第二次查詢取緩存的結果是 更改name結果之後的:
這是因為一級緩存中 存放的數據其實是對象的引用,導致第二次從一級緩存中查詢到的數據,就是我們剛剛改過的數據,而並不是數據庫中真實的數據。在同一個 SqlSession 中,如果對緩存中返回的對象進行了修改,而沒有同步更新數據庫,那麼在後續的查詢中會返回被修改的對象,而不是數據庫中的最新數據,導致髒讀。
4. 總結
- 一級緩存基於
SqlSession,不同SqlSession間不共享一級緩存 - 一級緩存被保存在
BaseExecutor的PerpetualCache中,本質上是HashMap - 執行 Insert、Delete、Update 語句會使一級緩存失效
- 一級緩存存放的數據是對象的引用,若對它進行修改,則之後取出的緩存為修改後的數據
巨人的肩膀
- 為什麼要實現序列化:MyBatis的一級緩存、二級緩存演示以及講解,序列化異常的處理
- 為什麼MyBatis二級緩存Cache Hit Ratio始終等於0:二級緩存的生命週期在同一個SqlSessionFactory中