跨链支付系统中的幂等设计:如何避免重复监听、重复回调与重复结算
在普通 Web 系统中,一个接口被重复调用往往只会产生重复记录;在跨链或支付系统中,重复执行结算可能直接造成资产损失。
可靠设计的目标不是假设事件只到达一次,而是接受以下事实:
- 区块扫描会重复;
- RPC 请求会超时;
- 消息队列至少投递一次;
- 下游回调可能已经成功,但响应在网络中丢失;
- 链可能发生短距离重组;
- 运维人员可能手动重新执行任务。
因此,整个结算链路必须具备端到端幂等性。
### 1. 为链上事件建立稳定唯一键
EVM 代币转账可以使用:
```text
chain_id + transaction_hash + log_index
```
不能只用交易哈希,因为同一笔交易可能包含多条 `Transfer` 日志。也不能只用接收地址和金额,因为多个订单可能出现相同金额。
数据表可以建立唯一约束:
```sql
CREATE UNIQUE INDEX uniq_chain_event
ON chain_events(chain_id, tx_hash, event_index);
```
非 EVM 链需要使用该链可稳定定位一条资产转移的字段,例如交易签名加指令索引。
### 2. 状态转换必须是条件更新
错误写法是先读取订单状态,再在应用层判断后更新。并发消费者可能同时读到相同状态。
更可靠的做法是把状态前置条件写进 SQL:
```sql
UPDATE orders
SET status = 'deposit_confirmed', updated_at = NOW()
WHERE id = ? AND status = 'confirming';
```
只有受影响行数为 1 的消费者才有资格继续触发路线执行。
### 3. 结算请求需要业务幂等键
调用流动性或结算服务时,使用订单 ID 与结算版本组成幂等键:
```text
settlement:{order_id}:{attempt_version}
```
下游必须保存该键与执行结果。客户端重试时返回原有结果,而不是再次发送目标资产。
### 4. 使用 Outbox 避免数据库与消息队列不一致
如果先更新数据库再发送消息,进程可能在两者之间崩溃。可以在同一事务内写入订单状态和 outbox 事件:
```sql
BEGIN;
UPDATE orders
SET status = 'deposit_confirmed'
WHERE id = ? AND status = 'confirming';
INSERT INTO outbox_events(event_id, topic, payload)
VALUES (?, 'route.execute', ?);
COMMIT;
```
独立发布器负责把 outbox 事件投递到消息队列,并记录投递状态。
### 5. 不要过早把链上交易视为最终状态
监听到交易后,应先记录为 `detected`,等待足够确认后再进入 `confirmed`。如果发生链重组,系统需要能够撤销尚未进入不可逆结算阶段的事件。
确认策略可以根据链、资产和金额分层,而不是全站使用同一个固定确认数。
### 6. 建立对账任务
实时流程之外,还需要周期性对账:
- 已确认入金但没有路线记录;
- 已执行路线但没有目标链交易哈希;
- 目标链交易成功但订单仍未完成;
- 同一订单出现多个结算记录;
- 数据库状态与链上状态不一致。
跨链系统的核心原则可以概括为一句话:**事件允许重复到达,但资产结算必须只能成功一次。**
本文只讨论工程设计,不构成任何数字资产投资建议。