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,最终落到SqlSession的selectOne,再交给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背后的CachingExecutor→BaseExecutor,维护了一个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、同一个查询参数,第二次selectBySku的CacheKey完全一致,直接从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 日志打开跑一遍,看看第二次查询有没有真的打到数据库。