2544 字
约 8 分钟
19
S08-X10 · MyBatis 缓存与连接池

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. 自测

  1. 一级缓存的生命周期由什么决定?Spring 无事务时它还存在吗?
  2. localCacheScope=STATEMENT 改变的是什么粒度?
  3. 二级缓存生效需要哪三个条件同时满足?
  4. 为什么同一个 namespace 里一次写操作会清空全部读缓存?
  5. maxLifetime 为什么要小于 wait_timeout?
  6. activeConnections 一直不降,最先查什么?
S08-X10 · MyBatis 缓存与连接池
https://www.clxhxhhr.top/posts/5957/
作者
clxstart
发布于
2024-12-31
许可协议
CC BY-NC-SA 4.0
评论
0 条
还没有评论,先写一条吧。