尧图建网站 尧图建网站 YAOTU WEB BUILD 免费咨询
ARTICLE DETAIL

资讯详情

深耕网站建设与建站编程的一线实战洞察。

MyBatis 一级缓存为什么常被说成鸡肋:一次读到过期数据的排查,和 Executor 四层调用链

MyBatis 一级缓存为什么常被说成鸡肋:一次读到过期数据的排查,和 Executor 四层调用链 title: MyBatis 一级缓存为什么常被说成鸡肋一次读到过期数据的排查和 Executor 四层调用链tags: [MyBatis, 源码分析, 一级缓存, ORM, Java]category: 后端从一张对不上的对账单说起去年 11 月我们的结算服务跑批时出了一个诡异的现象同一个批次任务里前后两次查同一个商户的账户余额第二次返回的还是旧值但数据库里明明已经被另一段逻辑更新过了。更诡异的是重跑一次就正常。那种「重启就好了」的问题最折磨人。最后定位到的原因是 MyBatis 的一级缓存。这篇就把那次排查里翻过的源码整理一遍顺带说清楚 MyBatis 从SqlSession到结果映射到底经过了哪些层。环境是 MyBatis 3.5.9、Spring Boot 2.4.5、MySQL 8.0.27。一次查询到底走了几层先把主干拎出来。调用userMapper.selectById(1L)时实际的调用链是MapperProxy (JDK 动态代理) → MapperMethod.execute → SqlSession.selectOne → Executor.query ← 一级缓存在这一层 → StatementHandler.query ← 拦截器最常挂的地方 → ParameterHandler.setParameters → ResultSetHandler.handleResultSetsMapper接口没有实现类实例是MapperProxy生成的public class MapperProxyT implements InvocationHandler, Serializable { private final SqlSession sqlSession; private final ClassT mapperInterface; private final MapMethod, MapperMethodInvoker methodCache; Override public Object invoke(Object proxy, Method method, Object[] args) throws Throwable { try { if (Object.class.equals(method.getDeclaringClass())) { // toString/hashCode 这类方法直接放行不走 SQL return method.invoke(this, args); } // 缓存 MapperMethod避免每次调用都解析一遍注解与签名 return cachedInvoker(method).invoke(proxy, method, args, sqlSession); } catch (Throwable t) { throw ExceptionUtil.unwrapThrowable(t); } } }逐行看几个容易被忽略的点第 10 行对Object方法做了短路。这就是为什么你在 Mapper 上调toString()不会触发 SQL。methodCache是ConcurrentHashMap键是Method对象。Mapper 方法的解析结果只算一次这也是 MyBatis 在高频调用下开销可控的原因之一。sqlSession在 Spring 环境下是SqlSessionTemplate它自己又是个代理会把每次调用绑定到当前事务的SqlSession上——这个细节直接关系到后面的缓存问题。一级缓存的作用域比大多数人以为的要大MyBatis 的一级缓存挂在BaseExecutor上public abstract class BaseExecutor implements Executor { protected PerpetualCache localCache; // 就是一级缓存本质是 HashMap Override public E ListE query(MappedStatement ms, Object parameter, RowBounds rowBounds, ResultHandler resultHandler) throws SQLException { BoundSql boundSql ms.getBoundSql(parameter); // 缓存 key 由 statementId 分页参数 SQL 参数值 共同构成 CacheKey key createCacheKey(ms, parameter, rowBounds, boundSql); return query(ms, parameter, rowBounds, resultHandler, key, boundSql); } public E ListE query(MappedStatement ms, Object parameter, RowBounds rowBounds, ResultHandler resultHandler, CacheKey key, BoundSql boundSql) throws SQLException { if (queryStack 0 ms.isFlushCacheRequired()) { clearLocalCache(); // flushCachetrue 的语句会清空一级缓存 } ListE list; try { queryStack; list resultHandler null ? (ListE) localCache.getObject(key) : null; if (list ! null) { handleLocallyCachedOutputParameters(ms, key, parameter, boundSql); } else { // 缓存没命中真正查库 list queryFromDatabase(ms, parameter, rowBounds, resultHandler, key, boundSql); } } finally { queryStack--; } return list; } }这里的要害localCache是普通HashMap没有容量上限、没有过期时间。它的生命周期跟着SqlSession走。CacheKey由 statementId、RowBounds、SQL 文本、参数值一起算出来。所以「同一个方法 同一个参数」在同一个 Session 里只会真正查一次库。resultHandler ! null时会跳过缓存读取。这是个冷门但实用的逃生口。queryStack用来处理嵌套查询比如association的懒加载只有最外层才会因flushCache清缓存。关键问题来了一个SqlSession到底活多久在 Spring 集成下一个事务 一个 SqlSession。如果你的方法上挂了Transactional那么整个方法执行期间的所有查询共用一份一级缓存。我们那次的批处理任务恰好是一个跨度十几秒的大事务。事故现场还原简化后的代码长这样Service public class SettlementService { Autowired private AccountMapper accountMapper; Autowired private JdbcTemplate jdbcTemplate; Transactional(rollbackFor Exception.class) public void settle(Long merchantId) { Account before accountMapper.selectByMerchantId(merchantId); // 第一次查询进缓存 // 这里为了性能历史代码用了原生 JdbcTemplate 批量更新余额 jdbcTemplate.update( UPDATE account SET balance balance - ? WHERE merchant_id ?, before.getPendingAmount(), merchantId); // 后续风控校验又查了一次 Account after accountMapper.selectByMerchantId(merchantId); // 命中缓存拿到旧值 if (after.getBalance().compareTo(BigDecimal.ZERO) 0) { throw new BizException(余额透支); } } }问题就在第 16 行after和before是同一个对象引用。因为中间那次更新走的是JdbcTemplate没有经过 MyBatis 的Executor一级缓存完全不知道数据变了。于是风控校验拿着更新前的余额去判断透支检查形同虚设。这个坑的隐蔽之处在于单测里两次查询之间没有事务边界测试类没加Transactional每次查询各自开 Session所以测试全绿。只有在生产的大事务里才会复现。我们最终是用一个笨但可靠的办法验证的Test void 一级缓存会让两次查询返回同一对象() { // 显式开启一个跨越两次查询的事务 transactionTemplate.execute(status - { Account first accountMapper.selectByMerchantId(1001L); // 绕过 MyBatis 直接改库 jdbcTemplate.update(UPDATE account SET balance 999 WHERE merchant_id 1001); Account second accountMapper.selectByMerchantId(1001L); // 这两行断言在修复前都会通过恰恰证明了问题存在 assertSame(first, second); assertNotEquals(new BigDecimal(999), second.getBalance()); return null; }); }assertSame通过意味着连对象都没重新创建——不是值没刷新是压根没查库。写下这个断言的那一刻两天的困惑才算结束。三种修法的取舍方案写法影响范围适用场景把JdbcTemplate换成 Mapper 更新改 SQL 调用方式MyBatis 感知到更新会自动清缓存首选根治语句上加flushCachetrueXML 属性该语句每次都清空整个一级缓存少量特殊查询localCacheScopeSTATEMENT全局配置一级缓存退化到单条语句级别老系统兜底MyBatis 自己更新时清缓存的逻辑在这里Override public int update(MappedStatement ms, Object parameter) throws SQLException { ErrorContext.instance().resource(ms.getResource()) .activity(executing an update).object(ms.getId()); if (closed) { throw new ExecutorException(Executor was closed.); } clearLocalCache(); // 任何 insert/update/delete 都会整体清空一级缓存 return doUpdate(ms, parameter); }注意是clearLocalCache()清空整个 Map不是精准失效某个 key。MyBatis 在这里选择了简单粗暴——它无法知道一条 UPDATE 会影响哪些查询结果索性全清。这个设计取舍我认为是对的精准失效需要 SQL 解析和表级依赖分析复杂度和收益完全不成正比。我们最后选的是第一种把那段JdbcTemplate改回 Mapper。虽然批量更新的写法要重写但只要更新走 MyBatis缓存一致性就是框架保证的不需要每个人都记住这个坑。配置项localCacheScopeSTATEMENT我不推荐作为常规方案。它会让每条语句执行完就清缓存等于把一级缓存彻底废掉——嵌套查询、association懒加载这些场景会多打很多 SQL。我们在一个遗留服务上试过同样的接口 SQL 次数从 12 次涨到 31 次。它更适合当作「这个服务里混用了各种数据访问方式我先保正确性」的临时闸门。顺带说说 ResultSetHandler 这一层很多人对 MyBatis 的理解停在「写 SQL 和 XML」其实结果映射这层的坑同样不少。核心逻辑在DefaultResultSetHandler#getRowValueprivate Object getRowValue(ResultSetWrapper rsw, ResultMap resultMap, String columnPrefix) throws SQLException { final ResultLoaderMap lazyLoader new ResultLoaderMap(); // 1. 创建结果对象可能用构造器也可能无参构造 反射赋值 Object rowValue createResultObject(rsw, resultMap, lazyLoader, columnPrefix); if (rowValue ! null !hasTypeHandlerForResultObject(rsw, resultMap.getType())) { final MetaObject metaObject configuration.newMetaObject(rowValue); boolean foundValues this.useConstructorMappings; if (shouldApplyAutomaticMappings(resultMap, false)) { // 2. 自动映射按列名匹配属性名 foundValues applyAutomaticMappings(rsw, resultMap, metaObject, columnPrefix) || foundValues; } // 3. 显式映射result columnxxx propertyyyy/ 优先级更高 foundValues applyPropertyMappings(rsw, resultMap, metaObject, lazyLoader, columnPrefix) || foundValues; // 4. 一行全 null 时根据 returnInstanceForEmptyRow 决定返回空对象还是 null rowValue (foundValues || configuration.isReturnInstanceForEmptyRow()) ? rowValue : null; } return rowValue; }值得记住的三点自动映射先执行显式映射后覆盖。所以result标签永远赢过下划线转驼峰。第 18 行解释了一个常见困惑左连接查不到数据时为什么有时候拿到的是null而不是属性全 null 的对象——取决于returnInstanceForEmptyRow配置。lazyLoader是懒加载的载体。如果你在事务外访问懒加载属性报的SqlSession was already closed就是从这条路来的。我的观点一级缓存被骂「鸡肋」我觉得不太公平但也不冤枉。说它不冤枉它默认开启、作用域跟着事务走、清理策略简单粗暴在混用多种数据访问方式的系统里就是个定时炸弹。而绝大多数团队的老系统都或多或少混用着 MyBatis、JdbcTemplate、甚至存储过程。说不公平它在association/collection嵌套查询、循环里反复查同一条配置数据这些场景下确实实打实地省掉了大量重复 SQL。我们那个批处理任务本身一级缓存帮它省掉了大约 40% 的查询次数。我的实际做法是保留一级缓存但立一条纪律——同一个事务内的写操作必须走 MyBatis。这条规则比调任何配置都有效而且新人一句话就能记住。至于二级缓存我的态度更直接分布式环境下不要用 MyBatis 自带的二级缓存。它是 JVM 本地的多实例部署时天然不一致要缓存就老老实实用 Redis 显式管理。思考题如果一个方法上没有Transactional连续调用同一个 Mapper 方法两次会命中一级缓存吗提示想想SqlSessionTemplate在没有事务时是怎么处理SqlSession生命周期的——它每次调用会不会都新建一个再关掉你在项目里踩过一级缓存的坑吗欢迎评论区交流。
返回列表