重放无法避免 — 要挡住的是第二次下单
一句话总结
进程崩溃后又恢复,要选择从哪里开始重新读取,读得太靠前,订单就会发出两次,读得太靠后,订单就会漏掉。在这两者之间没有安全的点。所以答案不是“恰好读一次”,而是让重新读取时也产生同样的订单 ID,并让接收第二次的一方把它过滤掉。
为什么需要它
发送订单的程序,通常是这个样子。从信号数据流里读一条,生成订单消息并发送,把处理到哪里写进检查点。问题的全部,就在于这三个动作之间进程可能崩溃。
如果在发送之后才写检查点,那么发送完、还没写之前崩溃时,这一段会被重新读取。订单会再发一次——这是 at-least-once。如果在发送之前就写检查点,那么写完、还没发送之前崩溃时,这一段就永远不会被读到。订单丢了——这是 at-most-once。此外,检查点通常不是每一条都保存,而是几条打包保存。所以重叠的区间和遗漏的区间,扩大的不是一条,而是一个批次的大小。
在订单这件事上,这两者的分量并不相同。丢掉的订单可以重新下,但发了两次的订单可能已经成交,那样就会产生不想要的持仓。所以发送路径通常选择 at-least-once,并在接收方过滤重复。重复的过滤放在哪里,才是这个问题真正的设计决定。
工作原理
要想过滤,就必须有一张能认出“同一个订单”的表。订单消息里有发送方附上的标识符,在 FIX 系列规范中,这个标识符叫作 ClOrdID。更正和撤销会为自己重新分配 ID,同时用 OrigClOrdID 指向之前的 ID。规范的含义可以在 FIXimate 中查到,规范本身可以在 FIX Trading Community 标准页面 中确认。
这里有一个人们最常栽跟头的地方。ID 是怎么生成的。每次运行都从 1 开始编的序列号,混入了进程 ID 或启动时间的前缀,以及随机数的 UUID——这三种都很常见,而且三种对重处理都无能为力。因为即使再次读取同一个信号,也会得到不同的 ID。接收方没有办法知道,这是新订单,还是刚才已经收到的订单。
修复的方法很简单。让 ID 只由输入中稳定的字段生成。把数据流名称、偏移量、标的、方向、数量、价格这些重新读取也相同的值连起来做哈希,那么同一个信号无论重新读取多少次,都会得出同样的 ID。不放入时间,不放入随机数,也不放入进程 ID。
而且还会有一件事随之而来。更正和撤销所指向的 OrigClOrdID,也必须能用同样的规则重新计算出来。如果把连接偏移量与 ID 的表只放在内存里,重启时就会消失,这样一来,被重处理的撤销消息就会指向不存在的订单。交易所会拒绝它,而在这期间,原订单依然存活。想撤销的订单没有被撤销、一直留在那里,就是这条链断裂时的实际后果。
最后还剩下去重窗口要定多长。这不是喜好,而是观测值。从数据中测量同一个信号最初发出的时间,与因重处理而再次发出的时间之差,再乘以余量。如果窗口比观测到的最大重处理延迟还短,去重就形同虚设。
在现场相遇的样子
曾经在某条发送路径上,调查过故障之后的重复订单。只看发送日志,同一个偏移量发出了两次,看起来全是重复,但与交易所的受理确认一对照,并没有一半那么多。一部分因为触及数量限额被拒绝,另一部分因为撤销消息指向了不认识的订单而被拒绝。真正危险的,只有两边都被受理的那些笔,它们的数量,不到最初统计数字的一半。要以被接受的记录来统计,而不是以发送的记录,这是调查的出发点。
还有一点。在事故报告里,经常看到只写着“我们将加入去重”。如果没有窗口长度,这句话就不是工作,而是意见。如果有从数据中测出的延迟,就有了数字,有了数字,日后就可以回头追问这个值对不对。
第三种经常遇到的,是由人用手来做恢复的情形。一旦出了故障,负责人用眼睛看偏移量,再把值填进去,这个值如果错了一位,就直接变成重复订单。如果无法取消手工挑选的流程,至少要在画面上一并显示这个值意味着什么——从这个偏移量开始读,会有多少条被重新发出,其中有多少条已经是被受理的订单。这两个数字一旦显示出来,人通常都能选出正确的值。恢复手册上只写偏移量,而不写它的影响,这是实际事故的一半。
而且,重处理并不会止步于订单。同一个信号流过两次,风险限额的计算、持仓汇总和审计日志也会一起动两次。如果只对订单去重,其余的原样不动,就会成为一种更糟糕的状态:订单只发出了一次,账簿里却记了两次。把去重的点集中到一处,让这之后的所有计算都从这个点经过,是设计的核心。
下一项实验要做什么
从 48 条的信号数据流、两次运行所留下的发送日志、交易所受理确认、检查点和故障记录开始。找出重处理区间,数出被再次发出的订单,亲手实现确定性的订单 ID,再与受理确认对照,只留下真正被接受了两次的订单。接着,把保存检查点的时机前后调换,分别数出重复和遗漏,比较两者,找出断裂的撤销与更正链,最后从数据中求出去重窗口的长度,写成恢复手册。