MYBATIS · LOCAL CACHE · SECOND LEVEL CACHE · CAFFEINE
三层缓存,离数据库越远越快,也越容易旧
一次查询从左往右找,哪一层有就在哪一层停下。MyBatis 自带两层:一级缓存和二级缓存。Caffeine 是你在 Service 层自己加的一层,有时还挡在它们前面。
@Cacheable · LoadingCache放在 Service 层,命中了连 Mapper 都不调。
整个应用 · 你来管<cache/> · 按 namespace同一个 Mapper 的所有会话共用。
一个 Mapper · 默认关localCache · 按 SqlSession会话里的小本子,会话一关就没了。
一次会话 · 默认开MySQL …唯一可信的数据。前面几层都只是它的复印件。
真数据PerpetualCache(一级)每个 SqlSession 一本,其实就是一个 HashMap。
默认开,关不掉,只能缩小范围Cache(二级)每个 Mapper 一个,所有会话共用。默认 LRU,最多 1024 条。
要在 XML 里写 <cache/>TransactionalCache查到的结果先放这儿,会话提交或关闭后才搬进公共柜子。
每个会话一份,回滚就扔掉本地缓存库不属于 MyBatis。按业务 key 缓存任何对象,淘汰算法更聪明。
常和 Spring 的 @Cacheable 一起用一级缓存:会话里的小本子
同一个 SqlSession 里,同样的 SQL、同样的参数再查一次,就直接从小本子里拿,不碰数据库。
返回的是本子里那个对象本身。你改了它的字段,同一个会话里后面再查,拿到的也是改过的。
语句 idcom.shop.mapper.OrderMapper.selectByIdoffset · limitRowBounds,默认 0 和 2147483647SQL拼好的最终 SQL,带问号参数值每个问号对应的值,这里是 7环境 idSpring 里默认是 SqlSessionFactoryBean什么时候整本清空
二级缓存:Mapper 的公共柜子
打开以后,同一个 Mapper 的查询结果,所有会话都能共用。查询顺序是先二级、再一级、最后数据库。
<cache
eviction="LRU" <!-- 淘汰:LRU / FIFO / SOFT / WEAK,默认 LRU -->
size="1024" <!-- 最多存多少条,默认 1024 -->
flushInterval="60000" <!-- 每隔多久整个清空,默认不清 -->
readOnly="false" <!-- false:每次给一份副本,实体要能序列化 -->
blocking="false"/> <!-- true:同一个 key 同时只放一个人去查库 -->
<!-- 注解方式:在接口上写 @CacheNamespace -->
<!-- 想和别的 Mapper 共用一个柜子:<cache-ref namespace="…UserMapper"/> -->
结果不是马上进柜子:先进暂存区
否则一个还没提交、甚至最后会回滚的事务,就会把数据放进大家共用的柜子。
delegate.query(…)先照常放进一级缓存TransactionalCache.entriesToAddOnCommit每个会话一份,别的会话看不到二级缓存大家都能命中。回滚的话暂存区直接扔掉本 Mapper 的 insert / update / delete 默认 flushCache="true":先在暂存区记一笔“提交时清空”,提交时把整个柜子清掉。
readOnly="false" 时,存进去先序列化,取出来再反序列化,每次都是新对象。实体没实现 Serializable 就报错。
两台机器各有各的柜子。底座可以用 <cache type="…"/> 换成 Redis,或者换成 Caffeine。
缓存实验台
选一个剧情,一步一步看:每个会话的一级缓存和暂存区里有什么,公共柜子里有什么,数据库被查了几次,最后拿到的是不是新数据。
数据库
这一步拿到的
selectById(7)和 Caffeine 逐项比
一级、二级缓存是 MyBatis 替你缓存“某条 SQL 的结果”;Caffeine 是你自己决定缓存什么、缓存多久、什么时候删。
| 一级缓存 | 二级缓存 | Caffeine | |
|---|---|---|---|
| 是什么 | SqlSession 里的一个 HashMap | Mapper 级别的缓存,默认是 LRU 包着 HashMap | 独立的本地缓存库 |
| 放在哪层 | Executor 里面 | Executor 外面那层 CachingExecutor | 你的代码里,通常是 Service 方法上 |
| 缓存什么 | 某条 SQL 的结果列表 | 同左,按 Mapper 分开存 | 任何对象,比如组装好的 OrderVO |
| key | CacheKey:语句 + SQL + 参数 … | 同左 | 你定,比如订单 id |
| 默认 | 开 | 关,写 <cache/> 才开 | 要自己引依赖、自己配 |
| 谁能看到 | 只有这一个会话 | 这台机器上所有会话 | 这台机器上所有线程 |
| 满了怎么办 | 不会满,会话结束就扔 | LRU(默认)、FIFO、SOFT、WEAK,默认 1024 条 | W-TinyLFU,按条数或按权重限制 |
| 过期 | 无 | flushInterval:到点把整个柜子清空 | 每条单独过期:expireAfterWrite、expireAfterAccess;还能 refreshAfterWrite 在后台刷新 |
| 数据变了 | 本会话增删改就清空 | 本 Mapper 增删改就整个清空,别的 Mapper 改了不管 | 你自己删,精确到某个 key |
| 拿到的对象 | 同一个引用 | 默认是反序列化出来的副本 | 同一个引用 |
| 热点 key 失效那一刻 | 不涉及 | blocking="true" 时按 key 加锁 | get(key, loader):同一个 key 只加载一次,其他人等着 |
| 命中率 | 看不到 | DEBUG 日志里打印 | recordStats():命中率、加载耗时、淘汰数 |
| 多台机器 | 不涉及 | 各存各的,会不一致 | 也是各存各的,要靠短过期或消息通知删除 |
同样 4 个格子,LRU 和 W-TinyLFU 谁命中得多
二级缓存默认用 LRU:满了就淘汰最久没用的。Caffeine 用 W-TinyLFU:新来的先进一个小窗口,想进主区,得比主区里要被淘汰的那个用得更频繁。下面这串访问里,A、B 是热点,中间夹着一串只来一次的 X。
LRU
0 / 0 命中W-TinyLFU(简化版)
0 / 0 命中简化版:真实的 Caffeine 用 Count-Min Sketch 估算频次,会定期把频次减半,让过气的热点退场;主区还分成试用区和保护区;窗口默认只占总容量的 1%。这里为了看得清,窗口 1 格、主区 3 格、频次精确计数。
# application.yml(还要在启动类上加 @EnableCaching)
spring:
cache:
type: caffeine
cache-names: orders
caffeine:
spec: maximumSize=10000,expireAfterWrite=10m,recordStats
@Cacheable(cacheNames = "orders", key = "#id", sync = true)
public OrderVO get(Long id) { // 命中就不调 Mapper
return toVO(orderMapper.selectById(id));
}
@CacheEvict(cacheNames = "orders", key = "#id")
public void updateAmount(Long id, BigDecimal amount) {
orderMapper.updateAmount(id, amount); // 只删这一个 key
}
LoadingCache<Long, OrderVO> cache = Caffeine.newBuilder()
.maximumSize(10_000)
.expireAfterWrite(10, TimeUnit.MINUTES)
.recordStats()
.build(id -> toVO(orderMapper.selectById(id))); // 没命中时怎么加载
OrderVO vo = cache.get(7L); // 同一个 key 并发来,只加载一次
cache.invalidate(7L); // 数据改了就删掉
double rate = cache.stats().hitRate();
怎么选
大多数项目的做法:一级缓存别管它,二级缓存关着,热点数据在 Service 层用 Caffeine 自己缓存。
一级缓存
关不掉,也不用关。只要记住:事务里重复查会命中,可能看不到别人刚改的数据。
一定要每次都查库:local-cache-scope=statement。
二级缓存
只适合单机部署、读多写少、只查单张表、能容忍短暂旧数据的 Mapper。
一改就清整个 Mapper,联表和多机都会读到旧数据。
Caffeine
字典、配置、商品详情这类读多写少的数据,按业务 key 缓存,数据改了精确删除。
多台机器时配短过期,或者本地 Caffeine + Redis 做两级。
JDK 8 和 JDK 17
MyBatis 的两层缓存在 3.5.x 里完全一样。差别在 Caffeine 的大版本。
Spring Boot 2
JDK 8+- Caffeine 只能用 2.x,最后一个版本是 2.9.3,Boot 2.7 管理的就是它
- 缓存的值通常是普通 POJO,从 Caffeine 拿到的是同一个引用,别在外面改它
Spring Boot 3
JDK 17+- Caffeine 3.x,要求 JDK 11+,Boot 3 管理的就是 3.x
- 可以用
record当缓存的值:天生不可变,Caffeine 返回同一个引用也不怕被改
常踩的坑
缓存的坑几乎都是同一种:数据已经变了,缓存还是旧的。
一级缓存命中了,再加上 MySQL 默认的可重复读。真要读最新的,配 local-cache-scope=statement,或者把这次查询放到事务外。
一级缓存和 Caffeine 都返回同一个引用。要改就先复制一份,或者缓存不可变对象。
开了二级缓存,默认要序列化,实体没实现 Serializable。给实体实现它,或者设 readOnly="true"(之后拿到的是同一个引用)。
结果缓存在 OrderMapper 名下,用户表是 UserMapper 改的,清不到这边。用 <cache-ref> 共用一个柜子,或者这条查询 useCache="false"。
柜子在各自的 JVM 里。要么关掉,要么换成共享的 Redis,要么设 flushInterval 只容忍几秒。
正常现象:结果还在暂存区,事务提交后才进柜子。第二次其实是一级缓存命中的。
没加 @EnableCaching,或者同一个类里自己调自己,没经过代理。和事务注解一样的原因(Spring 第 02 篇)。
几百个请求同时没命中,同时查库。用 @Cacheable(sync = true) 或 LoadingCache.get(key),同一个 key 只放一个去查。
删除早于提交,另一个线程趁这个空档从库里读到旧值放回缓存。改成提交之后再删,比如用 TransactionAwareCacheManagerProxy 包一下 CacheManager。
没设过期,只要不被挤掉就一直是旧的。至少配一个 expireAfterWrite 兜底。