# 第 2 章：从轮询到推送

> 用户涨到 10 万，想让消息马上到，为什么不能只是问得更勤？

深入理解 IM 系统 · https://im.liko.page/zh/push-or-poll/

App 长到了 10 万日活用户，高峰时 1 万人同时在线。用户开始说聊天“有点慢”：一句话发出去，对方要一秒多才看到。产品经理希望消息马上到。

最直接的办法是问得更勤：把每 2 秒问一次，改成每 1 秒、每 0.5 秒。

## 1. 问得更勤，会怎样

第 1 章算过：轮询的平均等待是半个间隔，再加两段单程的网络时间（共 0.2 秒），也就是 等待 − 0.2 秒 = T ÷ 2。1 万台手机每 T 秒问一次，每秒就有 10,000 ÷ T 个请求。两个式子相乘，T 消掉了：

> 每秒请求 ×（平均等待 − 0.2 秒）= 在线手机数 ÷ 2 = 5,000

等待和请求量被绑在一起：等待少一半，请求就多一倍。在下面的图里拖动蓝点试试：

*（互动图：请在网页上查看 https://im.liko.page/zh/push-or-poll/）*

- 每 2 秒问一次：**每秒 5,000 个请求**，平均等 1.2 秒。
- 每 1 秒：每秒 10,000 个，平均等 0.7 秒。
- 每 0.2 秒：**每秒 50,000 个**，平均等 0.3 秒。

而高峰时真正要送出去的，只有每秒约 1,389 次（每秒 139 条消息，按全书假设每条平均送到 10 台设备）。所以这些请求里至少 72%、86%、97% 是空的。

说句公道话：一台像样的服务器加数据库，扛住每秒五千、一万个带索引的小查询，并不是做不到。真正的问题有两个：

- **等待只能拿负载去换**，换得越多越不划算。想等得再少一点，代价就翻一倍。
- **手机也在付钱**：App 开着时，每一两秒一个请求，流量和电量都白白花在空请求上。

要跳出这个交易，就得换一个方向：不让手机来问，而是让服务器在有消息时主动告诉它。

## 2. 这条路是怎么走过来的

从“手机来问”到“服务器来告诉”，中间有几步。每一步解决了一个问题，也留下一个问题：

1. **短轮询**（第 1 章）：哪里都能用；但大部分请求是空的，消息平均要等半个间隔。
2. **长轮询**：手机发出请求后，服务器先不回答，把这个请求挂在那里，等到有消息、或者大约 30 秒过去，再回答；手机拿到回答，立刻再发下一个。消息来了马上就能回答，等待接近网络时间；每秒请求最多约 1,389 + 10,000 ÷ 30 ≈ **1,722 个**（一次回答可能带好几条，实际更少），比每 2 秒轮询的 5,000 少得多。30 秒的超时是有意选的：常见的负载均衡和代理会掐掉 60 秒左右没动静的连接。回答和下一个请求之间有一个小间隙，这时来的消息要等下一个请求；因为手机带着 `after=<id>` 来问，消息只会晚一点，不会丢。
3. **HTTP 流式（SSE，Server-Sent Events）**：一个永远不结束的回答，服务器有消息就往里写一条。它只能从服务器到客户端，手机发消息还得另发请求。它自带重连：服务器给每条事件标上 `id`，断了以后浏览器会带着最后收到的那个（`Last-Event-ID`）重新连，这正是 `after=<id>`。今天最常见的例子是 AI 聊天的流式回答（[Anthropic](https://platform.claude.com/docs/en/build-with-claude/streaming)、[OpenAI](https://platform.openai.com/docs/api-reference/streaming) 的 API 都用 SSE）：一问、一长串答，写完就结束；IM 却要双向，还要一直连着。
4. **WebSocket**（原生 App 里也常用自定义的 TCP 长连接协议）：一开始是一个普通的 HTTP 请求，带着 `Upgrade: websocket`；服务器回 `101`，同意“升级”，之后这条 TCP 连接就不再说 HTTP，而是传一帧一帧的数据（一帧大致就是一条消息），成了一条一直开着的双向管道。服务器可以随时往手机写，不用等手机来问；每条消息只多几个字节的帧头，而不是一次请求几百字节的 HTTP 头。今天大多数 IM 用的是这一类：比如 Discord 的客户端通过 WebSocket 连接它的 [Gateway](https://docs.discord.com/developers/events/gateway)；Telegram 的 App 和服务器之间保持一条用 [MTProto](https://core.telegram.org/mtproto) 协议的长连接。

*（互动图：请在网页上查看 https://im.liko.page/zh/push-or-poll/）*

**WebSocket 还是原生 TCP？** 这是同一件事的两种做法，常常同时存在：

- **WebSocket**：浏览器里只能用它（或者上面的 SSE、长轮询）；它走 HTTP 的那套基础设施，443 端口、负载均衡、CDN、代理大多能直接通过，现成的库也多。代价是建连时多一次 HTTP 升级，每帧多几个字节的帧头。
- **原生 TCP 加自定义协议**（外面同样套 TLS）：只有原生 App 能用。省掉 HTTP 升级这一步，帧格式可以做得更紧凑，心跳、压缩、加密都自己说了算。代价是“一条消息从哪开始、到哪结束”要自己切（旁支“拆包与组帧”），负载均衡只能按 TCP 转发，有些网络还会挡掉非常规端口。

所以常见的做法是：一套消息协议，两种跑法，App 走原生 TCP，网页走 WebSocket，后面接的是同一个连接层。Telegram 的 MTProto 就把 [TCP 和 WebSocket 都列为传输方式](https://core.telegram.org/mtproto/transports)。

## 3. 推送

选最后一种：每台手机连上来时，和服务器之间保持一条 长连接（WebSocket）。

- 服务器在内存里记一张表：**用户 → 他的连接**。是一组连接，不是一条：Ana 有手机和电脑，全书平均每人 1.5 台设备。这就是第 0 章“在线路由表”最早的样子。
- 既然连接一直开着，Ana 发消息也走这条连接，不用再另发请求。
- Ana 的消息写进表以后，服务器查到 Ben 的每一条连接，把消息写进去。
- 等待变成了网络时间，约 0.2 秒；每秒约 1,389 帧，没有一帧是空的。

在上面那张图里，推送是左下角那个绿色的方块，离轮询那条曲线很远：它不在“拿负载换等待”的交易里。图下面的两条时间线是同一组 6 条消息，轮询平均等 1.6 秒，推送平均等 0.2 秒。

**但拉取不能扔掉。** 在那两条时间线下面勾上“Ben 的手机在第 6–14 秒断线”：断线期间的推送都丢了，连接已经不在了。手机重新连上时，先带着 `after=<id>` 拉一次，丢的消息就全回来了。

为什么推送会丢？服务器“写成功”，只说明这些字节进了服务器自己的发送缓冲区，不说明手机收到了。如果连接已经悄悄断了（在电梯里、切换网络时常有），服务器要过一阵才会发现，这期间写进去的都没了。所以：**推送只是提醒，拉取才是依据。** 手机按 id 去掉重复。还要注意游标（第 1 章里的 `after`）只在拉取的结果里往前移：不同人发来的推送，到达的顺序不一定是 id 的顺序。比如 8 号先推到、7 号还在路上，如果手机看到 8 就把游标移到 8，而 7 号恰好在断线时丢了，重连时它问“8 之后的”，7 号就再也拿不回来了。拉取本身也还不完全严密：第 1 章说过，id 小的消息可能更晚才提交。第 5、6 章会用每个会话连续的序号把这些都做严格。

## 4. 建一条连接，要花多少

上面说的“一条长连接”，实际上几乎都是加密的：网页里是 `wss://`（WebSocket over TLS），App 里是 TLS 上的自定义协议。不加密的 `ws://` 不只是不安全，还常被路上的代理弄坏。所以建一条连接，要先走完几步：

| 步骤 | 来回次数 | 移动网络下（一个来回 0.2 秒） |
|---|---|---|
| TCP 三次握手 | 1 | 0.2 秒 |
| TLS 握手（交换证书、协商密钥） | TLS 1.3：1；TLS 1.2：2 | 0.2 秒 / 0.4 秒 |
| HTTP 升级到 WebSocket | 1 | 0.2 秒 |
| **合计** | **3–4** | **0.6–0.8 秒** |

（还不算查 DNS 的时间。）也就是说，一台手机从打开 App 到能收第一条推送，光建连就要大半秒；而连上以后，每条消息只要单程 0.1 秒。长连接的意义正在这里：**这笔钱只付一次**，之后的每条消息都不用再付。

建连还有一笔看不见的账：**服务器的 CPU**。TLS 握手里要做公钥运算（用证书签名、交换密钥），比之后加解密普通数据贵得多。按全书假设，一台服务器每秒能做约 2,000 次完整的 TLS 握手。这是一个故意保守的数字，大约相当于几个 CPU 核做 RSA-2048 签名；多核机器配 ECDSA 证书，能高出一个数量级。量级变了，下面的结论不变：

- **一次重启，所有人一起重连**：1 万台手机 ÷ 每秒 2,000 次 = 服务器要满负荷算 **5 秒**的握手，这期间新消息也被耽误。到 v3，一台网关上有 10 万条连接，就是 50 秒（第 23 章，重连风暴）。
- **轮询也逃不掉这笔账**：如果每次轮询都新建一条 HTTPS 连接，每 2 秒一次就是每秒 5,000 次握手，是一台服务器能力的 2.5 倍。所以轮询也得靠 HTTP keep-alive 复用连接，而 keep-alive，本身就已经是一条“长一点的连接”了。

省下这笔钱的办法：TLS 会话恢复（session ticket、TLS 1.3 的 PSK），让重连时跳过证书签名和验证这最贵的一步；以及别让所有人在同一秒回来（第 23 章）。

还有一条新路：**HTTP/3**，它跑在走 UDP 的 QUIC 上，把传输层握手和 TLS 1.3 握手合成一步，新连接只要 **1 个来回**（TCP 加 TLS 1.3 要 2 个）；恢复连接时还能 0 个来回就发数据（0-RTT，TLS 1.3 也有），不过这种数据可能被重放，只适合重复执行也无害的请求，发消息不算。另外，WebSocket 跑在 HTTP/3 上还很少见，浏览器里常是退回 TCP；UDP 被挡时怎么办、连接迁移怎么用，留给旁支“TCP 还是 UDP”和第 25 章。

## 5. 代价

- **服务器开始有状态了。** 长轮询其实已经有了（挂着的请求，和“谁在等”的表；在 Go、Node 这类事件驱动的服务器里，挂着的请求不占线程，在“一个请求一个线程”的老式服务器里就挂不了多少），推送更明显：服务器要记着每个人连在哪。按全书假设，一条空闲连接约占 30 KB 内存（栈、读写缓冲、TLS 状态、内核），1 万条就是 **约 300 MB**。一台服务器装得下；到第 21 章，百万连接时就装不下了。
- **重启会断掉所有人**，一起重连（握手的账见上一节；第 19、23 章）。
- **路上的设备要放行长连接。** 负载均衡、代理、防火墙要允许连接长时间开着；运营商的网络和手机系统会关掉太久没动静的连接（第 22 章讲心跳，第 25 章讲 App 退到后台以后）。

## 6. 轮询今天还活在哪里

长连接不是在所有地方都赢。

**实践中**：作者做过的第一个聊天系统用的是 WebSocket，但其中有一个业务场景，印象中是电视盒子之类的设备，建不了 WebSocket，只好退回长轮询。有些客户端环境就是保持不了 WebSocket，长轮询是哪里都能走通的退路。

公开的例子也不少：

- **聊天机器人**：Telegram 的 Bot API 给了两种取消息的方式，[`getUpdates`](https://core.telegram.org/bots/api#getupdates)（长轮询）和 webhook。没有公网 HTTPS 地址的机器人，用长轮询最省事。
- **实时库的第一步**：[Socket.IO](https://socket.io/docs/v4/how-it-works/) 默认先用 HTTP 长轮询连上，再升级到 WebSocket，因为总有一些代理和防火墙挡 WebSocket。
- **后台的手机**：App 退到后台后长连接会被关掉，要靠系统推送叫醒，打开时再拉一次，也就是第 1 章那种“有新的吗”（第 15、25 章）。

轮询还在用，原因通常是这几个：哪里都能穿过；服务器不用记状态、容易扩展、能用缓存；数据不急；或者客户端根本保持不了一条连接。

## 7. v0 的总结

*（互动图：请在网页上查看 https://im.liko.page/zh/push-or-poll/）*

**整个 v0**：一台服务器。连接层成了单独的一块，持有长连接，记着“用户 → 连接”；消息层、分发层还在同一个程序里，业务层还是几行判断；一个数据库。

**能扛多少**：1 万条连接、高峰每秒 139 条消息，一台服务器够用。把整台机器和每台手机的账都算一遍（轮询按 HTTP/1.1 和 keep-alive 算）：

**服务器（1 万人在线）**

| 资源 | 每 2 秒轮询 | 推送 |
|---|---|---|
| CPU | 每秒 5,000 个请求，解析 HTTP、查库 | 每秒约 1,389 帧；平时很省，重启时集中做握手 |
| 内存 | keep-alive 时也挂着约 1 万条空闲连接，但随时可关，不用记“用户 → 连接” | 每条连接约 30 KB，1 万条约 300 MB，还要记“用户 → 连接” |
| 网络带宽 | 请求和回复各带几百字节到 1 KB 的 HTTP 头：5,000 × 2 × 0.5–1 KB ≈ 40–80 Mbit/s，几乎全是头 | 消息本身 1,389 × 200 字节 ≈ 2.2 Mbit/s |
| 网卡（包量） | 每个请求一来一回再加确认，约 3–4 个包：每秒约 1.5–2 万个包 | 每帧加一个确认，约 2 个包：每秒约 2,800 个包 |
| 磁盘容量 | 消息约 0.8 GB/天；若每个请求记一行 200 字节的访问日志，高峰每小时约 3.6 GB，按平均负载（约为高峰的三分之一）约 29 GB/天 | 消息约 0.8 GB/天，日志少得多 |
| 磁盘 IO | 写：高峰每秒约 139 次插入，每次提交通常一次 fsync（强制落盘）；读：每秒 5,000 次查询，缓存不命中就是随机读；日志每秒约 1 MB 顺序写 | 写：同样每秒约 139 次；读：只在重连时拉一次，很少 |

网卡看的是包的数量，不是字节：小包多的时候，先吃满的往往是网卡和处理收包中断的 CPU。现在的网卡通常每秒能处理上百万个包，v0 远没到；连接到了百万级、分到多台网关上时（第 21 章）才要算这笔账。另外，每条连接在系统里都算一个打开的文件（文件描述符）。Linux 默认的软上限至今仍常是每个进程 1,024 个，超了会报 “too many open files”；Go 1.19 起会自己调高，其他情况在 ulimit 或 systemd 里配置一下即可，真正的限制是上面那行内存。HTTP/2、HTTP/3 会压缩重复的头，网络那一行会小很多，但请求数、空回答和下面手机被唤醒的次数都不变。

**实践中**：作者的经验是，因为业务需要打的日志比较多，磁盘 IO 反而经常成了瓶颈，比消息本身的读写更早撑不住。表里那 1 MB/s 只是每个请求一行的访问日志，磁盘轻松扛得住；业务日志往往一个请求好几行、每行更大，还常常是一小段一小段同步地写，和数据库挤在同一块盘上。日志量跟着请求数走，而轮询的请求数是推送的好几倍。常见的办法是：只在关键路径上打日志，大量重复的请求按比例采样；日志先在内存里攒一批再写，写到单独的磁盘上，或者直接发到别的机器去收集。

**一台手机（App 开着一小时）**

| 资源 | 每 2 秒轮询 | 推送 |
|---|---|---|
| 请求 | 1,800 个 | 收到几条，推几条 |
| 流量 | 光 HTTP/1.1 头就有 1,800 × 2 × 0.5–1 KB ≈ 1.8–3.6 MB | 收到的消息本身：每条约 200 字节 |
| 电量 | 每 2 秒唤醒一次蜂窝无线电（radio），它每次醒来要 10 秒左右才回到低功耗，所以一直醒着 | 每来一条消息唤醒一次 |
| 后台 | 系统不让后台每 2 秒请求 | 连接会被系统关掉，要靠系统推送叫醒（第 15、25 章） |

推送省电省多少，要看聊天有多忙。按全书假设，高峰时每台在线设备每秒约收到 1,389 ÷ 10,000 ≈ 0.14 条，一小时约 500 条，平均每 7 秒一条：这么忙的时候，推送也会让无线电大约四分之三的时间醒着，离轮询的一直醒着不远。聊天安静时（比如一小时 20 条），推送只醒 20 次，约 3 分钟，轮询却照样醒满一小时。所以推送最省的是安静的时候；忙的时候，要靠把几条消息攒在一起发（第 22、25 章）。另外，长连接以后还要加心跳（第 22 章）。

推送赢在网络、磁盘，以及安静时手机的电量和流量；输在服务器要记住每个人连在哪，以及重启时集中爆发的 CPU。

**下一步会坏在哪**：**连接会断。** 在电梯里、在地铁上，连接可能在发送的半路断掉，手机不知道 Ana 那条消息到底有没有到服务器。重发一次，可能变成两条；两条消息也可能以不同的顺序到达。这就是 v1（第 3–9 章）。第 3 章会把这些情况抽象成“丢包”来模拟。

## 8. 这一章的决定

**决策卡**

- 问题：1 万台手机在线，用户要求消息马上到；轮询只能拿负载换等待。
- 选择：每台设备一条 WebSocket 长连接，服务器写入后主动推送；连接和重连时按 after=<id> 拉取，推送只是提醒。
- 代价：服务器有了状态（用户 → 连接，约 30 KB 一条）；建连要 3–4 个来回，TLS 握手吃 CPU，重启会让所有人一起重连；路上的设备要放行长连接。
- 什么时候重新考虑：连接多到一台装不下、发布时的重连扛不住时（第 19、21、23 章）；心跳和后台（第 22、25 章）；网络变差、消息会丢会重复时（第 3 章起）。
- 其他答案：长轮询（被挡时的退路）；SSE（只要服务器往下推时）。
