# 实时数据管线：推送、背压与合并

> 行情不是一个“更快的接口”，而是一条消费者永远慢于生产者的无界数据流。这让背压成为核心设计问题，而它的答案取决于你的流是日志还是寄存器。

- Canonical (HTML): https://kaguc.com/blog/realtime-data-pipeline-zh/
- Date: 2026-08-28


*English version: [Real-time data pipelines: push, backpressure, and conflation](/blog/realtime-data-pipeline/)。本文是[交易类 App 架构](/blog/trading-app-architecture-zh/)系列第一篇。*

大多数客户端代码都默认一种形态：你发问，服务端回答，你渲染。行情数据流同时打破了这个假设的三个部分。没有人发问，数据自己就来了；没有所谓“回答”，这条流永远不会结束；生产者比消费者快，而且是设计使然的长期状态。

把这三件事想清楚，一个实时客户端的架构基本就自己成型了。

## 数据为什么必须被推送

最朴素的做法是轮询：每隔 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"](https://web.archive.org/web/20190913020020/https://engineering.linkedin.com/distributed-systems/log-what-every-software-engineer-should-know-about-real-time-datas-unifying)，LinkedIn Engineering，2013（存档）——日志作为每一条都有意义的追加序列，以及快照 + 偏移量作为复制原语。
- [Reactive Streams 规范](https://www.reactive-streams.org/)，2015——把背压作为生产者与消费者之间的一等契约；缓冲、丢弃、合并这三种诚实的回应，就是消费者在没有背压时仅有的选择。
- M. Brooker，["Exponential Backoff and Jitter"](https://aws.amazon.com/blogs/architecture/exponential-backoff-and-jitter/)，AWS Architecture Blog，2015——为什么没有抖动的退避会把恢复变成第二次故障。
- J. Dean, L. A. Barroso，["The Tail at Scale"](https://research.google/pubs/the-tail-at-scale/)，*CACM* 56(2)，2013——为什么描述用户体验的是分位数而不是平均值。
- Binance，[WebSocket Streams——"How to manage a local order book correctly"](https://developers.binance.com/docs/binance-spot-api-docs/web-socket-streams)——快照 + 增量、以序列号为接合点的公开规范。

---

*下一篇：[订单可靠性](/blog/order-reliability-zh/)，为什么“恰好一次”并不存在，以及应该怎么做。*

