MyBatis 的两级缓存和连接池是"默认开着、出事才想起"的三件东西。缓存出问题的表现是读到旧数据,连接池出问题的表现是请求排队或者 Communications link failure——两者的日志都不指向真凶。
下面按作用域从小往大走:一级缓存、二级缓存、连接池参数,再到排错。
1. 一级缓存:作用域是 SqlSession
一级缓存是 BaseExecutor 里的 localCache,就是一个 PerpetualCache(底层是 HashMap),生命周期等于 SqlSession。
命中条件苛刻到几乎无用:同一条 MappedStatement + 同一组参数 + 同一个 RowBounds + 同一条 BoundSql。任何一项不同都不命中。
失效的四种情况:
| 触发 | 原因 |
|---|---|
换了一个 SqlSession |
缓存对象随 session 创建,天然隔离 |
该语句配置 flushCache="true" |
执行前清空 localCache |
同一 session 里任何 insert/update/delete |
update() 里 clearLocalCache(),清整个 session |
| 事务提交或回滚 | commit()/rollback() 调用 clearLocalCache() |
在 Spring 里,"一个 SqlSession"的边界由 SqlSessionTemplate + 事务决定:无事务时每次 Mapper 调用是一条新 session(SIMPLE 执行器),缓存等于没有;有 @Transactional 时整个事务共用一个 session,缓存才持续存在。
于是就有了经典现象:同一事务里先查、别人改了库、再查还是旧值。这不是 bug,是一级缓存的预期行为。排查时看到两个查询之间夹着写操作却没刷新,先确认它们是否在同一事务、同一 session。
关闭方式:
mybatis:
configuration:
local-cache-scope: statement # 默认 session
STATEMENT 让每次语句执行前后都清缓存,等价于"一级缓存只在这一条语句内有效"。代价是同一事务里重复查询不再省往返,收益是可预测性——绝大多数服务端应用该选这个值。
2. 二级缓存:作用域是 namespace
二级缓存挂在 MappedStatement 所属的 namespace(通常一个 Mapper 接口)上,由 CachingExecutor 统一管理,存储实现是 Cache 接口。同一 namespace 的所有 session 共享。
开启需要三个条件同时成立:
| 条件 | 配置位置 | 缺了会怎样 |
|---|---|---|
<cache/> 声明 |
Mapper XML 顶部 | 该 namespace 无二级缓存 |
cacheEnabled=true |
mybatis-config.xml / configuration.cache-enabled |
全局关闭,<cache/> 无效 |
结果对象可序列化(用默认 SerializedCache 时) |
实体实现 Serializable |
反序列化报错,或命中后字段丢失 |
<cache eviction="LRU" flushInterval="600000" size="1024" readOnly="false"/>
readOnly="true" 返回共享实例、性能最好,但调用方改了对象就等于改了缓存——只适合绝对不可变的查询结果。
失效范围是"整个 namespace":
namespace 内任意 insert / update / delete
→ flushCache=true(默认)
→ CachingExecutor 调用 TransactionalCacheManager.clear()
→ 该 namespace 的二级缓存整体清空
注意它是整体清空,不是按 id 精确失效。一个 namespace 里有 30 条写语句、20 条读语句,任何一次写都会让 20 条读全部回源。所以把大表的所有语句塞进同一个 Mapper,二级缓存命中率会低得可怜。
3. 多实例下二级缓存为什么不一致
二级缓存存在每个 JVM 的本地内存里。两台实例各自持有一份,A 实例更新数据库并清自己的缓存,B 实例的缓存原封不动——B 继续返回旧数据。
三种规避,按推荐度排:
| 方案 | 做法 | 代价 |
|---|---|---|
| 不用二级缓存 | cache-enabled: false,热点数据交给 Redis |
少一层本地加速 |
| 只给只读表开 | 字典表、配置表这类不写的 namespace 加 <cache/> |
适用范围窄 |
| 集中式缓存 | 用 RedisCache 替换本地实现,多实例共享同一份 |
序列化开销、网络往返、需处理并发写 |
第三种看似最优,实际代价不小:RedisCache 每次命中都要反序列化,对象图大时开销接近一次轻量查询;flushInterval 语义变成"多久检查一次";事务未提交时的写入顺序在多个实例间无法保证。跨实例强一致靠缓存本身做不到,只能靠"缓存只读、写后删"的约定 + 短 TTL。
Ehcache 集成要额外依赖 mybatis-ehcache 并提供 ehcache.xml;Redis 集成要 mybatis-redis 或自己实现 Cache 接口(putObject/getObject/clear/size 四个方法)。两者都要处理"缓存里的对象版本与实体版本不一致"——实体加字段后旧缓存反序列化失败,通常只能清空重来。
4. 连接池:HikariCP 与 Druid 的取舍
| 维度 | HikariCP | Druid |
|---|---|---|
| 定位 | 极致轻量的高性能池 | 池 + 监控 + SQL 防火墙 |
| 默认地位 | Spring Boot 2.x 起默认 | 需显式引入 |
| 监控 | 指标交给 Micrometer | 内置 StatViewServlet、慢 SQL 记录 |
| 参数风格 | 参数少、自动推导 | 参数多、可精细控制 |
| 常见问题 | 参数调错时不报错只排队 | 监控页暴露、removeAbandoned 误杀 |
选型不需要纠结:没有监控诉求用 HikariCP,需要看 SQL 执行统计用 Druid。两者都别把连接池当性能瓶颈的默认答案——绝大多数"池不够"的真实原因是慢 SQL 占着连接不放。
5. 关键参数与数据库超时的配合
| 参数 | 含义 | 常见取值思路 | 设错的后果 |
|---|---|---|---|
maximumPoolSize |
池上限 | 按数据库能承受的并发连接数定 | 设太大把数据库打满 |
minimumIdle |
保底空闲连接 | 与 max 相等可避免抖动 | 设太小,突发时频繁建连 |
connectionTimeout |
取连接的最长等待 | 小于上游超时 | 请求已经超时了还在等连接 |
idleTimeout |
空闲连接回收 | 小于 maxLifetime |
回收太慢,连接被中间件先杀 |
maxLifetime |
连接最大存活 | 比数据库 wait_timeout 小几分钟 |
拿到已被服务端关闭的死连接 |
最关键的约束是这条:
maxLifetime < 中间件/数据库的空闲超时
MySQL 的 wait_timeout 默认 28800 秒(8 小时),云上负载均衡常把空闲连接在 60–350 秒内断掉。如果 maxLifetime 比它大,池里会留下一批"服务端认为已关闭、客户端以为还活着"的连接,下一次借出就抛 Communications link failure 或 No operations allowed after connection closed。
HikariCP 的推荐写法是让它比超时短 30–60 秒:
spring:
datasource:
hikari:
maximum-pool-size: 20
minimum-idle: 20
connection-timeout: 3000
idle-timeout: 600000
max-lifetime: 1740000 # 29 分钟,小于常见 30 分钟空闲断连
keepalive-time: 300000 # 定期探活,防止中间件静默断连
pool-name: app-pool
keepalive-time 只在连接空闲时发心跳,代价极小,是云环境里性价比最高的一项。
6. 连接泄漏怎么判定
判定不看"连接数高",看趋势:
| 信号 | 含义 |
|---|---|
activeConnections 持续不降(低峰期也是满的) |
借出未归还,泄漏 |
pendingThreads(等待线程)稳定大于 0 |
池已饱和 |
| 应用重启后立刻恢复 | 池内状态被污染,不是数据库问题 |
堆里有大量 PoolEntry/Connection 引用 |
连接对象被业务代码持有 |
HikariCP 暴露这些指标(HikariPoolMXBean),Druid 在监控页直接给。排查顺序:先看 active 曲线是否随流量回落,再看是否有代码在用 getConnection() 而不 close,最后才怀疑参数。
常见泄漏源:手写 JDBC 时 Connection 没放进 try-with-resources;@Transactional 加在私有方法或同类内部调用上导致代理失效;在异步线程里用了主线程的 session(session 随线程结束而关闭,连接却没还)。
7. 排错:五种现象
| 现象 | 原因 | 处理 |
|---|---|---|
| 同一事务内两次查询结果不同 | 中间夹了写操作,或换 session | 确认 session 边界,必要时 STATEMENT 作用域 |
| 更新后别处仍读旧值 | 二级缓存未清(不同 namespace)或本地缓存 | 检查是否跨 namespace,或关掉二级缓存 |
| 多实例数据不一致 | 二级缓存是进程内的 | 改集中式缓存或不用 |
Communications link failure |
maxLifetime 大于数据库空闲超时 |
调小并加 keepalive-time |
| 请求卡在获取连接 | maximumPoolSize 过小或慢 SQL 占满 |
先看慢 SQL,再调池 |
8. 边界
| 场景 | 建议 |
|---|---|
| 单机、读多写少、数据可容忍短暂过期 | 一级缓存 + STATEMENT |
| 多实例部署 | 不开二级缓存,热点走 Redis |
| 只读字典表 | 二级缓存安全,注意实体变更要清 |
| 强一致要求(余额、库存) | 完全绕过缓存,或缓存只做短 TTL 兜底 |
| 连接池大小 | 按数据库连接上限与业务并发定,不按 CPU 核数 |
最后一条值得强调:maximumPoolSize 的常见误区是"CPU 16 核就设 16×2"。数据库的连接数是服务端资源,池的总和要小于数据库的 max_connections 预留余量,否则一个服务扩容就把库连接打满。HikariCP 作者的原始建议是"小池 + 快查询",池大小与 CPU 无关,与查询持有连接的时长有关。
9. 自测
- 一级缓存的生命周期由什么决定?Spring 无事务时它还存在吗?
localCacheScope=STATEMENT改变的是什么粒度?- 二级缓存生效需要哪三个条件同时满足?
- 为什么同一个 namespace 里一次写操作会清空全部读缓存?
maxLifetime为什么要小于wait_timeout?activeConnections一直不降,最先查什么?