回报是交易的另一半。委托报出去只是开始,资金和持仓的变动要等成交回报回来才算数。而回报链路——交易所到会员、会员到柜台、柜台到客户端——每一跳都可能把同一条成交记录送两次。这篇讲为什么"恰好一次"在这个链路里是数学上就立不住的目标,幂等消费怎么落地,以及回报处理和委托状态机之间必须守住的不变量。
为什么回报必然重复
三个结构性原因,哪个都消灭不掉:
- 传输层的尽力而为:交易所回报常用 UDP 组播推送,丢了不补,但重复很常见——同一帧被两台网关收到、网关重启后重放缓存,都会变成重复回报;
- 断线重连的补偿:柜台与交易所的会话断掉再连上,标准做法是按回报序号补拉缺口。补拉窗口和实时推送窗口必然重叠,重叠区就是重复区;
- 故障切换的代价:柜台主备切换时,备机不确定最后几条回报对端是否收到,宁可重发——重复总比丢失好,这是全链路各个环节的共同倾向。
结论:链路语义是 at-least-once。在不可靠信道加节点故障的模型下,exactly-once 要求无限期保留去重状态,没人付得起这个成本。所以正确的姿势是一句话:允许重复送达,保证重复无效。
幂等键:回报里必须有的两个号码
一条成交回报最少要携带两个能唯一定位它的字段:
- 委托号:柜台侧订单标识,断线重报后新旧会话靠它对齐;
- 成交号:交易所撮合时分配的全局唯一号,同一次成交无论重放多少次,成交号不变。
幂等键就是 $(\text{委托号}, \text{成交号})$ 这个二元组,落地是数据库里的一条唯一约束:
CREATE TABLE exec_report (
order_id VARCHAR(32) NOT NULL,
exec_id VARCHAR(32) NOT NULL, -- 交易所成交号
last_qty DECIMAL(18,2) NOT NULL, -- 本次成交量
last_px DECIMAL(18,4) NOT NULL, -- 本次成交价
report_seq BIGINT NOT NULL, -- 会话内单调递增序号
recv_time TIMESTAMP NOT NULL,
PRIMARY KEY (order_id, exec_id)
);
重复回报到达时主键冲突,捕获后写一张重复回报日志就丢弃。注意日志必须记:重复回报本身是异常信号,某客户的重复率突然飙升,先查他家网关是不是出了问题,而不是静默吞掉。
序号 report_seq 是第二道防线,消费时计算缺口:
$$ \text{gap} = \text{seq}_{\text{expected}} - \text{seq}_{\text{received}} $$
- gap > 0:乱序到达,进暂存窗口等前面的到齐;
- gap < 0:丢包,触发按序号补拉;
- 暂存窗口有容量上限(一般几百条),超了说明对端状态已经乱了,降级报警走人工。
幂等键解决"同一条来两次",序号解决"中间少了一条",两个机制正交,缺一不可。
部分成交的累计不变量
回报会驱动资金解冻,处理逻辑里要维护一条硬不变量:累计成交量 ≤ 委托量,以及终态等式:
$$ \text{累计成交} + \text{累计撤单} = \text{委托量} $$
部成回报到达时做累计,一旦累计成交超过委托量,要么收到了假回报,要么本地委托量被改过——这是脏数据,直接熔断这条委托的后续处理,让日终对账来收口。宁可停一条委托,不能错一分钱。反过来,交易所发"全成"回报时累计量应该恰好等于委托量,差一点都要查:残量去哪了?是不是还有一条部成在乱序窗口里躺着?
和状态机的咬合:只有回报能驱动迁移
撤单篇立过规矩:迁移即账务,账务随确认。落到成交上具体是三条纪律:
- 柜台严禁用"我算出来应该会成交"提前改状态。预扣资金只是冻结,部成回报回来才真正记减少;
- 全成确认之前,剩余量保持冻结,因为"部成 → 撤残量"的组合随时可能出现;
- 回报处理必须在委托粒度上串行化:同一委托的两条部成并发处理,会打出错序的余额流水。做法是把回报按 order_id 散列到固定分区的消费线程,保证单委托内严格有序。
吞吐上有个常见误区:去重要不要每条查库?热点时段回报十万级 TPS,每条都 SELECT 一次去重表,数据库会先被打穿。做法是内存哈希集合做一级去重(进程重启后从去重表重建),落库走批量异步,主键约束兜底最后一道防线。和撮合一样,在这条链路上数据库是审计层,不是热路径。
最后一条纪律:对账先于信任。柜台内存里的成交明细,日终必须和交易所下发的成交对账文件逐笔核对。幂等保证"不多",对账保证"不少"——交易系统里所有关于"不少"的承诺,最终都落在对账上。