同一事务内数据不一致问题复盘
一、问题背景
在活动初始化任务中,系统需要批量写入商品范围、渠道范围以及规则明细,并在初始化完成后继续执行衍生数据计算和状态更新。
这类链路步骤长、涉及表多、写入量大,对事务一致性和异常传播要求很高。
本次问题的特点如下:
- 线上偶发,无法稳定复现
- 重跑后大概率恢复正常
- 应用日志显示前序保存逻辑已经执行
- 最终数据库结果却表现为多表数据不一致
从业务视角看,流程像是已经写成功;但从最终落库结果看,数据并没有以一个完整事务的形式保留下来。
二、故障现象
问题发生时,日志出现了两类互相矛盾的信号:
- 初始化过程中的批量保存日志显示已执行成功
- 后续检查日志又显示初始化结果为空
数据库最终表现为:
- 部分表(商品、渠道表)无数据
- 部分表(任务状态表、规则明细表)有部分数据
- 规则明细表的数据量不正确,少了一部分
- 应用日志中没有明显报错
从现象来看:问题不是简单的代码没有执行,而是执行过程中同一事务出现了数据不一致的问题。
三、排查过程
1. 排除重复执行和并发问题
由于该任务会在提交时实时触发一次,后续由定时任务补偿触发,第一步优先排查并发和重入问题,重点确认:
- 是否存在同一任务重复触发
- 是否存在同一业务对象被并发初始化
- 是否存在锁失效或任务重入
排查结果显示,接口外层已有分布式锁,结合日志也未发现同一业务对象的并发冲突,因此基本排除了并发覆盖写导致的数据异常。
2. 沿主链路增加事务前后关键日志
由于最终现象是数据不一致,因此开始怀疑事务本身是否未按预期生效。为此增加了以下日志:
- 每次 insert 后打印计划写入行数
- 事务提交前查询各目标表的数据量
- 事务提交后再次查询各目标表的数据量
新增日志后发现:
- insert 日志中的写入行数是正确的
- 事务提交前后,商品表和渠道表查询结果均为 0
- 规则明细表存在部分数据,但数量与日志中的写入量不一致
- 代码中不存在对应的 delete 逻辑
这意味着日志看到的执行成功和数据库里最终保留的数据,并不是同一个完整一致的事务结果。
3. 查询数据库 binlog
应用日志已经无法解释问题后,进一步查看了故障时间窗口内的数据库 binlog,发现:
- 大部分目标表没有对应的 insert
- 少部分表存在 insert
- binlog 中记录的事务开始时间与业务日志推断的事务开始时间不一致
例如,业务侧推断事务应开始于2026-04-22 14:23:55,但 binlog 中相关事务开始时间却是2026-04-22 14:24:37。
这说明应用日志中认为属于同一次初始化的数据库操作,实际上并不一定发生在同一个数据库事务里。
4. 查询死锁日志
继续往数据库层追查后,在同一时间窗口确认存在死锁。
这是排查过程中的关键转折点,因为它说明:
- 故障不是没有异常
- 而是异常发生在数据库层,但没有完整暴露到业务层
接下来的核心问题变成了:
- 既然数据库已经发生死锁并回滚,为什么应用层没有明确报错
- 为什么报错后后续流程还能继续往下执行
5. 回到代码定位异常传播链路
最终从死锁涉及的表反查写入逻辑,定位到通用分批保存和通用重试的组合。
关键点有两个:
- 分批保存的每一批都会进入重试逻辑
- 重试策略配置为只要抛异常就重试,并未区分异常类型
也就是说,数据库死锁这类事务级异常,也会被这个通用重试机制捕获并重试。
四、根因分析
根因并不只是发生了死锁,而是:
在事务方法内部,对数据库写操作做了通用异常重试,且重试粒度是单批 SQL 调用,不是整个事务。
本次问题中,最关键的两段代码如下。
分批处理代码:
publicstatic<IE>voidbatchConsume(Consumer<List<IE>>consumer,List<IE>allInput,intpartitionSize){if(isEmpty(allInput)){return;}if(allInput.size()<=partitionSize){RetryUtils.call(()->consumer.accept(allInput));return;}for(List<IE>partInput:Lists.partition(allInput,partitionSize)){RetryUtils.call(()->consumer.accept(partInput));}}通用重试代码:
privatefinalstaticRetryer<Object>objectRetryer=RetryerBuilder.newBuilder().retryIfException().withStopStrategy(StopStrategies.stopAfterAttempt(5)).withWaitStrategy(WaitStrategies.fixedWait(200,TimeUnit.MILLISECONDS)).build();publicstaticvoidcall(Runnablerunnable){try{objectRetryer.call(()->{runnable.run();returnnull;});}catch(com.github.rholder.retry.RetryExceptiont){ThrowablerealCause=t.getCause();if(RuntimeException.class.isAssignableFrom(realCause.getClass())){throw(RuntimeException)realCause;}else{thrownewRuntimeException(realCause);}}catch(ExecutionExceptione){thrownewRuntimeException(e);}}这两段代码组合在一起,就形成了本次事故的根本触发条件:
结合现象,完整链路可以还原为:
- 初始化主流程在 Spring 事务中执行
- 主流程通过
batchConsume进行分批保存 - 某一批写入在数据库侧发生死锁,事务 T1 被数据库回滚
- 该异常没有直接抛到事务边界,而是先被
RetryUtils捕获 RetryUtils依据.retryIfException()继续重试当前批次- 此时数据库侧原事务 T1 已结束,后续 SQL 实际运行在新的事务上下文 T2 中
- 由于异常没有穿透到外层业务方法,应用层认为流程仍在正常执行
- 后续初始化、查询、衍生计算继续运行,最终提交的是 T2 中的部分结果
- 最终形成部分表无数据、部分表有数据、业务日志看似成功的不一致状态
五、解决方案
- 由于方法名具有误导性,因此移除
BatchUtils.batchConsume(...)中的重试逻辑 - 新增
BatchUtils.batchConsumeWithRetry(...),用于确实需要重试的分批调用场景 - 事务内的数据库写操作一旦抛出异常,必须直接向外抛出,由事务整体回滚
六、复盘总结
本次事故的直接根因是:在事务内对单个执行语句进行了重试。进一步看,暴露出的工程问题是接口命名不清晰,方法职责不单一,在分批处理工具中混入了重试逻辑,容易让使用方误判其行为边界。
真正需要吸取的经验不是线上死锁要多关注,而是:
- 数据库写异常不能在事务内部被悄悄消费
- 重试操作必须放在正确层级
- 当日志显示执行过,但结果不一致时,要优先怀疑事务边界和异常传播链,而不是只盯业务分支逻辑
- 方法命名需要规范且慎重
这次排查从并发、日志、事务校验、binlog、死锁日志一路收敛到代码实现,最终定位出问题根因。问题定位本身说明排查方向是正确的,但该问题由三个人排查两天才收敛,也说明我们在事务边界类问题上的排查经验仍然不足。其实从故障现象就已经可以看到一些端倪,同一事务内如果出现明显的数据不一致,就应优先怀疑事务已经被中断或切换,此时尽早查看数据库层面的日志,通常能更快定位问题。