突发之下丢弃并打乱 order book 更新的处理器,通常是一个症状背后的两处独立故障:接入侧的序号处理,以及一个慢会话就能改变其他所有会话收到内容的分发层。本文讲序号、恢复、fan-out 和背压是怎么搭起来的,以及 conflation 从哪里开始不再诚实。
突发之下丢弃并打乱 L2 更新的处理器,通常是一个症状背后的两处独立故障。一处在接入侧:那里跟踪行情源的序号,缺口必须被发现,而不是被吞掉。另一处在分发侧:一份归一化后的账本要 fan-out 给大量会话,而单个慢读者会改变其他人收到的东西。
两者的修法不同,用错了只会让症状挪个地方,而不是消失。下面讲的是序号必须扛住突发时,这条链路长什么样:缺口到底是什么、恢复如何把快照与实时流拼起来、fan-out 如何把次序只定一次,以及 conflation 在哪里是诚实的。
简短的答案是结构性的。次序只在一个地方决定,位于所有会话的上游:每个合约由唯一的写入者把带序号的行情流折叠成账本,会话拿到的是从这次折叠派生出来的视图——它们从不自己给任何东西重新排序。amBrain 可以公开证实的内容:我们构建的一个迷你交易所在 MOEX 托管机房的生产环境中运行,我们构建了 Spectre Trade 交易终端,我们公布的行情延迟是实测得来的——在我们搭建的路径上低于 5 ms。这个数字描述的是我们的路径,而不是下文这套设计的跑分。
序号承诺的是次序,不是送达
行情源会给自己的更新编号,而这个号是你手里唯一的排序依据。到达时间不是:组播路径会乱序,一个合约可能由多条通道承载,接收队列分散在多个核上,而一波突发会把这一切都拉长。按到达顺序排序的处理器,只在网络平静时才是正确的——而那正是没人担心过的情形。
写恢复逻辑之前,必须先弄清行情源的六项属性。每一项都会改变「缺口」的含义。
- 序号覆盖的单位——通道、合约还是账本。按通道编的号不会告诉你哪个合约丢了一条更新,按合约编的号也不会告诉你某条通道停了
- 递增规则:在该单位内严格连续,还是递增但允许有空号。两种都存在,把第二种当成第一种来读,会触发一堆本来不需要的恢复
- 序号是否在交易时段边界重新开始,以及用什么来标记这个边界——把一次重置读成缺口,会让所有合约在同一刻涌进恢复
- 心跳是否携带当前序号。没有它,一条死掉的连接和一个安静的合约看起来一模一样
- 有没有重传,窗口有多大。如果没有,快照恢复就是唯一的回头路,那它必须便宜到可以经常用
- 快照对齐到哪个序号。没有这个,快照根本无法与实时流拼接
凡是某项属性确实不清楚的地方,去测它,而不要把猜测写进代码。这六项每一项都会在恢复路径上变成一个分支,那里的错误假设,日后会以一份悄悄与交易场所对不上的账本的形式被发现。
缺口和乱序,在最初几毫秒里长得一模一样
两者的开头一模一样:下一条更新没带着你期待的那个号。区别在于时间,所以分类不在到达的那一刻做出,而在一个有上限的等待到期时做出。
- 乱序:你期待 N,收到 N+2,而 N+1 在等待还没关闭时到了。什么都没丢,唯一的代价是这段等待
- 重复或重传:序号小于等于最后应用的那个。直接丢弃、不碰账本,但要计数,因为重复率上升说明这条链路有情况
- 缺口:等待到期,N+1 始终没到。账本无法越过这个洞往前走,这个合约进入恢复流程
- 过期:号是对的,但到得太晚,已经没用了。字节确实到了,可在下游它就是一次丢失
有一条规则能让损坏不至于变成无声的:只有当一条更新的序号恰好是期待的那个时,它才被应用。其余的一律进等待缓冲或进恢复。接受了乱序增量的账本照样在供价、看着也健康——与交易场所的不一致要到后来才被发现,由某个客户在一笔说不通的成交上发现。
这个等待是一个有界结构,不是一条会涨的队列。它按序号为键,存放跑在期待号前面的更新,因此释放它们是一次查表,而不是一次排序。
- 释放是一个循环:先应用期待的那个号,然后只要号还连着,就把已经缓冲的继续应用下去
- 这个期限要用时间来表达,而不只是用待处理更新的条数——一波突发把按条数设的窗口填满的时刻,远早于设计者的本意
- 缓冲等多久,每个消费者就跟着等多久。这个期限要按你自己链路上实测的乱序程度来定,而不是按一个感觉安全的数字来定
- 缓冲写满本身就是一次缺口声明:等待的上限既在时间上,也在内存上
- 等待按合约或按通道来,绝不设成全局的。一个安静的合约,不该把它周围的一切都拖住
恢复,就是把一份快照与你早已开始缓冲的流拼接起来
出问题的正是拼接这一步。快照是截至某个序号的账本,它在被生成的那一刻就已经过期;让它变得可用的,是取快照期间缓冲下来的增量流。
- 在请求快照之前就开始缓冲增量流。背后没有实时流的快照,落地时就已经落后于市场
- 读出快照所对应的那个序号。如果行情源根本不发布这个号,那它实际上就只能靠快照,设计文档必须把这一点明说出来
- 丢弃序号小于等于快照序号的缓冲更新,其余按序应用。若其中第一条不是紧接快照的那一条,说明拼接失败,恢复重新来过
- 若快照到达之前缓冲就写满了,就重新开始恢复,而不是只应用其中一部分——只应用了一半的恢复,与一份健康的账本看不出区别
- 合约在恢复期间,要作为流上的一个显式状态发布为「降级」。一份带着洞的账本,还当成当前状态供出去,比没有账本更糟
- 拼接之后要校验:行情源发布的校验和(如果它发布的话),或者你折叠出的账本与下一份快照是否一致
恢复是常规事件,不是事故,它的成本属于容量规划的一部分:取一份快照要多久、这期间要缓冲多少流,以及多少个合约可以同时恢复而不让快照服务成为瓶颈。
fan-out:归一化一次,编码一次,发很多份
数百个终端会话要的是同一份账本。在突发之下被放大的错误,是把本质上不属于单会话的工作按会话去做:为每个订阅者重建一份账本,或者把同一条更新按每个 socket 各序列化一次。
- 每个合约分片只有一个写入者持有账本。读者从不修改它,这样既去掉了锁,也去掉了谁的版本作准这个问题
- 写入者把带版本的更新发布进一个环形缓冲,读者各自按自己的节奏跟进,因此某个读者掉队不会拖慢任何人
- 每条更新按每种传输格式只编码一次,再以引用的方式在会话之间共享。只有分帧和流控是按会话来的
- 每个会话带着自己的外发序号,客户因此不必了解上游行情源,也能发现自己这条链上的丢失
- 次序按合约保证,因为客户依赖的正是这个保证。跨合约的次序,要么明确承诺并且真的实现,要么干脆不承诺
- 超出单个进程之后,fan-out 变成一层中继:每个中继向上游只订阅一份,向下服务一部分会话,写入者的工作量因此保持恒定
fan-out 的成本,由一条更新被转换了多少次决定,而不是由多少个 socket 收到它决定。编码一次、传引用,能随会话数扩展;为每个会话重建一份账本,则不能。
慢消费者是你选定的策略,不是碰巧发生的意外
总有那么一个会话在糟糕的网络上,或者某个终端的渲染循环卡住了,于是它的外发缓冲写满。可能的行为有四种,其中两种只会是不小心选中的。
- 阻塞写入者,等慢会话把积压排空:绝不。它会把一条坏连接变成整个分片上所有人的延迟事故
- 让队列无上限地涨:一个慢消费者就会变成内存耗尽,接着变成一次与原会话毫无关系的宕机
- 有界队列加 conflation:对账本状态是正确的,因为客户要的是当前的图景,而不是每一个中间步骤
- 有界队列加高水位断连:对无法做 conflation 的流是正确的,因为在那里丢掉一条,就是丢掉含义
- 无论采用哪种策略,队列都是按会话的,滞后要持续测量——队列深度,以及已发布序号与已写入 socket 的序号之间的距离
- 断开时要说明原因。没有原因的关闭会被循环重试;说明了原因的,后面跟的是一次重新订阅
背压是两侧交汇的地方。如果外发路径能把压力顶回账本写入者,一个慢终端最终会拖慢行情的折叠,缺口检测便会因为与交易场所毫无关系的原因开始报警。在两者之间放一个有界的环形缓冲,就能切断这条链。
conflation 对状态诚实,对事件则是错的
账本是状态:客户要的是当前的价位,而一个已经被替换掉的值,本身不再有任何含义。成交流水则是一份事件日志,其中每一条都是发生过的事实,不能被概括掉。
- 可以做 conflation 的:价位更新、盘口顶端、聚合深度,以及最新价或当日成交量这类派生统计
- 不可做 conflation 的:成交与逐笔成交记录、订单与成交回报、集合竞价与市场阶段变化,以及任何被客户按时间聚合的东西——用做过 conflation 的流拼出来的成交流水,是一个被自信地拿在手里的错数
- 按 key 做 conflation,而不是按流做。为每个价位保留最新的一条更新,账本就还在;只保留整体上最新的那一条,则会丢掉最后一次没有变动的所有价位
- 经过 conflation 的更新,带着它所代表状态的序号,客户因此知道它对应的是哪个时点
- conflation 的时间间隔,是你所报延迟的一部分。按间隔做过 conflation 的流,不能用未做 conflation 的流上测得的延迟来描述
- 需要每一个中间状态的客户——回测、合规留痕——订阅未做 conflation 的流,代价付在带宽上
conflation 是形态的改变,不是一个压缩选项。一条流一旦做过 conflation,客户就无法重建两条更新之间发生了什么,因此不能告诉它这条流是完整的。同时发布两条——做过 conflation 的账本流和未做 conflation 的事件流——才能让两类客户都保持正确。
重连就是一次重新同步,而它们总是同时到来
会话回来时,它手里那份账本一文不值,除非服务端能证明中间没有断。默认做法是:每个订阅给一份新的快照,带上它的序号,应用到一个已经先把本地状态丢掉的客户端上。
- 只有在存在有界重放缓冲的地方,才提供按序号续传。当请求的号已经过期被挤出缓冲,服务端如实告知并退回到快照,而不是发一条带洞的流
- 重连之后会话状态怎么处理,是一个必须明确做出的决定:要么服务端凭会话令牌在有限时间内保留订阅,要么客户端在连上后重新声明一遍。两种都行;含糊地混着来则不行
- 续传之后出现重复投递是意料之中的,客户按序号丢弃即可。至少一次加上序号,比精确一次更容易实现正确
- 重连总是扎堆到来,因为让一个会话断开的原因,通常也让很多会话断开了。客户端带抖动的退避,加上服务端的准入控制,能让这次恢复不变成第二次宕机
- 面对这一拥而上的请求,快照来自按合约维护、定期刷新的缓存,写入者因此是按固定节奏序列化快照,而不是每来一个重连会话就序列化一次
- 客户端的账本是重建,绝不是打补丁。把旧价位留着、直接往上叠新增量的终端,会把断连之前的错误带进一份看起来崭新的账本里
值得为之做设计的失效,不是一次单独的重连。而是一次网络事件让数百个会话在同一秒内回来,每个都在为自己盯着的每个合约索要快照,与此同时接入侧还在从同一事件造成的缺口中恢复。
amBrain 可以公开证实的内容:我们在亚美尼亚埃里温,用 Rust 构建低延迟交易平台、matching engine 和实时竞价系统,我们公布的行情延迟——低于 5 ms——是在我们搭建的路径上测得的。如果你的处理器在突发之下正在丢序号,值得先谈的,是在动手重写任何一侧之前,把接入侧和分发侧分开。