English version: Real-time data pipelines: push, backpressure, and conflation。本文是交易类 App 架构系列第一篇。
大多数客户端代码都默认一种形态:你发问,服务端回答,你渲染。行情数据流同时打破了这个假设的三个部分。没有人发问,数据自己就来了;没有所谓“回答”,这条流永远不会结束;生产者比消费者快,而且是设计使然的长期状态。
把这三件事想清楚,一个实时客户端的架构基本就自己成型了。
数据为什么必须被推送
最朴素的做法是轮询:每隔 n 毫秒问一次。它在进入生产环境之前,先在算术上就失败了。
轮询给数据的陈旧程度设了一个下限,这个下限等于轮询间隔。想让数据不超过 100 毫秒旧,就得每秒问十次。再乘上屏幕上的每一只标的、以及每一个在线客户端。无论数据有没有变化,服务端每一跳都要完整处理一次请求,而绝大多数时候它并没有变。你付费购买的是时钟,不是信息。
推送把成本模型倒了过来。只有数据变化时才产生工作量。平静的市场只消耗一条空闲连接,剧烈的市场只消耗那些真正携带新消息的报文。这正是你想要的性质:成本与变化成正比,而不是与时间成正比。
推送的代价是,连接不再是一次事件,而是一份由你负责维护的状态。这份状态需要:
- 心跳。 TCP 会很乐意替你保持一条实际上已经死掉的连接。没有应用层的 ping,你无法区分“市场很安静”和“我们二十分钟前就断了”。必须让沉默变得可证伪。
- 带指数退避和抖动的重连。 退避是为了不去捶打一台本就吃力的服务器;抖动则是因为,如果没有它,所有在同一时刻掉线的客户端会在同一时刻回来,你的恢复过程会变成第二次故障。
- 续传令牌,让重连不等于从零开始。
丢包,以及日志与寄存器的区别
每条消息都带序列号。它不是用来记账的,而是用来发现那些你没收到的消息。行情里被悄悄丢掉的一次更新不是“缺了数据”,而是错误的数据:你的客户端此刻正在显示一个它以为是最新、实际上不是的数字。
发现丢包之后该怎么做,取决于这条流是什么类型。这个分类会决定后面的一切。
寄存器保存的是当前值。最后一次写入胜出,被覆盖的早期写入不再有意义。最新成交价就是寄存器:如果你漏掉了三跳、收到了第四跳,你并没有损失任何第四跳没告诉你的东西。
日志是一串增量,其中每一条都有意义。用增量更新维护的订单簿就是日志:漏掉一条,之后所有更新都会叠加在一个已经损坏的基础上。这种损坏是永久且无声的,而且会不断累积。
所以,对寄存器来说,丢包只是一次小磕碰;对日志来说,丢包意味着重新同步:丢弃本地状态,请求一份新快照,再从该快照的序列号往后应用增量。通用模式是快照加增量,以快照的序列号作为接合点。
把你产品里的每一条流都写下来,标明它是日志还是寄存器。这是一个五分钟的练习,但它会替你决定下一节的答案。
背压才是真正的设计难题
你的消费者是一部手机,你的生产者是一个市场。在高负载下生产者一定赢,而“赢”的意思是你的队列会无限增长,直到进程被杀死。
诚实的应对只有三种:
全部缓冲。 原理上正确,实践中致命。内存无上限增长;更糟的是,你最终渲染出来的数据已经过时。你造了一个队列,把新鲜度问题变成了延迟问题,然后又变成了崩溃。
丢弃。 落后时主动丢消息。对寄存器是合理的,对日志是灾难性的。
合并。 每个键只保留最新值,把你来不及消费的部分合并掉。两帧之间某只标的的十跳,坍缩成一跳。这是寄存器的正确答案,而且它不是降级:渲染最新价完全正确,因为那些中间跳在人眼能感知之前就已经被覆盖了。
合并是实时客户端里收益最高的一个想法,而它直接来自日志与寄存器的划分。你不能合并一条日志,也永远不该缓冲一个寄存器。
把数据频率和刷新频率解耦
屏幕每秒刷新 60 次。一只活跃标的的更新频率可以远高于此。所以超过每秒 60 次的那部分更新,在客户端上根本无法被表示,它们只能给你带来成本。
由此得到的架构是:一条接收路径负责解码、检测丢包,并合并进一张“每标的当前值”的表;一条渲染路径每帧采样这张表一次。数据频率和帧率就此独立。市场活跃度涨十倍,接收成本略增,渲染成本完全不变。
常见的翻车做法是把套接字直接接到 UI 状态上,于是每条消息都触发一次重绘。这种设计在测试时表现优秀,在第一个剧烈波动的开盘就会垮掉,因为你无意中把自己的帧率和市场绑在了一起。
原生边界该划在哪里
如果你要跨越语言边界(Dart 到 C++,JVM 到原生),成本主要来自每次调用,而不是每个字节。仅这一个事实就替你决定了边界的位置。
把解码、丢包检测、合并、分发放在快的那一侧。每帧跨越一次,带上一批发生变化的标的。不要每条消息跨一次。
一个每跳都跨边界调用的客户端,可能比全部用托管语言实现还要慢,因为它为了省下一次解码,付出了成千上万次跨越开销。这里选择原生的理由不是“C++ 快”,而是托管运行时的尾延迟不可预测。一次垃圾回收停顿在平均值里看不见,却会在最糟糕的时刻表现为一次明显的卡顿。你在热路径上选原生,是为了时序的确定性,也仅仅是为了这个。
订阅是生命周期,不是一次调用
最后一块是很容易被低估的簿记工作:
- 按标的做订阅引用计数。 两个组件显示同一只标的,应该只产生一次订阅,并且在第二个组件离开时才结束。
- 重连后重新订阅。 服务端的订阅状态已随套接字一起消失。如果客户端不这么认为,它会展示一个冻结的画面,而这个画面看起来和“市场很安静”一模一样。这是最糟糕的失败形态,因为它和正常工作无法区分。
- 退到后台时降级。 不可见的应用应当取消订阅,或降到低频摘要。这是“表现良好的应用”和“因为耗电被卸载的应用”之间的区别。
什么时候完全不需要这些
上面的一切,都由一个数字决定:你的数据变化频率,相对于人的感知速度是多少。如果某个值每几秒才变一次,那么一条 WebSocket 把 JSON 直接交给 UI 状态,就是正确的设计,上面每一项机制都是浪费。
需要的纪律是,等到测量结果要求时再加,大致按这个顺序:先是轮询开始变痛,才上推送;正确性提出要求,才上序列号;队列开始增长,才上合并;只有当性能剖析指向解码循环时,才引入原生边界,在此之前绝不。
参考
- J. Kreps,“The Log: What every software engineer should know about real-time data’s unifying abstraction”,LinkedIn Engineering,2013(存档)——日志作为每一条都有意义的追加序列,以及快照 + 偏移量作为复制原语。
- Reactive Streams 规范,2015——把背压作为生产者与消费者之间的一等契约;缓冲、丢弃、合并这三种诚实的回应,就是消费者在没有背压时仅有的选择。
- M. Brooker,“Exponential Backoff and Jitter”,AWS Architecture Blog,2015——为什么没有抖动的退避会把恢复变成第二次故障。
- J. Dean, L. A. Barroso,“The Tail at Scale”,CACM 56(2),2013——为什么描述用户体验的是分位数而不是平均值。
- Binance,WebSocket Streams——“How to manage a local order book correctly”——快照 + 增量、以序列号为接合点的公开规范。
下一篇:订单可靠性,为什么“恰好一次”并不存在,以及应该怎么做。