同一事务内数据不一致问题复盘
同一事务内数据不一致问题复盘一、问题背景在活动初始化任务中系统需要批量写入商品范围、渠道范围以及规则明细并在初始化完成后继续执行衍生数据计算和状态更新。这类链路步骤长、涉及表多、写入量大对事务一致性和异常传播要求很高。本次问题的特点如下线上偶发无法稳定复现重跑后大概率恢复正常应用日志显示前序保存逻辑已经执行最终数据库结果却表现为多表数据不一致从业务视角看流程像是已经写成功但从最终落库结果看数据并没有以一个完整事务的形式保留下来。二、故障现象问题发生时日志出现了两类互相矛盾的信号初始化过程中的批量保存日志显示已执行成功后续检查日志又显示初始化结果为空数据库最终表现为部分表商品、渠道表无数据部分表任务状态表、规则明细表有部分数据规则明细表的数据量不正确少了一部分应用日志中没有明显报错从现象来看问题不是简单的代码没有执行而是执行过程中同一事务出现了数据不一致的问题。三、排查过程1. 排除重复执行和并发问题由于该任务会在提交时实时触发一次后续由定时任务补偿触发第一步优先排查并发和重入问题重点确认是否存在同一任务重复触发是否存在同一业务对象被并发初始化是否存在锁失效或任务重入排查结果显示接口外层已有分布式锁结合日志也未发现同一业务对象的并发冲突因此基本排除了并发覆盖写导致的数据异常。2. 沿主链路增加事务前后关键日志由于最终现象是数据不一致因此开始怀疑事务本身是否未按预期生效。为此增加了以下日志每次 insert 后打印计划写入行数事务提交前查询各目标表的数据量事务提交后再次查询各目标表的数据量新增日志后发现insert 日志中的写入行数是正确的事务提交前后商品表和渠道表查询结果均为 0规则明细表存在部分数据但数量与日志中的写入量不一致代码中不存在对应的 delete 逻辑这意味着日志看到的执行成功和数据库里最终保留的数据并不是同一个完整一致的事务结果。3. 查询数据库 binlog应用日志已经无法解释问题后进一步查看了故障时间窗口内的数据库 binlog发现大部分目标表没有对应的 insert少部分表存在 insertbinlog 中记录的事务开始时间与业务日志推断的事务开始时间不一致例如业务侧推断事务应开始于2026-04-22 14:23:55但 binlog 中相关事务开始时间却是2026-04-22 14:24:37。这说明应用日志中认为属于同一次初始化的数据库操作实际上并不一定发生在同一个数据库事务里。4. 查询死锁日志继续往数据库层追查后在同一时间窗口确认存在死锁。这是排查过程中的关键转折点因为它说明故障不是没有异常而是异常发生在数据库层但没有完整暴露到业务层接下来的核心问题变成了既然数据库已经发生死锁并回滚为什么应用层没有明确报错为什么报错后后续流程还能继续往下执行5. 回到代码定位异常传播链路最终从死锁涉及的表反查写入逻辑定位到通用分批保存和通用重试的组合。关键点有两个分批保存的每一批都会进入重试逻辑重试策略配置为只要抛异常就重试并未区分异常类型也就是说数据库死锁这类事务级异常也会被这个通用重试机制捕获并重试。四、根因分析根因并不只是发生了死锁而是在事务方法内部对数据库写操作做了通用异常重试且重试粒度是单批 SQL 调用不是整个事务。本次问题中最关键的两段代码如下。分批处理代码publicstaticIEvoidbatchConsume(ConsumerListIEconsumer,ListIEallInput,intpartitionSize){if(isEmpty(allInput)){return;}if(allInput.size()partitionSize){RetryUtils.call(()-consumer.accept(allInput));return;}for(ListIEpartInput:Lists.partition(allInput,partitionSize)){RetryUtils.call(()-consumer.accept(partInput));}}通用重试代码privatefinalstaticRetryerObjectobjectRetryerRetryerBuilder.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){ThrowablerealCauset.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、死锁日志一路收敛到代码实现最终定位出问题根因。问题定位本身说明排查方向是正确的但该问题由三个人排查两天才收敛也说明我们在事务边界类问题上的排查经验仍然不足。其实从故障现象就已经可以看到一些端倪同一事务内如果出现明显的数据不一致就应优先怀疑事务已经被中断或切换此时尽早查看数据库层面的日志通常能更快定位问题。