`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,还能系统性降低"线上数据不一致"事故率。


相关推荐
Xiu Yan2 小时前
Java 转 C++ 系列:函数对象、谓词和内建函数对象
java·开发语言·c++
那个失眠的夜2 小时前
Spring整合Mybatis实现用户的CRUD
java·spring·mybatis
superantwmhsxx2 小时前
Spring Initializr创建springboot项目,提示java 错误 无效的源发行版:16
java·spring boot·spring
山河梧念2 小时前
【保姆级教程】VMware虚拟机安装全流程
android·java·数据库
莫逸风2 小时前
【java-core-collections】红黑树深度解析
java·开发语言
李少兄2 小时前
Fastjson2 处理 JSON 字段大小写不一致的优雅方案
java·json
计算机毕业设计指导2 小时前
基于SpringBoot+Vue3的荣成市健康管理平台设计与实现
java·spring boot·后端
渔民小镇2 小时前
5 分钟搭建桌游服务器:Room 模块 + 领域事件实战
java·运维·服务器·分布式·游戏
SeeD NICK2 小时前
Spring Boot 3.4 正式发布,结构化日志!
java·spring boot·后端