结论
同一次 DGJ 出库曾在一个 addOutBound() 业务流程中同时发送旧事件 app_sa_order_out 和新事件 garage_repair_outbound_created。SAAS 为两类事件注册了不同消费者,两边都先查询 t_order_out_flow 再累计订单/明细出库数量并插入流水。
由于旧消费者没有进入新消费者的分布式锁,且 t_order_out_flow.out_bill_no 只有普通索引,没有跨协议原子幂等边界,两个消费者并发时可能同时读到“流水不存在”,随后各写一次,造成出库数量翻倍、订单提前进入待收货;若用户随后确认收货,订单还会进入已完成。
本次最小止血在 DGJ 正常出库链路中停发旧事件,保留新事件。SAAS 的旧消费者暂不直接删除,以兼容可能仍存在的独立旧生产者;长期仍应建设跨协议共享的业务幂等能力。
现象与影响
- DGJ 实际只部分出库,SAAS 却显示全部出库或已完成。
t_order_out_flow中同一出库单、同一订单明细和同一数量出现两条流水。- 主单
out_order_number、明细out_goods_number被累计两次。 - 主单可能从
13(待发货)错误变为14(待收货)。 - 用户在错误的待收货状态下确认收货后,状态变为
1(已完成),同时写入in_time和in_operator_id。
完整链路
sequenceDiagram
participant U as 店管家出库操作
participant D as DGJ addOutBound
participant O as 旧 app_sa_order_out
participant N as 新 garage_repair_outbound_created
participant SO as SAAS saOrderOut
participant SN as SAAS GarageRepairOutboundSyncService
participant DB as SAAS MySQL
U->>D: 提交一次真实出库
par 旧协议
D->>O: billNo/appOrderNo/detailId/qty
O->>SO: 消费
SO->>DB: 查询 out_bill_no 是否存在
and 新协议
D->>N: 销售单、出库单、库存与库位明细
N->>SN: 消费
SN->>DB: 新协议锁内查询 out_bill_no 是否存在
end
Note over SO,SN: 两套消费者没有共享同一把锁
DB-->>SO: 0 条
DB-->>SN: 0 条
SO->>DB: 累加主单/明细并插入旧流水
SN->>DB: 累加主单/明细并插入新流水
Note over DB: 数量翻倍,状态可能提前进入待收货
代码证据
DGJ 旧生产者
application/Services/InvSa/NormalSaleSer.php- 旧调用:
MqSer::service()->sendSaOrderOutToMq($mqData)。 application/Services/Mq/MqSer.php::sendSaOrderOutToMq()发送app_sa_order_out。
DGJ 新生产者
application/service/scm/InvSaService.php::addOutBound()。- 调用
SyncOrderSer::syncOutboundCreate()。 application/Services/SyncOrder/SyncOrderSer.php发送garage_repair_outbound_created。
SAAS 旧消费者
app/Amqp/Consumer/Dgj/DgjConsumer.php::saOrderOut()。- 先调用
OrderOutflowRepository::getCountByOutBillNo()。 - 再累计主单
out_order_number、明细out_goods_number并插入t_order_out_flow。
SAAS 新消费者
app/Amqp/Consumer/Dgj/DgjConsumer.php::garageRepairOutboundCreated()。- 调用
app/Service/Order/Sync/GarageRepairOutboundSyncService.php::createFromDgj()。 - 新链路支持创建、更新、撤销、库位、延迟重试、订单汇总和报价单绑定。
- 新链路使用
garage_repair_outbound_created:{sid}:{outBillNo}分布式锁,但旧消费者不会获取该锁。
数据库幂等边界
app/Model/Repository/OrderOutflowRepository.php::getCountByOutBillNo()是普通COUNT查询。- 写入发生在查询之后,属于非原子的“先查后写”。
- 生产表
t_order_out_flow只有主键、out_bill_no普通索引和order_no普通索引,没有阻止两套协议并发落同一业务事实的唯一约束。
为什么已有重复检查仍然失效
顺序消费时,后到事件通常能看到已有流水并停止;但两个 Consumer 进程可以并发执行:
- 旧消费者查询,得到
0。 - 新消费者在自己的锁内查询,也得到
0。 - 旧消费者完成累计和插入。
- 新消费者完成累计和插入。
因此问题不是“完全没有幂等判断”,而是幂等判断没有覆盖跨协议并发,也没有通过共享锁、幂等记录或数据库约束把判断和写入变成一个互斥业务动作。
新旧协议差异
| 对比项 | 旧 app_sa_order_out | 新 garage_repair_outbound_created |
|---|---|---|
| 主要定位字段 | 应用订单号、出库单号、源明细 ID | DGJ 销售单主键/单号、出库单号、sid、联系人 |
| 明细匹配 | SAAS 源明细 ID | inv_id + is_gift 等同步维度 |
| 库位 | 通常不携带 | 支持 location_area_id |
| 生命周期 | 仅创建式累计 | 创建、更新、撤销 |
| 重试与补偿 | 旧 Consumer 异常处理 | 锁、延迟重试、订单未就绪补偿 |
| 下游动作 | 主单、明细、流水 | 主单、明细、流水、订单汇总、报价单绑定 |
新协议覆盖了维修厂订单出库同步的主要业务能力,但“新协议存在”不等于“可以立即删除所有旧消费者”。正确迁移顺序是先保证正常生产者只发新事件,再通过日志确认没有独立旧生产者,最后单独评审旧消费者退役。
标准排查步骤
1. 确认真实出库事实
以 DGJ 出库单及其明细数量为业务事实,不以 SAAS 当前状态反推真实出库量。
2. 对齐两类消息日志
在紧凑时间窗内同时搜索:
- 出库单号;
app_sa_order_out;garage_repair_outbound_created;- SAAS 日志
app_sa_order_out与dgj_order_sync。
不同 eventId 只能说明是两条消息,业务去重应使用 sid + 出库单号,并继续下钻到出库明细。
3. 查询 SAAS 数据
订单分表必须由已验证 sid 和当前分片代码确定,不能照抄示例后缀。
SELECT id, order_no, order_number, out_order_number, order_status,
out_time, in_time, in_operator_id
FROM t_order_<suffix>
WHERE sid = <sid>
AND order_no = '<order_no>';
SELECT id, order_id, inv_id, goods_number, out_goods_number,
order_status, location_area_id
FROM t_order_detail_<suffix>
WHERE order_id = '<order_id>';
SELECT id, out_bill_no, order_no, order_detail_id,
out_goods_number, location_area_id, create_time
FROM t_order_out_flow
WHERE out_bill_no = '<out_bill_no>'
ORDER BY id;
重复候选查询:
SELECT out_bill_no, order_detail_id, out_goods_number, COUNT(*) AS row_count
FROM t_order_out_flow
WHERE out_bill_no = '<out_bill_no>'
GROUP BY out_bill_no, order_detail_id, out_goods_number
HAVING COUNT(*) > 1;
旧流水经常没有真实库位,新流水可能带非零 location_area_id,但该字段只能作为辅助证据。删除前必须用日志和消息字段确认哪条代表真实的新协议出库。
数据修复原则
- 先本地备份:把主单、全部明细、该出库单的全部流水导出到 Git 仓库外的本地 SQL 文件,权限限制为当前用户,并生成 SHA256。
- 严格条件:修复 SQL 必须包含订单主键、订单号、sid、明细主键、出库单号、当前错误数量和状态。
- 只删重复事实:保留真实出库流水,只删除已由日志证明的重复协议流水。
- 按真实出库量恢复:主单和明细数量以保留流水及 DGJ 出库事实为准。
- 状态分别计算:父订单部分出库保持
13;某个单行已全部出库时,该明细可以是14,即使父订单仍是13。 - 收货字段成组处理:仅当错误待收货导致用户又确认收货、且需要将订单从已完成恢复到部分出库时,才同时清空
in_time和in_operator_id。 - 不改无关数据:未被重复累计的明细、DGJ 库存、真实出库单和支付记录不做“顺手归一化”。
- 事务后回查:核对流水合计、主单数量、明细数量、状态、库位和收货字段。
代码修复与兼容策略
- 最小止血:注释 DGJ
NormalSaleSer中正常出库流程的旧sendSaOrderOutToMq()调用。 - 保留新
syncOutboundCreate(),因为它承载当前维修厂出库的完整同步语义。 - 暂时保留 SAAS
saOrderOut()消费入口,避免误伤尚未盘点完成的历史独立生产者。 - 修复提交已进入 DGJ
develop;从修复前切出的功能分支仍可能显示旧调用为启用状态,发布前必须合并或变基到包含修复的基线。
回归清单
- 正常部分出库:只产生新事件,SAAS 只累计一次,父订单保持待发货。
- 正常全部出库:只累计一次,父订单进入待收货。
- 同一新事件重复投递:不重复累计、不新增同一出库事实。
- 出库更新:先回滚指定出库单影响,再按新明细重放。
- 出库撤销:仅回滚指定出库单,不影响同订单其他出库单。
- 历史旧事件:若仍由独立旧生产者发送,旧消费者行为保持可用并被监控。
- 分支检查:实际待发布分支中的旧生产者调用必须处于禁用状态。
长期改进
- 设计覆盖旧、新消费者的共享业务幂等键或幂等记录,判断与写入必须处于同一互斥边界。
- 不应直接对
out_bill_no建简单唯一索引,因为一个出库单可以合法包含多个明细流水;唯一性需要先明确出库头与明细的业务键。 - 为“双协议同出库单并发”增加集成测试,而不只测试同一事件串行重放。
- 建立旧事件生产者清单和退役观测窗口,确认无流量后再删除旧消费者。
相关 Skill 与手册
- SAAS 项目 Skill:
saas-dgj-message-resender - 消息重试与补偿手册:
15_发布运维与排查手册/SAAS_DGJ/01_维修厂订单同步重试与补偿手册.md - SAAS 仓库:
/Users/zhoujiangbin/code/docker-dev-env/www/saas - DGJ 仓库:
/Users/zhoujiangbin/code/docker-dev-env/www/dgj2.0