深入理解 IM 系统

第 2 章

从轮询到推送

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

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

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

想让平均等待降到 0.3 秒,1 万台在线手机每秒要发多少个轮询请求?

轮询123456推送123456048121620 秒

Ana 发出的消息带回消息的轮询空轮询消息等了多久

轮询:这 6 条
平均等待 1.6 秒10 次请求, 4 次空的
推送:这 6 条
平均等待 0.2 秒6 推送帧
  • 每 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、OpenAI 的 API 都用 SSE):一问、一长串答,写完就结束;IM 却要双向,还要一直连着。
  4. WebSocket(原生 App 里也常用自定义的 TCP 长连接协议):一开始是一个普通的 HTTP 请求,带着 Upgrade: websocket;服务器回 101,同意“升级”,之后这条 TCP 连接就不再说 HTTP,而是传一帧一帧的数据(一帧大致就是一条消息),成了一条一直开着的双向管道。服务器可以随时往手机写,不用等手机来问;每条消息只多几个字节的帧头,而不是一次请求几百字节的 HTTP 头。今天大多数 IM 用的是这一类:比如 Discord 的客户端通过 WebSocket 连接它的 Gateway;Telegram 的 App 和服务器之间保持一条用 MTProto 协议的长连接。
做法平均等待每秒请求(1 万台手机)每条消息的额外开销能不能双向穿过代理和防火墙
短轮询(每 2 秒)1.2 秒(半个间隔 + 0.2 秒)5,000,72% 以上是空的一次 HTTP 请求和回复,几百字节到 1 KB 的头发送另走请求都能过
长轮询约 0.2 秒,偶尔加上重新发起请求的间隙约 1,700(1,389 + 10,000 ÷ 30)每收一次,一次请求的头发送另走请求都能过
SSE(HTTP 流式)约 0.2 秒约 1,389 条消息,加少量重连每条几个字节只能服务器 → 客户端一般能过
WebSocket / 自定义 TCP约 0.2 秒约 1,389 帧帧头 2–14 字节双向wss(加密)通常能过;明文 ws 常被代理弄坏

WebSocket 还是原生 TCP? 这是同一件事的两种做法,常常同时存在:

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

所以常见的做法是:一套消息协议,两种跑法,App 走原生 TCP,网页走 WebSocket,后面接的是同一个连接层。Telegram 的 MTProto 就把 TCP 和 WebSocket 都列为传输方式。

3. 推送

选最后一种:每台手机连上来时,和服务器之间保持一条 长连接长连接long-lived connection设备和服务端之间一直开着的一条 TCP 或 WebSocket 连接。有了它,服务端可以随时主动把消息推下去,不用等客户端来问。在术语表里查看(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(长轮询)和 webhook。没有公网 HTTPS 地址的机器人,用长轮询最省事。
  • 实时库的第一步:Socket.IO 默认先用 HTTP 长轮询连上,再升级到 WebSocket,因为总有一些代理和防火墙挡 WebSocket。
  • 后台的手机:App 退到后台后长连接会被关掉,要靠系统推送叫醒,打开时再拉一次,也就是第 1 章那种“有新的吗”(第 15、25 章)。

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

7. v0 的总结

v0 的终点:连接层变成持有长连接的一块,服务器一写入就推给 Ben;Ben 重连时仍按 after=<id> 拉一次。
AnaBenWebSocket推送;重连时after=<id>一个程序连接层持有长连接;记着 用户 → 连接消息层(收)检查、写入、回复 id分发层(发)写入后推给对方的每条连接业务层(旁路)Ana 在这个会话里吗存储层数据库messages 表

整个 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. 这一章的决定