专业书籍精读 · System Design Interview 卷一 · 第 12 章
System Design Interview Vol 1 · Ch 12 · Alex Xu · 2020
你在微信、WhatsApp 上跟人说话,消息「叮」一下就到了。这一章讲的就是怎么把这个「叮」造出来。它看上去比信息流简单——不就是把一句话从 A 送到 B——其实底下压着一个更基础的麻烦:在互联网最常用的那套规矩里,服务器根本没办法主动来找你。
把服务器想成一个邮局,它的规矩很死:你走进去问一句「有我的信吗」,它才会回答你。它不会主动敲你家的门。所以「寄信」毫无问题——走进去把信交给柜台就行;难的是「收信」:你怎么知道什么时候有信?
最笨的办法是每隔几秒跑一趟邮局问一次。你不累,邮局累:几千万人都这么问,它每秒要应付上千万次问询,而九成九的回答是「没有」。改良一点是去了就赖着不走——问完不回家,站在柜台前等,有信立刻给你,实在没有就过一阵请你先回去、你再来一趟。空跑少了,可你还是得反复来回;而且邮局也说不清你到底是回了家,还是在半路上晕倒了。
今天的做法是第三种:你和邮局之间接一根一直不挂断的电话线。接通那一下依然是你打过去的(规矩没破),但线一旦通了,两头谁想说话都能直接说——邮局终于能主动开口了。消息之所以「叮」一下就到,就是因为这根线一直开着,不用每次重新拨号。
一、人不在线怎么办。线断了,消息先存在服务器上,摆进一个专属于你的收件箱;你什么时候上线,什么时候来取。实在等不了就改走手机推送通知——那已经是别人家的地盘了。
二、群聊怎么发。人少的群,最省事的办法是抄 N 份:一条消息往每个人的收件箱各放一份。人少时这笔账很划算,人一多就彻底不行。
三、怎么知道谁还在。不能一断线就判「离线」——你钻进地铁网抖一下,头像就灰了,出来又亮,一路闪。所以真实系统靠心跳:在线的人每隔几秒吱一声,连着几十秒没吱声,才算真下线。
诚实的代价:这根一直开着的线不是白来的——服务器要为每个在线的人留一块内存,一台机器撑到几百万根就到顶;更麻烦的是它一旦要重启,上面所有人会在同一秒一起回头重连。
聊天系统真正难的不是「发」,是「收」。发消息跟你平时打开网页没区别;可要让服务器能主动找到你,就得先跟它接一根不挂断的线——之后所有复杂度:这根线接到哪台机器、断了消息存哪儿、群里怎么抄、怎么判断你还在——全是在为这一根线善后。
想进到长连接接入层、消息 ID 定序与在线状态的具体量级? → 切到精读版
这一章的主角不是消息,是连接。它从一条协议事实出发——HTTP 只能由客户端开口,服务端无法主动找人——推出整个架构的形状:接入层必须握着一条长期不断开的 WebSocket,于是变成有状态的;有状态就带来服务发现(该连哪台)、心跳(还在不在)、收件箱(不在时消息存哪)、会话内单调的消息 ID(收到后怎么排序)这一整串连锁代价。聊天系统里最难的从来不是「发」,是「收」。
本章是卷一 Part II「系统案例」的第 9 篇。上承 Ch11 信息流——两章都要把内容分发给多个人,但信息流是一对多广播、可容忍分钟级先后差,聊天是双向、要求秒内送达且不能丢;也上承 Ch10 通知系统——用户一旦离线,聊天就退化成通知问题。下启 Ch13 搜索自动补全。现实里对应 WhatsApp、Messenger、Slack、Discord、微信,以及任何带客服窗口的产品。
书里的场景:5000 万日活、一对一与群聊、群成员上限 100 人、纯文本、单条上限 10 万字符、聊天记录永久保存、一个账号多设备同时在线、要在线状态与离线推送。
先算消息量(以下是按这个场景做的推算,不是书里的数字):人均每天 20 条,就是 10 亿条 / 天 ≈ 1.16 万条 / 秒,峰值按 3 倍算约 3.5 万条 / 秒——对现代存储不算吓人。
真正的压力不在消息,在连接。假设同一时刻有 10% 的日活在线,就是 500 万条并发长连接。书里给了单条连接约 10 KB 内存的量级——100 万条连接光连接本身就吃掉约 10 GB,500 万条就是 50 GB,还没算任何业务数据。聊天系统的容量单位是「同时挂着的连接数」,不是 QPS,这是本章第一个要换掉的直觉。
不解决会怎样?退回轮询的话,5000 万人每 5 秒问一次就是 1000 万次请求 / 秒 的常压,绝大多数返回「没有新消息」,而延迟仍然是轮询间隔那么大。
本章最该记住的判断在最开头:发送方与接收方并不对称。发消息是客户端主动发起的动作,一次普通 HTTP 请求就够了。可「收」不行——HTTP 的整个模型是客户端问、服务端答,服务端没有任何合法途径主动敲门。整章后面所有结构,都是为了绕过这一条。
书里摆了三种绕法:轮询(定期问,简单但延迟等于间隔、空答占绝大多数)、长轮询(问了先挂着,有消息再回)、WebSocket(一次 HTTP 握手升级成双向持久通道,两端都能主动写)。长轮询看着是个便宜的折中,书却点名了三个具体毛病:收发双方可能连到不同的服务器(HTTP 服务器通常无状态、由负载均衡轮转分配,服务端不知道该把消息交给谁)、判断不出客户端是否已经断开、以及不聊天的用户也会因超时而周期性重连。下面的对比表把三者摊开。
选了 WebSocket,代价立刻落到架构上:握着连接的那台机器,成了这个客户端唯一能用的机器。于是书把服务分成三类:无状态服务(注册、登录、资料,摆在负载均衡后面,任意一台都行)、有状态服务(聊天服务,客户端在某台机器上挂着持久连接,只要那台还活着就不换)、第三方集成(推送通知)。
有状态带来的第一个新组件是服务发现:客户端上线得先问「我该连哪一台」,本章用 ZooKeeper 登记所有可用的聊天服务器,按地理位置与容量挑一台推荐给它。没有这一层,客户端只能盲连,负载会歪到某几台上。
更要命的是运维假设全变了:无状态服务重启一台没人察觉;有状态接入层重启一台,就是上面所有连接同时断开、同时重连、同时重新鉴权、同时重拉历史。这就是「重连风暴」——这一层最典型的死法不是被外部流量打垮,是被自己恢复时的那一下打垮。
本章把数据分成两类:通用数据(用户资料、设置、好友列表)量小、要事务,放关系型数据库;聊天记录量极大,放键值存储。三条理由:键值存储水平扩展容易、随机访问延迟低,而关系型数据库处理长尾数据不好——索引和数据集一大,随机访问就变贵。书还点了名:Messenger 用 HBase,Discord 用 Cassandra。
数据模型是这一节的硬核。一对一聊天用一张消息表,主键是 message_id——不能拿 created_at 当排序依据,两条消息完全可能落在同一毫秒。群聊用复合主键 (channel_id, message_id),channel_id 是分区键,因为群聊的所有查询都发生在一个频道之内——同一个群的消息全落在一个分片上,翻历史只访问一个分片。
消息 ID 要同时满足唯一和按时间可排序。做法有二:全局的 64 位序列号生成器(Snowflake),或只在单个会话内保证递增的局部生成器。书的判断是后者就够——聊天系统不需要全局有序,只需要每个会话内有序。这是本章最省力的一次降级:把「分布式全局定序」这个真难题缩回成单分区内的自增问题,而分区键早就有了,就是 channel_id。
一对一的完整流程:A 把消息发给 chat server 1 → 服务器取一个消息 ID → 进消息同步队列 → 落进键值存储 → B 在线就转给它所连的 chat server 2 推下去,离线则交给推送通知服务。存储发生在推送之前,这个顺序不能反:先落盘再投递,才谈得上「不丢」。
多端同步是这一节最漂亮的设计。每台设备维护一个 cur_max_message_id,记着自己见过的最大消息 ID;要拉新消息,就查「收件人是我、且 ID 大于 cur_max_message_id」的那些。它值得单独学,是因为它把「送达到哪儿了」的记账责任从服务端搬给了设备自己。服务端不必为每个用户的每台设备维护游标,也就不必处理「新装一台设备该从哪开始」;断线重连、换手机、多端并行,全是同一条查询。这才是消息 ID 必须单调递增的真实用途——不是排序好看,是让「我还差哪些」变成一次范围查询。
群聊在本章走写扩散:一条群消息复制进每个成员的消息同步队列。好处是客户端只查自己那一个收件箱、同步逻辑与一对一完全一致,而群小的时候复制 100 份的成本可以忽略。书自己划了界:群成员上限 100,并提到微信把群限制在 500 人。这与 Ch11 信息流的写时扇出是同一笔账——成本正比于接收者数量,人一多就崩;只不过聊天系统能直接用产品规则把这个数摁住,信息流没这个特权,只能做混合。
在线状态由 presence 服务经 WebSocket 单独维护:登录时把状态与 last_active_at 写进键值存储,登出时改回离线。麻烦的是异常断线——网络本来就不可靠,一断就判离线,用户头像会在通勤路上一路闪烁。解法是心跳:在线客户端周期性发一个心跳事件(书里举的量级是每 5 秒一次,超过 30 秒没收到才判离线)。
状态怎么告诉别人?书用发布订阅:每一对好友维护一个频道,A 的状态一变就往 A-B、A-C、A-D 各发一条。小规模下干净利落,但代价藏在数量里——频道数正比于好友对的总数,是 N² 量级,微信把好友上限设在 5000 与此直接相关。所以书留了逃生口:群人数一多就不再广播,改成用户进入群或手动刷新时才拉一次。这是本章第二次「把推改成拉」,理由和信息流对大 V 的处理完全一样。
还得记一笔:心跳本身就是一个不小的系统。500 万连接、每 5 秒一次,就是 100 万次 / 秒 的心跳事件——比真实消息量(约 1.16 万条 / 秒)高近两个数量级。聊天系统里流量最大的东西,往往不是消息。
表 1 · 三种「让服务端能主动说话」的办法
| 方案 | 机制 | 消息延迟 | 服务端开销 | 致命缺点 | 还适合什么 |
|---|---|---|---|---|---|
| 轮询 polling | 客户端定期问 | 等于轮询间隔 | 5000 万日活 / 5 秒 ≈ 1000 万次 / 秒 | 为约 1.16 万条 / 秒 真消息付一千万次空答 | 可容忍分钟级延迟的状态查询 |
| 长轮询 long polling | 问了先挂住,有消息或超时才返回 | 接近实时 | 连接反复重建,挂起的请求也各占一个连接 | 收发可能落在不同服务器;判不出客户端已断;不聊天的人也周期性重连 | 用不了 WebSocket 的受限环境 |
| WebSocket (本章的选择) | HTTP 握手升级成双向持久通道 | 最低:不用重新拨号 | 每连接约 10 KB;500 万连接 ≈ 50 GB | 接入层变成有状态:要服务发现、要心跳、重启即重连风暴 | 所有要服务端主动推的实时场景 |
表 2 · 本章之后必须自己回答的四个岔路
| 岔路 | 本章的选择 | 代价 / 什么时候要换 |
|---|---|---|
| 消息 ID 怎么定序 | 会话内局部递增,不追求全局有序 | 省掉全局协调;代价是跨会话不可比,全局搜索或跨群时间线要另配 Snowflake |
| 群聊怎么分发 | 写扩散:复制进每个成员的收件箱,上限 100 人 | 成本正比于成员数。上万人的频道必须改读扩散(进群时现拉),代价是读侧变贵 |
| 在线状态怎么传播 | 好友对 pub/sub 推;群里改成进入时拉 | 推的频道数是 N² 量级;拉的代价是状态陈旧几十秒。微信把好友上限设在 5000 与此有关 |
| 聊天记录放哪 | 键值存储(HBase / Cassandra) | 换来水平扩展与低延迟随机读;代价是没有跨行事务,复杂查询与全文搜索都要另建系统 |
还有个高频追问:连接该怎么分配给机器?上线时由服务发现按容量与地理位置推荐,但真正的坑在下线——摘一台机器要先停止接受新连接、再让老连接慢慢迁走,并给客户端的重连加随机退避,否则你会亲手制造一次重连风暴。
面试里被问到聊天系统,把这条链说清楚基本就过了:发和收不对称 → 收要靠长连接 → 接入层因此有状态 → 于是需要服务发现、心跳、收件箱、会话内单调 ID。反过来,一开口就是「用 WebSocket」却说不出它带来的状态代价,等于没答。
((channel_id, bucket), message_id),bucket 是约 10 天的静态时间窗,所有 ID 都是 Snowflake、天然按时间可排序。这就是本章那个复合主键的现实版,只是多一层分桶防止热门频道把分区撑爆。2017 年 12 个 Cassandra 节点存十亿级消息,2022 年已是 177 个节点、万亿级。Discord Engineering《How Discord Stores Billions of Messages》, 2017 ↗ · 《How Discord Stores Trillions of Messages》, 2022 ↗1.16 万条 / 秒 听着轻松,可 500 万并发连接光内存就 50 GB,心跳还有 100 万次 / 秒。容量必须按连接与心跳估。created_at 排序,或追求全局有序:同毫秒会撞车、客户端时钟更不可信,排序只能靠单调的 message_id;而聊天只需要会话内有序,升格成全局定序是白加一个分布式难题。message_id 幂等去重。① 核心命题:聊天系统的形状由一条协议事实决定——HTTP 只能由客户端开口。发消息毫无难度,为了「收」才要重建整个后端。
② 三种绕法:轮询(延迟等于间隔、空答淹没)、长轮询(收发可能不同机、判不出断线、仍反复重连)、WebSocket(一条双向持久通道)。
③ 选了 WebSocket,架构就劈成三块:无状态服务、有状态的 chat server、第三方推送。有状态是一切后续复杂度的源头,也带来服务发现与重连风暴。
④ 容量单位是连接数不是 QPS:单连接约 10 KB,500 万并发 ≈ 50 GB;心跳约 100 万次 / 秒,比真实消息量高近两个数量级。
⑤ 通用数据进关系库,聊天记录进键值存储;群聊用复合主键 (channel_id, message_id),channel_id 作分区键。
⑥ 消息 ID 只需会话内单调递增,不需要全局有序——本章最省力的一次难题降级。created_at 不能当排序依据。
⑦ 多端同步靠每台设备自己的 cur_max_message_id 拉差量,把「送达到哪了」的记账责任搬给设备;重连、换机、新设备共用同一条查询。
⑧ 在线状态靠心跳(5 秒一次、30 秒判离线)避免误判;好友对 pub/sub 是 N² 量级,群里因此改成进入时才拉——与信息流对大 V 的处理是同一个动作。