给一个 10 亿用户、5000 万并发在线的即时通讯系统(WhatsApp、Discord、微信)设计后端。Feed(Day 14)是读放大问题——一条内容被几百万人刷;聊天反过来是连接放大问题——几千万条永不关闭的长连接要随时双向推送。难点不在「存消息」,而在如何维持海量空闲长连接、并保证每条消息恰好递交一次、有序、不丢。
核心是把「连接」和「逻辑」解耦:接入层(Gateway)只管维持长连接和收发字节;业务层无状态,可水平扩展。关键组件是 Session Registry——记录「用户 → 当前连在哪个 Gateway 节点」的路由表,是消息找到收件人的寻址核心。发送方的消息先落 持久化存储(保证不丢),再查 Registry 把消息路由到收件人所在的 Gateway 推下去;收件人离线则留在存储里等上线拉取。
一句话 trade-off:用「服务端维持千万级有状态连接的复杂度」换「真双向、低延迟、低空载开销的实时推送」。
原理:聊天要服务端主动推。三条路线:短轮询(定时 HTTP 拉)延迟高、空载请求海量浪费;长轮询(hold 住请求直到有数据)省一些但每条消息仍要一次完整 HTTP 往返、连接频繁重建;WebSocket 一次握手升级成全双工长连接,之后双向推送几乎零额外开销,是现代聊天的默认。代价是 Gateway 变成有状态:每个连接占内存+fd,节点重启/扩缩容会断开海量连接。所以接入层要选高并发轻量连接的运行时——WhatsApp 用 Erlang/BEAM,Discord 用 Elixir,二者都基于 actor 模型:每条连接是一个独立的轻量进程(不是 OS 线程),调度由 VM 管,单机扛百万连接。
| 方案 | 实时性 | 空载开销 | 代价 |
|---|---|---|---|
| 短轮询 | 差(取决间隔) | 极高(空请求洪水) | 简单但不可扩展 |
| 长轮询 | 中 | 中(连接频繁重建) | HTTP 兼容好,过渡方案 |
| WebSocket | 优(<200ms) | 低(连接复用) | Gateway 有状态、扩缩容难 |
另一关键是 presence(在线状态)与心跳:服务端靠周期心跳判活,连接断了要及时从 Registry 摘除——否则消息会被路由到死连接。但 presence 广播是放大炸弹:一个用户上下线要通知所有好友/同群成员,必须聚合、限频。
一句话 trade-off:用「按会话+时间分桶的分区键」换「避免大群单分区被打爆的热点」。
原理:消息要先持久化再投递(不能只靠内存,否则节点挂了就丢)。读模式是「拉某会话最近 N 条 / 某时间点之后的消息」,天然适合 宽列存储(Cassandra/ScyllaDB):分区键 channel_id,聚簇键用时间有序的消息 ID(Snowflake,呼应 Day 11),同会话消息物理相邻、按时间排好。陷阱是热点分区:超大群(百万人同一频道)所有消息挤进单一 channel_id 分区,读写全压一个副本组,p99 飙升。解法是分桶——把分区键改成 (channel_id, time_bucket),按时间窗口(如每 N 天)切分桶,把大分区横向打散。1:1 与群聊用同一套:1:1 也建模成一个只有两人的 channel。
# 分桶后的消息表 (CQL 风格 pseudo-code)
CREATE TABLE messages (
channel_id bigint,
bucket int, # 时间分桶,避免热点大分区
message_id bigint, # Snowflake:时间有序,兼当排序键
author_id bigint,
payload blob, # E2E 下这里是密文
PRIMARY KEY ((channel_id, bucket), message_id)
) WITH CLUSTERING ORDER BY (message_id DESC);
# 读最近消息:定位当前 bucket,按 message_id 倒序取 N 条
(channel_id, bucket) 复合分区键分桶,正是为了防止活跃大频道形成热点分区;后来从 Cassandra 迁到 ScyllaDB(C++ 写、无 GC),177 节点缩到 72 节点,历史消息读 p99 从 40–125ms 降到稳定 ~15ms(Discord 工程博客《How Discord Stores Trillions of Messages》)。一句话 trade-off:用「at-least-once + 客户端幂等去重」换「比 exactly-once 简单得多、又不丢不重的工程实现」。
原理:网络会丢包、客户端会重连重发,真正的 exactly-once 在分布式下代价极高(呼应 Day 8)。聊天系统的通行解是 at-least-once 投递 + 端侧幂等:发送端给每条消息带一个客户端生成的幂等 ID(client message id),服务端按它去重——重试不会产生两条。投递则靠 ACK 链:消息每经一跳(服务端收到、对端收到、对端已读)回一个 ACK,没收到 ACK 就重发。有序性靠每会话单调递增的 seq:客户端按 seq 排序、检测空洞(收到 5 但没收到 4 → 主动拉 4),把「服务端保证全序」的重担卸到客户端的会话内排序,避免昂贵的全局有序。
# 发送 + 幂等去重 + ACK 重试 (pseudo-code)
def send(msg):
msg.client_id = uuid() # 客户端幂等键
while not acked(msg.client_id):
conn.push(msg)
if wait_ack(timeout=5s): break # 收到服务端 ACK 才算落库
# 重连后用 client_id 去重,服务端: INSERT IF NOT EXISTS
def on_receive(msg): # 接收端
if msg.seq <= last_seen: return # 重复,丢弃
if msg.seq > last_seen + 1:
fetch_gap(last_seen+1, msg.seq) # 检测空洞,回补
deliver(msg); send_ack(msg.seq); last_seen = msg.seq
一句话 trade-off:用「服务端彻底读不到明文、且密钥管理复杂」换「前向保密 + 后向恢复的强隐私」。
原理:E2E 下服务端只是密文的搬运工。业界标准是 Signal Protocol:用 X3DH 在双方(可能一方离线)间协商初始共享密钥,再用 Double Ratchet 持续演进——每条消息用一把一次性消息密钥,发完即弃。两个棘轮:DH 棘轮(每轮交换新 DH 公钥)+ KDF 棘轮(链式派生)。由此得到两个核心安全性质:前向保密(forward secrecy,今天密钥泄露解不开昨天的消息)+ 后向恢复(post-compromise security,泄露后续上新 DH 又恢复安全)。群聊不能两两 N² 配对,用 Sender Keys:每人有一把发送链密钥分发给群成员,自己加密一次、群内复用,把扇出从 N² 降到 N。
channel_id 单独做分区键,活跃大频道会打爆单分区。要 (channel_id, bucket) 分桶。高频追问:① 5000 万连接怎么在内存里维持,瓶颈是什么?② A 发给离线的 B,消息生命周期怎么走?③ 多设备登录,消息怎么在设备间同步且不重不漏?④ 群成员 100 万,一条消息怎么扇出不打爆系统?⑤ E2E 开了之后,服务端搜索和内容审核还怎么做?
瓶颈是内存与文件描述符,不是 CPU。空闲连接不耗算力,但每条都占:内核 socket 缓冲(收发各几 KB~几十 KB)+ 应用层会话状态 + 一个 fd。200 万连接光内核缓冲就可能几十 GB。估算:若每连接综合占 ~10KB,200 万 × 10KB ≈ 20GB——还没算业务态。所以要 ① 调内核(fd 上限、socket 缓冲、ephemeral port);② 选轻量连接运行时(BEAM 进程而非 OS 线程);③ 压缩每连接的应用态。这正是 WhatsApp 调优 FreeBSD、用 Erlang 的原因——把「每连接固定成本」压到极低,单机连接数才能上百万。
流程:① A 的消息到 Chat Service,先持久化(带 client_id 幂等键)→ 回 A 一个 ACK(A 显示「已送达服务器」✓);② 查 Registry 发现 B 无活跃连接 → 消息留在存储/B 的待投递队列;③ B 上线,建立 WebSocket,Registry 登记 B→GatewayX;④ B 拉取「上次 seq 之后」的离线消息 → 投递 → B 回 ACK → 服务端标记已送达。易错点:若第①步「先回 ACK 再落库」,宕机就丢且 A 以为成功;若 B 拉取后没基于 seq 去重,重连重拉会重复展示;若 Registry 没及时登记 B 上线,新消息可能短暂投不到。每步都要可重试 + 幂等兜底。
本质同构于 Feed 的 push/pull 抉择。给 100 万人各写一份收件箱 = fanout-on-write 写爆炸,不可行。大群用 共享会话日志(消息存一份)+ 读时拉:成员各自按「上次读到的 seq」拉增量;在线成员靠 Registry 找到其 Gateway 批量推通知(而非整条复制)。差别在:Feed 多为异步可容忍秒级,聊天要实时推送,所以「在线成员实时 notify + 各自拉」是折中。进一步优化:把同群在线成员按 Gateway 聚合,一个 Gateway 收一次再本地扇给该节点上的成员,减少跨节点流量——Discord 的 guild 进程就是这种聚合点。
搜索:服务端无法建倒排索引(密文无意义)→ 只能客户端本地解密后建索引、本地搜,换设备就搜不到旧历史。多设备同步:要么每设备独立参与密钥协商(各自一套会话),要么设备间安全传输会话密钥/历史——都比明文同步复杂得多。Discord 的取舍:它的核心价值是服务端能力——全文搜索、内容审核、垃圾/未成年人保护、机器人生态、AI 功能,这些都要服务端读明文。E2E 与这些根本冲突。所以 E2E 与否是产品定位决策:隐私优先(Signal/WhatsApp)vs 服务端功能优先(Discord/Slack)。没有纯技术意义上的「更对」。
风险是惊群重连(thundering herd):几百万客户端同时断开 → 同时重连 → 握手、鉴权、Registry 写、离线消息拉取全在一瞬间挤进来,可能把刚恢复的 Gateway 和后端再次打垮,陷入「连上又被压垮」的振荡。缓解:① 客户端带抖动的指数退避重连(呼应 Day 23 的 retry+jitter),把重连摊到时间轴上;② Gateway 滚动重启而非全量(Day 22),每次只断一小批;③ 重连鉴权和离线拉取走限流/排队,保护后端;④ 用连接迁移/优雅停机,提前通知客户端去连别的节点而非硬断。核心思想:永远不要让海量客户端的行为同步对齐——抖动是打散同步性的关键工具。