线上数据"头行不一致"排障实录:不是重复调用,而是异步旧对象覆盖(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 = PENDINGreturn_total_qty = 0
- 明细(行表):
return_qty已有值(>0)
同时,主单上还能看到:
u8_push_status = SUCCESScompleted_at有值
也就是说:头看起来没签收,行看起来已签收。
2. 第一轮排查:为什么一开始会怀疑重复调用
出现这类问题时,大家通常先怀疑:
purchaseReturnConfirm被重复调用(幂等没做好)- 补偿任务改过同一单据
- 事务部分提交导致"半成功"
这些方向都合理,但这次并不是主因。
3. 时间线还原:问题真正发生的窗口
通过日志和库里时间字段还原出关键顺序(已脱敏):
- 创建时间:
15:54:34 - 签收完成时间:
15:54:45 - U8 推送成功时间:
15:54:48
这个顺序很关键:
- 签收主流程先把业务字段写正确
- 异步线程后落库
- 最终结果却是旧值 -> 典型"后写覆盖前写"
4. 根因定位:异步线程拿旧对象做全字段更新
根因一句话:
异步线程持有的是创建时的旧对象快照,并执行了整对象 update,导致旧值把新值覆盖。
4.1 创建流程里的对象初始值
创建时对象通常是:
status = PENDINGreturn_total_qty = 0
对象被放入 orders 集合后,异步线程直接复用该对象。
4.2 签收流程会把数据改成正确值
签收会更新:
- 头表:
status=OUTBOUND、return_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
修复步骤:
return_total_qty = sum(line.return_qty)- 结合库存事件/签收日志判断是否应修为
OUTBOUND - 修复后写审计日志(记录修复人、时间、批次号)
提醒:先灰度、再全量,避免误修。
9. 最终总结
这次事故的技术本质是:
异步旧对象 + 全字段 update = 并发脏覆盖
最有效的止血方案不是"多加 if",而是:
- 异步改字段级 patch 更新
- 关键表加乐观锁
- 业务状态走状态机约束
- 入口补齐幂等
- 历史数据做可审计修复
这套方法不仅能修这一个 bug,还能系统性降低"线上数据不一致"事故率。