结论

同一次 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 进程可以并发执行:

  1. 旧消费者查询,得到 0。
  2. 新消费者在自己的锁内查询,也得到 0。
  3. 旧消费者完成累计和插入。
  4. 新消费者完成累计和插入。

因此问题不是“完全没有幂等判断”,而是幂等判断没有覆盖跨协议并发,也没有通过共享锁、幂等记录或数据库约束把判断和写入变成一个互斥业务动作。

新旧协议差异

对比项旧 app_sa_order_out新 garage_repair_outbound_created
主要定位字段应用订单号、出库单号、源明细 IDDGJ 销售单主键/单号、出库单号、sid、联系人
明细匹配SAAS 源明细 IDinv_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,但该字段只能作为辅助证据。删除前必须用日志和消息字段确认哪条代表真实的新协议出库。

数据修复原则

  1. 先本地备份:把主单、全部明细、该出库单的全部流水导出到 Git 仓库外的本地 SQL 文件,权限限制为当前用户,并生成 SHA256。
  2. 严格条件:修复 SQL 必须包含订单主键、订单号、sid、明细主键、出库单号、当前错误数量和状态。
  3. 只删重复事实:保留真实出库流水,只删除已由日志证明的重复协议流水。
  4. 按真实出库量恢复:主单和明细数量以保留流水及 DGJ 出库事实为准。
  5. 状态分别计算:父订单部分出库保持 13;某个单行已全部出库时,该明细可以是 14,即使父订单仍是 13。
  6. 收货字段成组处理:仅当错误待收货导致用户又确认收货、且需要将订单从已完成恢复到部分出库时,才同时清空 in_time 和 in_operator_id。
  7. 不改无关数据:未被重复累计的明细、DGJ 库存、真实出库单和支付记录不做“顺手归一化”。
  8. 事务后回查:核对流水合计、主单数量、明细数量、状态、库位和收货字段。

代码修复与兼容策略

  • 最小止血:注释 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