news 2026/4/25 6:15:51

同一事务内数据不一致问题复盘

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
同一事务内数据不一致问题复盘

同一事务内数据不一致问题复盘

一、问题背景

在活动初始化任务中,系统需要批量写入商品范围、渠道范围以及规则明细,并在初始化完成后继续执行衍生数据计算和状态更新。

这类链路步骤长、涉及表多、写入量大,对事务一致性和异常传播要求很高。

本次问题的特点如下:

  1. 线上偶发,无法稳定复现
  2. 重跑后大概率恢复正常
  3. 应用日志显示前序保存逻辑已经执行
  4. 最终数据库结果却表现为多表数据不一致

从业务视角看,流程像是已经写成功;但从最终落库结果看,数据并没有以一个完整事务的形式保留下来。

二、故障现象

问题发生时,日志出现了两类互相矛盾的信号:

  1. 初始化过程中的批量保存日志显示已执行成功
  2. 后续检查日志又显示初始化结果为空

数据库最终表现为:

  1. 部分表(商品、渠道表)无数据
  2. 部分表(任务状态表、规则明细表)有部分数据
  3. 规则明细表的数据量不正确,少了一部分
  4. 应用日志中没有明显报错

从现象来看:问题不是简单的代码没有执行,而是执行过程中同一事务出现了数据不一致的问题。

三、排查过程

1. 排除重复执行和并发问题

由于该任务会在提交时实时触发一次,后续由定时任务补偿触发,第一步优先排查并发和重入问题,重点确认:

  1. 是否存在同一任务重复触发
  2. 是否存在同一业务对象被并发初始化
  3. 是否存在锁失效或任务重入

排查结果显示,接口外层已有分布式锁,结合日志也未发现同一业务对象的并发冲突,因此基本排除了并发覆盖写导致的数据异常。

2. 沿主链路增加事务前后关键日志

由于最终现象是数据不一致,因此开始怀疑事务本身是否未按预期生效。为此增加了以下日志:

  1. 每次 insert 后打印计划写入行数
  2. 事务提交前查询各目标表的数据量
  3. 事务提交后再次查询各目标表的数据量

新增日志后发现:

  1. insert 日志中的写入行数是正确的
  2. 事务提交前后,商品表和渠道表查询结果均为 0
  3. 规则明细表存在部分数据,但数量与日志中的写入量不一致
  4. 代码中不存在对应的 delete 逻辑

这意味着日志看到的执行成功和数据库里最终保留的数据,并不是同一个完整一致的事务结果。

3. 查询数据库 binlog

应用日志已经无法解释问题后,进一步查看了故障时间窗口内的数据库 binlog,发现:

  1. 大部分目标表没有对应的 insert
  2. 少部分表存在 insert
  3. binlog 中记录的事务开始时间与业务日志推断的事务开始时间不一致

例如,业务侧推断事务应开始于2026-04-22 14:23:55,但 binlog 中相关事务开始时间却是2026-04-22 14:24:37

这说明应用日志中认为属于同一次初始化的数据库操作,实际上并不一定发生在同一个数据库事务里。

4. 查询死锁日志

继续往数据库层追查后,在同一时间窗口确认存在死锁。

这是排查过程中的关键转折点,因为它说明:

  1. 故障不是没有异常
  2. 而是异常发生在数据库层,但没有完整暴露到业务层

接下来的核心问题变成了:

  1. 既然数据库已经发生死锁并回滚,为什么应用层没有明确报错
  2. 为什么报错后后续流程还能继续往下执行

5. 回到代码定位异常传播链路

最终从死锁涉及的表反查写入逻辑,定位到通用分批保存和通用重试的组合。

关键点有两个:

  1. 分批保存的每一批都会进入重试逻辑
  2. 重试策略配置为只要抛异常就重试,并未区分异常类型

也就是说,数据库死锁这类事务级异常,也会被这个通用重试机制捕获并重试。

四、根因分析

根因并不只是发生了死锁,而是:

在事务方法内部,对数据库写操作做了通用异常重试,且重试粒度是单批 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);}}

这两段代码组合在一起,就形成了本次事故的根本触发条件:

结合现象,完整链路可以还原为:

  1. 初始化主流程在 Spring 事务中执行
  2. 主流程通过batchConsume进行分批保存
  3. 某一批写入在数据库侧发生死锁,事务 T1 被数据库回滚
  4. 该异常没有直接抛到事务边界,而是先被RetryUtils捕获
  5. RetryUtils依据.retryIfException()继续重试当前批次
  6. 此时数据库侧原事务 T1 已结束,后续 SQL 实际运行在新的事务上下文 T2 中
  7. 由于异常没有穿透到外层业务方法,应用层认为流程仍在正常执行
  8. 后续初始化、查询、衍生计算继续运行,最终提交的是 T2 中的部分结果
  9. 最终形成部分表无数据、部分表有数据、业务日志看似成功的不一致状态

五、解决方案

  1. 由于方法名具有误导性,因此移除BatchUtils.batchConsume(...)中的重试逻辑
  2. 新增BatchUtils.batchConsumeWithRetry(...),用于确实需要重试的分批调用场景
  3. 事务内的数据库写操作一旦抛出异常,必须直接向外抛出,由事务整体回滚

六、复盘总结

本次事故的直接根因是:在事务内对单个执行语句进行了重试。进一步看,暴露出的工程问题是接口命名不清晰,方法职责不单一,在分批处理工具中混入了重试逻辑,容易让使用方误判其行为边界。

真正需要吸取的经验不是线上死锁要多关注,而是:

  1. 数据库写异常不能在事务内部被悄悄消费
  2. 重试操作必须放在正确层级
  3. 当日志显示执行过,但结果不一致时,要优先怀疑事务边界和异常传播链,而不是只盯业务分支逻辑
  4. 方法命名需要规范且慎重

这次排查从并发、日志、事务校验、binlog、死锁日志一路收敛到代码实现,最终定位出问题根因。问题定位本身说明排查方向是正确的,但该问题由三个人排查两天才收敛,也说明我们在事务边界类问题上的排查经验仍然不足。其实从故障现象就已经可以看到一些端倪,同一事务内如果出现明显的数据不一致,就应优先怀疑事务已经被中断或切换,此时尽早查看数据库层面的日志,通常能更快定位问题。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/4/25 6:09:23

支持多协议转换的工业物联网智能网关应用

工业级4G远程双卡双待物联网智能网关 型号&#xff1a;JM-WG310-IOT22 第 1 章 产品简介 1.1 产品概述 JM-WG310-IOT22 是基于 5G/4G/3G/2G 、WiFi 、虚拟专网等技术开发的工业级路由器/CPE 。产品采用高性能的工业级 32 位通信处理器和工业级无线模块&#xff0c;以嵌入式…

作者头像 李华
网站建设 2026/4/25 6:04:50

老师说孩子聪明但粗心,真相往往是基础不牢

几乎每个家长都听过这句话&#xff1a;“你家孩子很聪明&#xff0c;就是粗心。”这句话太有迷惑性了&#xff0c;它让你误以为孩子只是态度问题&#xff0c;只要仔细一点就能拿高分。但真相是&#xff1a;百分之九十的“粗心”&#xff0c;本质都是基础不牢。如果一个孩子真正…

作者头像 李华
网站建设 2026/4/25 6:02:56

3分钟掌握BepInEx:让你的游戏拥有无限可能的插件框架

3分钟掌握BepInEx&#xff1a;让你的游戏拥有无限可能的插件框架 【免费下载链接】BepInEx Unity / XNA game patcher and plugin framework 项目地址: https://gitcode.com/GitHub_Trending/be/BepInEx 你是否曾经想过为心爱的游戏添加新功能&#xff1f;或者想自定义游…

作者头像 李华
网站建设 2026/4/25 6:02:54

Kimi K2.6:最佳开源 LLM 就在这里

大多数开源模型&#xff0c;都有一种很熟悉的野心&#xff1a;什么都想会一点。写代码&#xff0c;想沾&#xff1b;推理&#xff0c;想卷&#xff1b;聊天&#xff0c;要跟&#xff1b;Agent&#xff0c;也不能落下。看上去面面俱到&#xff0c;实际上常常是哪边都能碰一下&am…

作者头像 李华