Spring 事务诡异 bug:已提交的数据,居然查不到最新值!
文章目录一、问题背景二、问题现象与核心矛盾1. 正常串行表现2. 并发异常表现3. 核心矛盾点三、问题分析排除无效猜想锁定核心方向1. 排除事务未提交2. 排除 Spring 事务失效3. 排除 MyBatis 一级/二级缓存4. 排除数据库 Bug5. 最终锁定核心方向四、根本原因RR隔离级别 MVCC 行锁三者共同作用1. 关键底层机制回顾1Spring 事务默认行为2MVCC 多版本并发控制3行锁机制2. 完整异常链路还原3. 一句话总结根本原因五、解决方案四种方案对比 生产推荐方案一修改隔离级别为 READ_COMMITTEDRC【生产首选】方案二查询改为当前读 FOR UPDATE【强一致场景】方案三拆分事务更新与查询分离【架构清晰】方案四前端 后端防重复提交【根治方案】六、总结与避坑指南1. 问题核心总结2. 开发避坑指南一、问题背景后端实现一个按题目顺序逐个获取问题并确认的功能首次获取返回第一题每次确认后返回下一题直到确认完所有问题接口设计如下不传题目ID返回当前未读最小的一题传入题目ID将该题状态更新为“已读”返回当前未读最小的一题。后台接口简化实现如下ServicepublicclassQuestionService{/** * 主业务方法更新题目状态 查询下一题 */TransactionalpublicLonggetNextQuestion(LongquestionId){// 更新为已读updateStatusToRead(questionId);// 查询最小的未读题目IDreturnqueryMinUnreadQuestion();}/** * 更新状态逻辑省略 */privatevoidupdateStatusToRead(LongquestionId){// UPDATE question SET status1 WHERE id?}/** * 查询下一题逻辑省略 */privateLongqueryMinUnreadQuestion(){// SELECT id FROM question WHERE status0 ORDER BY id ASC LIMIT 1}}接口正常串行调用逻辑清晰传第二题ID → 第二题标为已读 → 返回第三题传第三题ID → 第三题标为已读 → 返回第四题。但由于前端线程处理异常出现重复调用真实线上时序如下13:29:14.558传入第二题ID → 接口正常返回第三题13:29:15.670传入第三题ID期望返回第四题13:29:15.747重复传入第二题ID无实际状态变化最终异常结果步骤二传第三题ID返回了第二题步骤三重复传第二题ID返回了第四题。从业务逻辑看步骤二明显异常能传入第三题ID说明第二题状态一定已更新并提交步骤二理应返回第四题却返回了早已被标为已读的第二题。二、问题现象与核心矛盾1. 正常串行表现传第2题ID → 更新第2题状态为1 → 事务提交 → 返回第3题传第3题ID → 更新第3题状态为1 → 事务提交 → 返回第4题2. 并发异常表现传第3题ID的同时重复传第2题ID传第3题ID的请求返回第2题重复传第2题ID的请求返回第4题。3. 核心矛盾点业务链路可保证能调用第3题说明第2题的更新事务一定已提交状态已为1重复调用第2题只是把“已读”再次更新为“已读”无数据变化不应该影响查询结果第3题的请求中查询本该跳过第1、2、3题直接返回第4题数据库数据已提交、已更新查询却读到了更早的历史版本完全违背直观认知。初期排查方向集中在事务未提交、AOP失效、MyBatis 一级缓存、数据库BUG等均无法解释该现象。最终定位为Spring 默认事务传播行为 MySQL RR 隔离级别 InnoDB MVCC 多版本并发控制 行锁共同作用的结果。三、问题分析排除无效猜想锁定核心方向1. 排除事务未提交前端参数是上一个接口的返回值能拿到第3题ID并发起请求证明第2题的更新事务一定已经提交数据已持久化排除事务未提交、未刷新的可能。2. 排除 Spring 事务失效主方法加Transactional两个子方法为私有方法无自调用、无异常捕获吞掉、无异步事务切面正常生效整个方法在一个事务内执行排除事务不生效、回滚异常等问题。3. 排除 MyBatis 一级/二级缓存查询语句每次执行都带真实业务条件无缓存命中且现象与缓存机制不符排除缓存干扰。4. 排除数据库 Bug查询读到历史版本完全符合 InnoDB 官方文档定义的行为不是数据库漏洞而是隔离级别 MVCC 锁的标准表现。5. 最终锁定核心方向SpringTransactional默认传播级别REQUIREDMySQL InnoDB 默认隔离级别REPEATABLE READRRInnoDB MVCC 多版本快照读机制行锁与快照读的交互规则四、根本原因RR隔离级别 MVCC 行锁三者共同作用1. 关键底层机制回顾1Spring 事务默认行为传播级别REQUIRED有事务则加入无则新建隔离级别复用数据库默认即 MySQL 的RR可重复读。2MVCC 多版本并发控制InnoDB 通过 Undo Log 保留行的历史版本实现无锁并发读RR 级别下事务第一次查询时生成一致性快照整个事务生命周期内快照不再刷新事务内所有普通 SELECT 都走快照读只看快照生成前已提交的数据。3行锁机制执行UPDATE时无论数据是否真的被修改InnoDB 都会对匹配行加排他锁X锁阻塞其他事务的当前读但不阻塞快照读。2. 完整异常链路还原初始状态第1、2、3、4题状态均为0未读。正常流程传第2题ID → 事务提交第2题状态更新为1事务提交数据库中第2题已是已读。并发开始请求A13:29:15.670 传第3题ID进入事务1更新第3题为12执行查询生成事务快照。请求B13:29:15.747 重复传第2题ID进入事务1执行UPDATE 第2题 SET status12虽然数据无变化但对第2题加了X锁并生成新的未提交版本。关键异常点请求A第3题在执行快照读时第2题已被请求B加X锁RR 级别快照读不会等待锁释放而是直接读取锁生效前的最后一个已提交版本锁生效前的版本就是第2题最初的 status0 版本。最终查询结果请求A的查询按ID正序扫描第1题已读 → 跳过第2题读到历史版本 status0 → 直接返回第2题不再继续扫描第3、4题导致结果异常。而请求B重复传第2题事务提交后查询看到第1、2、3题均已读返回第4题表现正常。3. 一句话总结根本原因在RR 隔离级别下重复更新触发的行锁导致并发事务的快照读回退到了更早的历史版本读到了早已被更新提交的旧数据这是 MySQL 为了保证可重复读和并发不阻塞的标准设计不是 Bug。五、解决方案四种方案对比 生产推荐方案一修改隔离级别为 READ_COMMITTEDRC【生产首选】Transactional(isolationIsolation.READ_COMMITTED)publicLonggetNextQuestion(LongquestionId){updateStatusToRead(questionId);returnqueryMinUnreadQuestion();}原理RC 级别每次查询都会生成新的快照只读取查询时刻最新的已提交数据不会因锁回退到极旧版本从根源解决问题。优点改动极小只改注解并发性能高无锁等待90% 业务都能适用。缺点失去 RR 级别的“可重复读”保证同一事务内多次查询结果可能不同。方案二查询改为当前读 FOR UPDATE【强一致场景】SELECTidFROMquestionWHEREstatus0ORDERBYidASCLIMIT1FORUPDATE原理FOR UPDATE强制走当前读读取最新版本遇到锁会等待不读历史版本。优点保留 RR 隔离级别数据绝对准确。缺点会锁等待高并发下性能下降可能引发阻塞、超时。方案三拆分事务更新与查询分离【架构清晰】ServicepublicclassQuestionService{TransactionalpublicvoidupdateStatus(LongquestionId){// 更新状态}Transactional(isolationIsolation.READ_COMMITTED)publicLongqueryNext(){// 查询下一题}// 外层无事务先提交更新再查询publicLonggetNextQuestion(LongquestionId){updateStatus(questionId);// 事务已提交returnqueryNext();// 新事务无锁干扰}}优点完全避免快照读异常逻辑清晰易于维护。缺点代码改动较大失去更新与查询的原子性。方案四前端 后端防重复提交【根治方案】前端按钮防抖、置灰、请求队列化后端分布式锁、接口幂等、防重校验。优点从源头消灭并发诱因不依赖底层事务机制。缺点开发成本较高需要分布式改造。六、总结与避坑指南1. 问题核心总结本次诡异问题的本质RR 隔离级别 MVCC 固定快照 重复更新加行锁 → 快照读回退历史版本。串行调用完全正常并发重复提交时暴露是典型的“底层机制符合规范但业务表现反直觉”问题。2. 开发避坑指南高并发“更新查询”场景优先使用 RC 隔离级别不要认为“事务提交就一定能读到最新数据”RR 级别不保证避免无意义的UPDATE减少行锁与版本链污染核心接口必须做防重复提交排查事务问题优先看隔离级别、传播行为、锁、MVCC 快照。