news 2026/8/22 4:23:25

一个事务里先查库存再扣减,第二次查询拿到旧值:MyBatis 一级缓存把超卖藏在了本地 Map

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
一个事务里先查库存再扣减,第二次查询拿到旧值:MyBatis 一级缓存把超卖藏在了本地 Map

title: "一个事务里先查库存再扣减,第二次查询拿到旧值:MyBatis 一级缓存把超卖藏在了本地 Map"
tags: [MyBatis, 一级缓存, Executor, 源码分析, 事务]
category: 后端


大促零点,库存变成了负数

那次大促我们给秒杀接口挂了限流,本以为万无一失。结果开抢 3 分钟,库存表出现了-37这个离谱的数字,超卖 37 单。第一反应是并发没加锁,但加了SELECT ... FOR UPDATE还是复现。最后定位到一个谁都没注意的地方:同一个 Spring 事务里,查库存、扣库存、再查库存,第二次查根本没走数据库,拿的是 MyBatis 一级缓存里的旧对象。

事故现场:看起来毫无问题的三段逻辑

扣减接口长这样,单线程跑永远正确,问题出在缓存。

@Transactional public void deduct(Long skuId, int count) { Stock stock = stockMapper.selectBySku(skuId); // 1. 查库存,比如 100 if (stock.getCount() < count) { throw new BizException("库存不足"); } stock.setCount(stock.getCount() - count); // 2. 内存里减 stockMapper.updateById(stock); // 3. 写回数据库 Stock again = stockMapper.selectBySku(skuId); // 4. 再查,期望 100-count log.info("扣减后库存={}", again.getCount()); // 5. 日志里还是 100! }

第 4 步的again.getCount()打印出来还是 100,而不是 100-count。我们一度怀疑updateById没生效,直到手动在数据库客户端查,值明明已经更新了。差别在于:Java 这边第二次selectBySku压根没发 SQL。

源码逐行:为什么第二次查询「消失」了

MyBatis 的 Mapper 接口是动态代理,调用会先经过MapperProxy,最终落到SqlSessionselectOne,再交给Executor

// MapperProxy.invoke —— 每个 Mapper 方法调用的入口 public Object invoke(Object proxy, Method method, Object[] args) throws Throwable { if (Object.class.equals(method.getDeclaringClass())) { return method.invoke(this, args); } // 1. 把方法包装成 MapperMethod,统一走 execute final MapperMethod mapperMethod = cachedMapperMethod(method); // 2. 这里转而调用 sqlSession 的 select/insert,进入 Executor 链 return mapperMethod.execute(sqlSession, args); }

关键在于sqlSession是谁给的。Spring 环境下,MapperFactoryBean注入的是SqlSessionTemplate,它会从事务同步管理器里拿SqlSession

// SqlSessionTemplate.SqlSessionInterceptor.invoke public Object invoke(Object proxy, Method method, Object[] args) throws Throwable { // 1. 从事务上下文取「当前线程绑定的 SqlSession」 SqlSession sqlSession = getSqlSession(sqlSessionFactory, executorType, exceptionTranslator); try { // 2. 复用同一个会话,整个 @Transactional 方法共用一个 SqlSession return method.invoke(sqlSession, args); } finally { // ... } }

因为整个deduct方法在一个事务里,getSqlSession每次都返回同一个SqlSession。而这个SqlSession背后的CachingExecutorBaseExecutor,维护了一个localCache(PerpetualCache,本质是个HashMap)。

// BaseExecutor.query —— 一级缓存命中的地方 public <E> List<E> query(MappedStatement ms, Object parameter, RowBounds rowBounds, ResultHandler resultHandler, CacheKey key, BoundSql boundSql) { // 1. 用 statementId + 参数 + rowBounds 算一个 CacheKey // 2. 先查本地缓存 list = resultHandler == null ? (List<E>) localCache.getObject(key) : null; if (list != null) { // 3. 命中就直接返回缓存对象,不发 SQL handleLocallyCachedOutputParameters(...); } else { list = queryFromDatabase(...); // 4. 没命中才查库并写入 localCache } return list; }

第 3 步就是元凶:同一个事务、同一个SqlSession、同一个查询参数,第二次selectBySkuCacheKey完全一致,直接从localCache返回第一次查到的那个Stock对象。我们第 2 步改的是内存里那个对象的字段,但数据库已经更新成新值——Java 这边看到的却始终是「第一次查出来的快照」。更糟的是,localCache存的是同一个对象引用,如果别处也改了它,还会互相串。

排查过程:先怀疑事务隔离级别,再怀疑缓存

我们一开始以为是事务隔离级别问题,把READ_COMMITTED调来调去没用。真正突破口是开了 MyBatis 的 SQL 日志,发现第二次selectBySku根本没打印任何 SQL。顺着BaseExecutor.query打了断点,看到localCache.getObject(key)直接返回了对象。

确认后做了个最小复现:在事务方法里连续selectBySku两次,中间不 update,第二次一定命中缓存。项目环境是 MyBatis 3.5.13、MyBatis-Spring 2.1.1、Spring Boot 2.7.18、MySQL 8.0.32、HikariCP 5.0.1。

四种处理手段,效果不同

手段做法优点代价
关一级缓存localCacheScope=STATEMENT每次查询都查库,彻底消除失去缓存收益,QPS 高时压力上移
flushCache=true在 update 的 Mapper 上标@Options(flushCache=...)更新后清缓存要记得每个写操作都标
拆事务查询和扣减分两个事务/方法各自独立 SqlSession事务边界要重新设计
查完即弃第二次查询改用不同参数或重新select绕开相同 CacheKey治标不治本

我们线上先用@Options(flushCache = Options.FlushCachePolicy.TRUE)标在写操作上止血,长期方案是把「读-算-写」拆成两个事务边界清晰的方法,读用独立SqlSession

@Update("update stock set count = #{count} where sku_id = #{skuId}") @Options(flushCache = Options.FlushCachePolicy.TRUE) // 写完立刻清一级缓存 int updateById(@Param("skuId") Long skuId, @Param("count") int count);

复盘数字

这次超卖持续约 4 分钟,涉及 37 个 sku,超卖合计 412 单,事后对账补单花了 2 天。加上flushCache后,单 sku 扣减链路 P99 从 12ms 升到 18ms(多了一次查库),但库存数据恢复强一致,这个代价我们接受。

我的取舍判断

我不建议无脑把localCacheScope全局设成STATEMENT。一级缓存在「一个方法里多次查同一只读数据」的场景是有意义的,能挡掉不少重复 SQL。真正要防的是「先查、再改、再查」这种写读混合逻辑落在同一个SqlSession——它把缓存变成了脏数据来源。

我的习惯是:凡是涉及「查出来再算再写」的库存、余额、状态机类操作,要么显式flushCache,要么把写操作和读操作放在不同的事务边界,让localCache自然失效。比起追求那点缓存收益,数据正确性优先级高得多。

二级缓存会把坑再放大一圈

如果stockMapper上开了@CacheNamespace(二级缓存),情况更糟:二级缓存跨SqlSession生效,意味着就算你拆掉事务让每次查询用新SqlSession,只要别的线程改了库存并提交,二级缓存可能还返回旧值。我们的硬性规则是:库存、余额、订单状态这类写多读少、要求强一致的表,一律不开二级缓存,Mapper 上标@Options(flushCache = TRUE, useCache = false)

最终的「读-算-写」我们改成直接用 SQL 在数据库侧原子扣减,根本不依赖先查后算:

@Update("UPDATE stock SET count = count - #{count} WHERE sku_id = #{skuId} AND count >= #{count}") int deductCount(@Param("skuId") Long skuId, @Param("count") int count); // 1. 数据库侧原子扣减 @Transactional(readOnly = true, propagation = Propagation.REQUIRES_NEW) public int currentCount(Long skuId) { return stockMapper.selectCount(skuId); // 2. 只读查询放独立事务,自带新 SqlSession }

第 1 行用UPDATE ... SET count = count - #{count}在数据库行上加锁扣减,返回受影响行数判断够不够扣,彻底绕开了本地缓存的旧值问题。第 2 行把只读查询放到REQUIRES_NEW的独立事务里,每次都是新SqlSession,不会吃到上一个事务遗留的一级缓存。

思考题

你项目里有没有「同一个事务里 update 完立刻再 select 校验」的写法?把 MyBatis SQL 日志打开跑一遍,看看第二次查询有没有真的打到数据库。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/8/22 4:21:42

HTTP 5xx服务器错误全解析:从500到504的故障排查与防御体系构建

1. 从一次深夜告警说起&#xff1a;为什么5xx不只是“服务器挂了”凌晨两点&#xff0c;手机突然开始疯狂震动。打开监控平台&#xff0c;满屏的红色告警&#xff0c;核心服务的HTTP接口返回码清一色是“502 Bad Gateway”。开发群里瞬间炸开了锅&#xff0c;运维同事的第一反应…

作者头像 李华
网站建设 2026/8/22 4:21:06

多智能体系统全局Hopf分岔:中立型分布时滞下的对称周期振荡分析

1. 项目概述&#xff1a;从延迟到振荡&#xff0c;多智能体系统的动力学新视角最近在复现和拓展一些关于多智能体系统同步控制的仿真时&#xff0c;我遇到了一个有趣且棘手的问题&#xff1a;当系统中不仅存在常见的离散时滞&#xff0c;还引入了所谓的“中立型分布时滞”时&am…

作者头像 李华
网站建设 2026/8/22 4:18:27

免费电子课本下载工具 tchMaterial-parser:解析智慧教育平台 PDF

免费电子课本下载工具 tchMaterial-parser&#xff1a;解析智慧教育平台 PDF 【免费下载链接】tchMaterial-parser 国家中小学智慧教育平台 电子课本下载工具&#xff0c;帮助您从智慧教育平台中获取电子课本的 PDF 文件网址并进行下载&#xff0c;让您更方便地获取课本内容。 …

作者头像 李华
网站建设 2026/8/22 4:18:12

DDD领域事件发布:事务性发件箱模式详解与实战

1. 项目概述&#xff1a;为什么领域事件发布是DDD落地的关键一环聊到领域驱动设计&#xff0c;很多朋友可能对实体、值对象、聚合这些概念已经耳熟能详了&#xff0c;但一到项目实战&#xff0c;尤其是涉及到跨聚合、跨限界上下文甚至跨系统的状态同步时&#xff0c;就感觉有点…

作者头像 李华
网站建设 2026/8/22 4:16:03

无人机送货技术解析:从系统架构到落地挑战

1. 先搞清楚“无人机送货”到底在解决什么问题&#xff0c;以及它离我们有多远 看到“无人机送货”这个词&#xff0c;很多人第一反应是科幻电影里的场景。但亚马逊的Prime Air项目&#xff0c;正在把它变成一种现实的物流补充方案。它核心解决的&#xff0c;不是取代所有快递员…

作者头像 李华