「至少一次投递」是回调类接口的标准语义。工程实践中常见的错误,是选用单一方案(如唯一索引)覆盖全部重复场景。本文以一次真实事故说明单一方案的缺口,并给出四层防护的完整设计。
一、事故样本
某订单的支付结果通知在四分钟内投递三次。接收方对通知日志表建立唯一索引:第一次处理成功,后两次被索引拦截并直接确认。该机制对「日志重复插入」有效,但第二次投递到达前,另一补偿链路已将订单状态推进至「已发货」;第三次投递携带渠道侧旧快照,处理代码未校验当前状态直接覆盖,触发重复发货。当周共七笔订单重复出库。
二、单一方案的缺口分析
| 方案 | 覆盖场景 | 缺口 |
| 唯一索引 | 重复插入 | 业务动作不在同一事务 |
| 先查后写 | 串行重复 | 并发竞态窗口 |
| 分布式锁 | 并发互斥 | 锁失效;不解决乱序 |
| 状态判断 | 显性重复 | 多链路写状态后依据失效 |
结论:四层防护按职责叠加——幂等键负责识别业务意图,唯一约束负责并发兜底,状态机负责合法性判断,结果回放负责响应重复请求。
三、表结构设计
CREATE TABLE notify_claim ( id BIGINT PRIMARY KEY AUTO_INCREMENT, biz_key VARCHAR(160) NOT NULL COMMENT '渠道流水号 + 事件类型', order_no VARCHAR(64) NOT NULL, payload_hash CHAR(64) NOT NULL COMMENT '报文摘要,识别同键不同内容', claim_state VARCHAR(16) NOT NULL DEFAULT 'CLAIMED', result_json TEXT COMMENT '处理结果,重复请求回放', created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uk_biz_key (biz_key) );
payload_hash 用于拦截「同键不同报文」:该情形表明对方变更了内容重推,应进入人工核对队列而非自动放过。
四、处理实现
def handle_notify(payload): biz_key = f"{payload['channel_txn_id']}:{payload['event_type']}" digest = hashlib.sha256(payload["raw_body"].encode()).hexdigest() try: db.insert("notify_claim", {"biz_key": biz_key, "payload_hash": digest}) except DuplicateKeyError: row = db.get("notify_claim", biz_key=biz_key) if row["payload_hash"] != digest: alarm.push("PAYLOAD_CONFLICT", biz_key) return "FAIL", None return "SUCCESS", row.get("result_json") moved = db.execute(""" UPDATE biz_order SET status = 'PAID' WHERE order_no = %s AND status = 'PENDING_PAY' """, (payload["order_no"],)) if moved == 0: current = db.get_order(payload["order_no"])["status"] db.update("notify_claim", biz_key=biz_key, claim_state="DONE", result_json=json.dumps({"skipped": current})) return "SUCCESS", current with db.transaction(): outbox.write("ORDER_PAID", payload["order_no"]) db.update("notify_claim", biz_key=biz_key, claim_state="DONE") return "SUCCESS", "PAID"
实现要点:占位插入与业务变更位于同一事务,杜绝「占位成功、业务失败」的割裂;状态更新采用带原状态条件的 UPDATE,以影响行数判定是否真实推进;下游消息经 outbox 表与事务同提交。
五、校验清单
- 幂等键由「渠道流水号 + 事件类型」构成;
- 唯一索引兜底,占位与业务同事务;
- 报文摘要入表,同键不同内容告警;
- 带条件的 UPDATE 判定状态推进;
- 重复请求回放第一次结果;
- 非法跃迁确认成功并记录,避免触发对端重试;
- 同步路径 200 毫秒内返回,重活异步化;
- 监控重复命中率,异常上涨提示对端重推。