尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

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

一个事务里先查库存再扣减,第二次查询拿到旧值:MyBatis 一级缓存把超卖藏在了本地 Map title: 一个事务里先查库存再扣减第二次查询拿到旧值MyBatis 一级缓存把超卖藏在了本地 Maptags: [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维护了一个localCachePerpetualCache本质是个HashMap。// BaseExecutor.query —— 一级缓存命中的地方 public E ListE query(MappedStatement ms, Object parameter, RowBounds rowBounds, ResultHandler resultHandler, CacheKey key, BoundSql boundSql) { // 1. 用 statementId 参数 rowBounds 算一个 CacheKey // 2. 先查本地缓存 list resultHandler null ? (ListE) 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。四种处理手段效果不同手段做法优点代价关一级缓存localCacheScopeSTATEMENT每次查询都查库彻底消除失去缓存收益QPS 高时压力上移flushCachetrue在 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 日志打开跑一遍看看第二次查询有没有真的打到数据库。
返回列表