`Java并发排障实录:没有报错,却把正确数据覆盖错了`

线上数据"头行不一致"排障实录:不是重复调用,而是异步旧对象覆盖(Java + MyBatis)

适用场景:Spring Boot + MyBatis 项目中,存在"同步主流程 + 异步回写状态"的业务。


目录

  • [1. 事故现象](#1. 事故现象)
  • [2. 第一轮排查:为什么一开始会怀疑重复调用](#2. 第一轮排查:为什么一开始会怀疑重复调用)
  • [3. 时间线还原:问题真正发生的窗口](#3. 时间线还原:问题真正发生的窗口)
  • [4. 根因定位:异步线程拿旧对象做全字段更新](#4. 根因定位:异步线程拿旧对象做全字段更新)
  • [5. 时序图(修复前 vs 修复后)](#5. 时序图(修复前 vs 修复后))
  • [6. 修复方案(可落地)](#6. 修复方案(可落地))
  • [7. 进一步加固(为什么要这么做)](#7. 进一步加固(为什么要这么做))
  • [8. 历史脏数据修复思路](#8. 历史脏数据修复思路)
  • [9. 最终总结](#9. 最终总结)

1. 事故现象

线上出现了一个非常"反直觉"的数据状态:

  • 主单(头表):
    • status = PENDING
    • return_total_qty = 0
  • 明细(行表):
    • return_qty 已有值(>0)

同时,主单上还能看到:

  • u8_push_status = SUCCESS
  • completed_at 有值

也就是说:头看起来没签收,行看起来已签收


2. 第一轮排查:为什么一开始会怀疑重复调用

出现这类问题时,大家通常先怀疑:

  1. purchaseReturnConfirm 被重复调用(幂等没做好)
  2. 补偿任务改过同一单据
  3. 事务部分提交导致"半成功"

这些方向都合理,但这次并不是主因。


3. 时间线还原:问题真正发生的窗口

通过日志和库里时间字段还原出关键顺序(已脱敏):

  • 创建时间:15:54:34
  • 签收完成时间:15:54:45
  • U8 推送成功时间:15:54:48

这个顺序很关键:

  • 签收主流程先把业务字段写正确
  • 异步线程后落库
  • 最终结果却是旧值 -> 典型"后写覆盖前写"

4. 根因定位:异步线程拿旧对象做全字段更新

根因一句话:

异步线程持有的是创建时的旧对象快照,并执行了整对象 update,导致旧值把新值覆盖。

4.1 创建流程里的对象初始值

创建时对象通常是:

  • status = PENDING
  • return_total_qty = 0

对象被放入 orders 集合后,异步线程直接复用该对象。

4.2 签收流程会把数据改成正确值

签收会更新:

  • 头表:status=OUTBOUNDreturn_total_qty=sum(returnList)completed_at=now
  • 行表:return_qty=实际签收数量

4.3 异步线程回写触发覆盖

异步线程只想更新 U8 推送状态,但如果调用 update(order)(整对象非空字段更新),就会把对象中的旧业务字段也写回去。

最终形成:

  • 头表被覆盖回旧值(PENDING/0
  • 行表不受影响(仍是已签收)
  • -> 头行不一致

5. 时序图(修复前 vs 修复后)

5.1 修复前(问题版)

Async(U8 Push) MySQL Service Manager Client Async(U8 Push) MySQL Service Manager Client 把旧对象的PENDING/0写回 创建单 purchaseReturnApply insert头(status=PENDING, return_total_qty=0) insert行(return_qty=0) 返回orders(旧对象快照) runAsync 推送U8 签收单 purchaseReturnConfirm update头(status=OUTBOUND, return_total_qty=71) update行(return_qty=实际值) update(order) 全字段更新 头PENDING/0,行已签收(不一致)

5.2 修复后(正确版)

Async(U8 Push) MySQL Service Manager Client Async(U8 Push) MySQL Service Manager Client 仅更新u8_push_status/u8_push_at等字段,不碰业务状态 创建单 purchaseReturnApply insert头/行 runAsync 推送U8 签收单 purchaseReturnConfirm update头(status=OUTBOUND, return_total_qty=71) update行(return_qty=实际值) updateU8PushFieldsById(...) 头行一致


6. 修复方案(可落地)

6.1 方案A:异步线程改为"字段级更新"(推荐,改动最小)

为什么这么做

异步线程只负责"推送结果",不应该写业务主状态字段。

把更新范围收窄,天然避免覆盖签收流程写入的关键字段。

伪代码(反例)

java 复制代码
// 反例:高风险,整对象更新
runAsync(() -> {
    order.setU8PushStatus("SUCCESS");
    order.setU8PushAt(now());
    orderDao.update(order); // 可能把status/returnTotalQty旧值写回
});

伪代码(正例)

java 复制代码
// 正例:只更新异步职责内字段
runAsync(() -> {
    PushPatch patch = new PushPatch();
    patch.setId(order.getId());
    patch.setU8PushStatus("SUCCESS");
    patch.setU8PushAt(now());
    patch.setU8TransfersNo(order.getU8TransfersNo()); // 可选
    patch.setUserDefined3(order.getUserDefined3());   // 可选
    orderDao.updateU8PushFieldsById(patch);
});

Mapper 示例(思路)

xml 复制代码
<update id="updateU8PushFieldsById">
  update inventory_return_order
  <set>
    <if test="u8PushStatus != null">u8_push_status = #{u8PushStatus},</if>
    <if test="u8PushAt != null">u8_push_at = #{u8PushAt},</if>
    updated_at = now()
  </set>
  where id = #{id}
</update>

7. 进一步加固(为什么要这么做)

7.1 乐观锁 version(防"最后写覆盖")

为什么

即便做了字段级更新,未来其他模块仍可能误用整对象 update。

乐观锁可以在并发写冲突时显式失败,不让脏覆盖静默发生。

伪代码

java 复制代码
int affect = update ... where id = ? and version = ?;
if (affect == 0) {
    throw new ConcurrentUpdateException("并发更新冲突");
}

7.2 状态机约束(防逆向回写)

为什么

异步线程只应改"推送态",不应改"业务态"。

状态机约束可防止 OUTBOUND 被错误写回 PENDING。

伪代码

java 复制代码
if (updater == ASYNC_PUSHER) {
    forbidUpdate("status", "return_total_qty", "apply_total_qty");
}

7.3 签收幂等(防重复请求)

为什么

虽非本次主因,但重复调用是高频线上问题,建议一起补齐。

伪代码

java 复制代码
Order db = orderDao.findByOutReturnNo(outReturnNo);
if ("OUTBOUND".equals(db.getStatus())) {
    return true; // 幂等返回成功
}

8. 历史脏数据修复思路

建议筛选规则:

  • 头表:status = PENDING
  • 头表:return_total_qty = 0
  • 行表:存在 return_qty > 0

修复步骤:

  1. return_total_qty = sum(line.return_qty)
  2. 结合库存事件/签收日志判断是否应修为 OUTBOUND
  3. 修复后写审计日志(记录修复人、时间、批次号)

提醒:先灰度、再全量,避免误修。


9. 最终总结

这次事故的技术本质是:

异步旧对象 + 全字段 update = 并发脏覆盖

最有效的止血方案不是"多加 if",而是:

  1. 异步改字段级 patch 更新
  2. 关键表加乐观锁
  3. 业务状态走状态机约束
  4. 入口补齐幂等
  5. 历史数据做可审计修复

这套方法不仅能修这一个 bug,还能系统性降低"线上数据不一致"事故率。


相关推荐
庞轩px3 小时前
第七篇:Spring扩展点——如何优雅地介入Bean的创建流程
java·后端·spring·bean·aware·扩展点
tongluowan0074 小时前
一个请求在Spring MVC 中是怎么流转的
java·spring·mvc
夜郎king5 小时前
Spring AI 对接大模型开发易错点总结与实战解决办法
java·人工智能·spring
oradh5 小时前
Oracle数据库中的Java概述
java·数据库·oracle·sql基础·oracle数据库java概述
组合缺一5 小时前
Java AI 框架三国杀:Solon AI vs Spring AI vs LangChain4j 深度对比
java·人工智能·spring·ai·langchain·llm·solon
c++之路5 小时前
适配器模式(Adapter Pattern)
java·算法·适配器模式
吴声子夜歌6 小时前
Java——接口的细节
java·开发语言·算法
阿拉金alakin6 小时前
深入理解 Java 锁机制:CAS 原理、synchronized 优化与主流锁策略全总结
java·开发语言
myheartgo-on6 小时前
Java—方 法
java·开发语言·算法·青少年编程
雨落在了我的手上6 小时前
如何学习java?
java·开发语言·学习