生产环境数据库数据异常丢失问题复盘排查报告
1. 问题概述
生产环境中,一个基于 SOFA 框架的定时任务在执行过程中,出现了严重的数据不一致现象:任务调度执行上游服务 A 创建指令数据,服务 A 在逻辑中(同事通过手动事务)进行了提交;随后调用服务 B,服务 B 能成功查询到该指令数据,但在尝试更新数据状态时失败(异常被内部捕获处理)。然而,流程结束后检查数据库,发现服务 A 创建的指令数据完全消失,且数据库层面未记录到该数据的成功提交日志。此现象不符合预期的业务逻辑和数据库 ACID 特性。
令人比较费解的是,尽管在异常流程中,服务 B 能够成功查询到由服务 A 创建的数据,并且其内部尝试更新数据时发生的失败异常得到了妥善的捕获和处理(未向外抛出),服务 A 创建的本应持久化的数据最终却从数据库中完全消失,同时数据库层面也未能追踪到该数据成功提交的事务日志。
后续通过一项关键的验证步骤——在生产环境中临时移除对服务 B 的调用后,此数据丢失问题便不再复现。
2. 问题背景与详细现象描述
- 技术环境:
- 应用框架: SOFA 框架
- 核心组件: 定时任务调度机制
- 数据库: MySQL
- 事务管理: 主要依赖 Spring 的声明式事务 (
@Transactional
),但服务 A 早期被描述为“手动提交事务”,可能存在手动 JDBC 事务代码与 @Transactional
的混合使用。系统中未使用分布式事务中间件(如 Seata)。
- 业务流程:
- SOFA 定时任务按预定时间触发。
- 定时任务调用服务 A 的业务逻辑。
- 服务 A: 生成一条指令数据,并将其持久化到数据库。
- 服务 A: 调用服务 B 的接口,传递相关指令信息。
- 服务 B: 接收到调用后,首先根据指令 ID 查询数据库,验证指令是否存在。
- 服务 B: 如果查询到指令,则尝试更新该指令的状态等信息。
- 异常现象详细记录:
- (✓) 查询成功: 服务 B 在执行更新逻辑前,通过日志确认能够成功查询到由服务 A 创建并(理论上已)保存的指令数据。
- (✗) 更新失败: 服务 B 在对该指令数据执行
UPDATE
操作时发生错误。具体错误原因未在本次排查中明确(可能是锁竞争、约束违反等),但这本身不是数据丢失的直接原因。
- (✓) 异常内部处理: 服务 B 的代码使用了
try-catch
块捕获了 UPDATE
操作抛出的异常,仅在日志中打印了 error 信息,没有将异常重新抛出。因此,从调用栈来看,服务 B 的方法是“正常”返回给服务 A 的。
- (✓) 后续流程正常: 服务 B 捕获异常后,其后续的业务代码(如果有的话)继续执行,并且调用服务 B 的服务 A 以及整个定时任务的后续流程(如果服务 A/定时任务在调用 B 后还有其他步骤)在日志层面看起来是正常执行完毕的,没有记录到其他的业务或框架级异常。
- (✗) 数据最终丢失: 在整个定时任务执行完毕后,通过数据库客户端或后续查询发现,服务 A 最初创建的那条指令数据在数据库中消失了,无法找到。
- (✗) 无数据库提交日志: DBA 通过检查数据库的相关日志(如 Binlog、Commit Log 等),确认没有找到服务 A 创建的那条指令数据的最终成功提交记录。
- (✓) 无显式删除逻辑: 通过代码审查,确认在服务 A 或服务 B 的正常业务逻辑以及异常处理逻辑中,没有显式编写
DELETE
语句来删除这条指令数据的代码。
3. 关键信息与核心矛盾点
- 核心矛盾 1 (查询成功 vs 最终丢失): 服务 B 明确能查询到数据,意味着在查询那一刻,数据在某种形式上存在于数据库(至少在当前事务视图或某种隔离级别下可见),但这与数据最终完全消失且无提交日志相矛盾。
- 核心矛盾 2 (声称提交 vs 无提交日志): 服务 A 的逻辑意图是保存数据并提交,但数据库层面却无该事务成功提交的最终证据,这直接挑战了数据库事务的持久性(Durability)和原子性(Atomicity)的基本原则。
- 关键行为点: 服务 B 对更新失败异常的内部捕获和处理,使得异常没有向上传播,这通常意味着它不会直接触发 Spring 默认的事务回滚机制。
- 关键技术点: SOFA 定时任务环境、服务 A 代码中存在手动 JDBC 事务控制与
@Transactional
声明式事务管理的混用。这本身就是一种反模式,极易引发难以预测的事务行为。
- 直接触发因素: 服务 B 的调用执行过程(包括其成功的查询和失败的更新尝试)成为了暴露和触发底层事务管理混乱问题的直接导火索。移除它,问题就消失,证明了其关键触发作用。
- 根本原因: 手动事务与声明式事务的混用是问题的根源。它破坏了事务控制的单一性和权威性,导致框架无法正确管理事务状态,为后续的异常行为埋下了伏笔。
- 业务需求: 业务逻辑要求服务 A 的数据保存操作应独立完成,其成功与否不应受服务 B 后续操作失败的影响,两者需要明确的事务隔离
4. 排查分析过程与思路演进
-
初步方向:是否为数据库锁或并发问题?
- 思考: 是否是 MySQL 的锁(行锁、间隙锁、临键锁)导致
UPDATE
失败,进而引发问题?或者高并发下的写入冲突?
- 分析与排除: 数据库锁主要导致阻塞、等待、超时失败,或者死锁。并发冲突可能导致更新丢失(覆盖),但这些都无法解释“数据记录完全消失且无提交日志” 的现象。数据库的 ACID 机制旨在防止这类问题。因此,单纯的锁或并发竞争不是根本原因。
-
转向核心:事务处理机制!数据丢失指向事务未真正提交或被回滚。
- 思考: 什么情况下,看似“提交”的操作最终会无效,且数据消失?这必然与事务的生命周期管理有关。
- 分析可能性:
- 数据库层面 (低可能性):
innodb_flush_log_at_trx_commit
非 1 (牺牲强持久性换性能):不太可能在生产环境如此配置。
- 主从复制延迟与读写分离:服务 B 可能读到从库的旧数据或延迟数据,但在主库更新失败。但这无法解释主库上服务 A 的数据也消失了。
- 数据库 Bug 或存储故障:概率极低,且通常有其他明显症状和错误日志。DBA 排查未发现此类证据。
- 应用层面事务管理 (高可能性): 这是最复杂的环节,问题最可能出在这里。
-
深入应用层事务:结合关键现象进行推理
- 现象 P1: 查询成功。 表明在服务 B 查询的那个时间点,数据在当前事务的上下文中是可见的。
- 现象 P2: 最终丢失且无提交日志。 表明包含服务 A 插入操作的那个事务,最终并未成功提交到数据库。
- 现象 P3: 服务 B 更新失败被捕获,流程继续。 表明服务 B 的失败本身大概率没有触发事务回滚。
- 推理路径 1:脏读 (
READ UNCOMMITTED
)?
- 假设: 服务 B 在读未提交隔离级别下运行,读取了服务 A 尚未物理提交的数据。之后服务 A 所在的事务因其他原因回滚了。
- 评估: 技术上可能,但需要显式设置非默认隔离级别。优先级暂放低。
- 推理路径 2:外层事务回滚覆盖内层操作?
- 假设: 存在一个由 SOFA 定时任务启动的、或者服务 A 外层调用链启动的“顶层”事务。服务 A (
@Transactional(propagation=REQUIRED)
) 和服务 B (@Transactional(propagation=REQUIRED)
) 都加入了这个顶层事务。服务 A 执行插入。服务 B 查询时在同一事务内,能看到数据。服务 B 更新失败被捕获,不影响事务。关键: 在服务 B 返回后,顶层事务的后续逻辑(可能在服务 A 的剩余代码中,或定时任务的后续代码中)遇到了未捕获的 RuntimeException
或显式调用了 setRollbackOnly
,导致整个顶层事务最终回滚。
- 评估: 此假设能完美串联所有现象(P1, P2, P3),且非常符合 Spring/SOFA 框架下定时任务和事务传播的常见模式。可能性极高。
-
聚焦核心疑点:手动事务与声明式事务 (@Transactional
) 的冲突
- 背景: 业务需求是服务 A 独立提交,确认服务 A 中确实存在手动事务与
@Transactional
混用时,之前的“外层事务回滚”假设得到了最有力的支撑和解释:
- 状态混乱是前提: 手动 commit 在框架管理的事务中执行,直接破坏了事务状态的一致性和框架的控制权。
- 临时可见性得以解释: 手动 commit 可能导致数据对后续操作(如服务 B 的查询)变得可见。
- 框架的最终裁决权: 无论中间发生了什么手动操作,Spring/SOFA 事务管理器在事务结束时拥有最终的解释权和执行权(提交或回滚)。
- 回滚覆盖一切: 框架的最终回滚决定会无情地撤销事务范围内的所有变更,包括那些看似已被手动 commit 的部分。
- 服务 B 的触发作用: 服务 B 的执行过程,在这个本已不稳定的事务状态下,成为了触发最终回滚的特定条件。其内部的数据库交互(尤其是可能涉及锁资源争抢或导致连接状态变化的更新失败)很可能将事务“推向”了最终被框架判定为必须回滚的境地。移除 B,就移除了这个触发特定路径的“催化剂”。
- 冲突分析: 当
@Transactional
管理一个事务时,它期望完全控制 Connection
的事务状态(何时 commit
,何时 rollback
)。如果在其管理的事务上下文中(例如,在 @Transactional
方法内部)手动调用 connection.commit()
:
- 临时提交: 手动
commit
可能确实会触发数据库的临时物理提交。这就是服务 B 能查询到数据的原因。
- 控制权仍在框架: Spring/SOFA 的事务管理器并不知道这次手动提交。它仍然认为自己管理的事务(范围可能更大,覆盖整个方法或更外层调用)还在进行中。
- 最终决定权: 当这个由框架管理的事务最终结束时,如果框架根据其规则(例如,检测到后续有异常或
setRollbackOnly
标记)决定需要回滚,它会执行 connection.rollback()
。
- 覆盖效果: 这个最终的
rollback
指令会撤销整个事务期间的所有操作,包括你之前手动 commit
的那部分。结果就是数据消失,且从数据库日志看,该事务从未成功提交。
- 结论: 混合使用手动事务和声明式事务是导致本次问题的“完美风暴”。它精确地解释了为什么数据“看似提交了”(服务 B 能查到),但“最终消失了”(被框架的最终回滚决定覆盖)。
5. 核心原因推断
综合上述所有信息、现象分析及推理过程,本次生产数据异常丢失问题的最可能核心原因是:
服务 A 代码中严重违反了事务管理单一控制原则,不规范地混合使用了手动 JDBC 事务控制(如 connection.commit())与框架的声明式事务管理 (@Transactional)。这种混用导致事务状态不可预测,是系统性的核心脆弱点,为数据不一致埋下了严重隐患。服务 B 的调用过程,其包含的数据库查询和(失败的)更新操作,则是在此脆弱的事务管理基础上,扮演了触发者的角色,其执行过程直接或间接地引发了包含服务 A 插入操作的整个外层事务最终被框架判定为回滚,进而无情地覆盖了服务 A 的手动提交操作,造成了数据的最终丢失。
服务 B 更新失败被内部捕获的事件,本身并未直接触发这次回滚,它更像是在这个最终注定要回滚的事务中发生的一个插曲。
6. 最终结论与改进
- 结论: 本次生产问题的根源清晰明确,即服务 A 中手动事务与声明式事务的混用。这是一个严重违反事务管理最佳实践、极易引发严重后果的技术债,直接导致了本次生产故障。服务 B 的调用过程是触发此潜在问题的具体场景,但并非问题的根本所在。
- 问题: 测试环境目前无法100%复现,甚至说无法复现,生产环境上同样的代码,只存在部分企业异常,更新成功的则不会出现该问题,
- 改进:
- 【根本解决措施,最高优先级】彻底移除手动事务代码: 在整个应用代码库中(特别是服务 A),进行彻底排查,坚决移除所有
connection.commit()
, connection.rollback()
, connection.setAutoCommit()
等任何形式的手动 JDBC 事务控制代码。必须保证事务的边界、提交、回滚完全由应用的事务管理框架(Spring/SOFA 的 @Transactional
或编程式事务 API)统一控制。
- 【推荐采纳】实现服务 A 事务独立性 (满足业务需求): 为了达到“服务 A 保存数据独立于服务 B 操作结果”的业务目标,应将服务 A 中负责核心数据插入与持久化的方法,使用独立的事务进行管理。通过修改其
@Transactional
注解,将事务传播行为设置为 Propagation.REQUIRES_NEW
。
@Transactional(propagation = Propagation.REQUIRES_NEW)
public String createAndSaveDataIndependent(...) {
return savedDataId;
}
- 【配合调整】优化调用流程: 确保在调用服务 B 之前,已经成功调用了服务 A 的
createAndSaveDataIndependent
方法(标记为 REQUIRES_NEW
的那个),并获取到了成功的结果。这样可以保证在进入服务 B 的逻辑时,服务 A 的数据已经在一个独立的、已提交的事务中稳定存在于数据库里。
- 【必要步骤】代码审查与充分测试: 对修改后的代码进行严格的同行评审(Code Review),重点关注事务注解的使用和潜在的异常路径。进行全面的单元测试、集成测试,并尽可能模拟生产中的异常场景(如服务 B 更新失败),确保事务行为符合预期,数据持久性得到保障。
- 【辅助手段】增强日志记录: 在事务边界(方法入口、出口)、关键数据库操作前后、服务调用前后增加更详细的日志输出,包括事务状态、关键数据 ID 等信息,以便未来快速定位类似问题。