MYBATIS · LOCAL CACHE · SECOND LEVEL CACHE · CAFFEINE

三层缓存,离数据库越远越快,也越容易旧

一次查询从左往右找,哪一层有就在哪一层停下。MyBatis 自带两层:一级缓存和二级缓存。Caffeine 是你在 Service 层自己加的一层,有时还挡在它们前面。

Caffeine
@Cacheable · LoadingCache

放在 Service 层,命中了连 Mapper 都不调。

整个应用 · 你来管
二级缓存
<cache/> · 按 namespace

同一个 Mapper 的所有会话共用。

一个 Mapper · 默认关
一级缓存
localCache · 按 SqlSession

会话里的小本子,会话一关就没了。

一次会话 · 默认开
数据库
MySQL …

唯一可信的数据。前面几层都只是它的复印件。

真数据
更快、范围更大、更容易旧更慢、更准
小本子PerpetualCache(一级)

每个 SqlSession 一本,其实就是一个 HashMap。

默认开,关不掉,只能缩小范围
公共柜子Cache(二级)

每个 Mapper 一个,所有会话共用。默认 LRU,最多 1024 条。

要在 XML 里写 <cache/>
暂存区TransactionalCache

查到的结果先放这儿,会话提交或关闭后才搬进公共柜子。

每个会话一份,回滚就扔掉
Caffeine本地缓存库

不属于 MyBatis。按业务 key 缓存任何对象,淘汰算法更聪明。

常和 Spring 的 @Cacheable 一起用

一级缓存:会话里的小本子

同一个 SqlSession 里,同样的 SQL、同样的参数再查一次,就直接从小本子里拿,不碰数据库。

SqlSession #1 → Executor
localCache(一级缓存)key → 上次查到的结果
OrderMapper.selectById02147483647SELECT … WHERE id = ?7SqlSessionFactoryBean
[Order{id=7, amount=99.00}]

返回的是本子里那个对象本身。你改了它的字段,同一个会话里后面再查,拿到的也是改过的。

key 由这六样拼成(CacheKey),一样都不能差:
语句 idcom.shop.mapper.OrderMapper.selectById
offset · limitRowBounds,默认 0 和 2147483647
SQL拼好的最终 SQL,带问号
参数值每个问号对应的值,这里是 7
环境 idSpring 里默认是 SqlSessionFactoryBean

什么时候整本清空

本会话执行任何 insert / update / delete commit rollback close select 上写了 flushCache="true" 配了 localCacheScope=STATEMENT:每条语句执行完就清
在 Spring 里没开事务时,每次 Mapper 调用都是一个新会话,用完就关,一级缓存基本用不上。开了事务,整个事务共用一个会话,一级缓存才会命中(第 02 篇)。

二级缓存:Mapper 的公共柜子

打开以后,同一个 Mapper 的查询结果,所有会话都能共用。查询顺序是先二级、再一级、最后数据库。

OrderMapper.xml写这一行就打开了
<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"/> -->
柜子本身也是一层层套出来的(从外到里)
BlockingCacheblocking="true" 才有
SynchronizedCache加锁,线程安全
LoggingCache打印命中率
SerializedCache存取都序列化,readOnly=false 时
ScheduledCache配了 flushInterval 才有
LruCache满了淘汰最久没用的
PerpetualCache最里面还是一个 HashMap

结果不是马上进柜子:先进暂存区

否则一个还没提交、甚至最后会回滚的事务,就会把数据放进大家共用的柜子。

查到的结果delegate.query(…)先照常放进一级缓存
暂存区TransactionalCache.entriesToAddOnCommit每个会话一份,别的会话看不到
公共柜子二级缓存大家都能命中。回滚的话暂存区直接扔掉
改数据时整个清空

本 Mapper 的 insert / update / delete 默认 flushCache="true":先在暂存区记一笔“提交时清空”,提交时把整个柜子清掉。

默认给副本

readOnly="false" 时,存进去先序列化,取出来再反序列化,每次都是新对象。实体没实现 Serializable 就报错。

只在本机内存里

两台机器各有各的柜子。底座可以用 <cache type="…"/> 换成 Redis,或者换成 Caffeine。

缓存实验台

选一个剧情,一步一步看:每个会话的一级缓存和暂存区里有什么,公共柜子里有什么,数据库被查了几次,最后拿到的是不是新数据。

orderMapper.selectById(7)

数据库

这一步拿到的

1/5
selectById(7)

和 Caffeine 逐项比

一级、二级缓存是 MyBatis 替你缓存“某条 SQL 的结果”;Caffeine 是你自己决定缓存什么、缓存多久、什么时候删。

一级缓存二级缓存Caffeine
是什么SqlSession 里的一个 HashMapMapper 级别的缓存,默认是 LRU 包着 HashMap独立的本地缓存库
放在哪层Executor 里面Executor 外面那层 CachingExecutor你的代码里,通常是 Service 方法上
缓存什么某条 SQL 的结果列表同左,按 Mapper 分开存任何对象,比如组装好的 OrderVO
keyCacheKey:语句 + 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,下一行是 W-TinyLFU;绿色是命中

LRU

0 / 0 命中
4 格,左边最久没用,右边最近用过

W-TinyLFU(简化版)

0 / 0 命中
窗口 1 格
主区 3 格(左边最久没用)

简化版:真实的 Caffeine 用 Count-Min Sketch 估算频次,会定期把频次减半,让过气的热点退场;主区还分成试用区和保护区;窗口默认只占总容量的 1%。这里为了看得清,窗口 1 格、主区 3 格、频次精确计数。

在 Spring Boot 里用配合 @Cacheable
# 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
}
直接用 Caffeine 的 API
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 返回同一个引用也不怕被改
没变的:一级缓存跟着会话走二级缓存要写 <cache/>暂存区提交后才写入Spring Cache 的注解和配置

常踩的坑

缓存的坑几乎都是同一种:数据已经变了,缓存还是旧的。

同一个事务里,读不到别人刚提交的数据

一级缓存命中了,再加上 MySQL 默认的可重复读。真要读最新的,配 local-cache-scope=statement,或者把这次查询放到事务外。

改了查出来的对象,后面再查也变了

一级缓存和 Caffeine 都返回同一个引用。要改就先复制一份,或者缓存不可变对象。

CacheException: Error serializing object … NotSerializableException

开了二级缓存,默认要序列化,实体没实现 Serializable。给实体实现它,或者设 readOnly="true"(之后拿到的是同一个引用)。

联表查询的二级缓存,读到旧数据

结果缓存在 OrderMapper 名下,用户表是 UserMapper 改的,清不到这边。用 <cache-ref> 共用一个柜子,或者这条查询 useCache="false"。

集群部署,二级缓存各机器不一致

柜子在各自的 JVM 里。要么关掉,要么换成共享的 Redis,要么设 flushInterval 只容忍几秒。

同一个事务里查两次,二级缓存没命中

正常现象:结果还在暂存区,事务提交后才进柜子。第二次其实是一级缓存命中的。

@Cacheable 不生效

没加 @EnableCaching,或者同一个类里自己调自己,没经过代理。和事务注解一样的原因(Spring 第 02 篇)。

热点 key 过期那一刻,数据库被打满

几百个请求同时没命中,同时查库。用 @Cacheable(sync = true) 或 LoadingCache.get(key),同一个 key 只放一个去查。

先删了缓存,事务还没提交,旧值又被读回去了

删除早于提交,另一个线程趁这个空档从库里读到旧值放回缓存。改成提交之后再删,比如用 TransactionAwareCacheManagerProxy 包一下 CacheManager。

Caffeine 只设了 maximumSize,数据一直不更新

没设过期,只要不被挤掉就一直是旧的。至少配一个 expireAfterWrite 兜底。