针对 Anthropic 一对一聊天题的完整解答:容量估算、WebSocket 网关、用户路由、在线状态、收件箱表、Kafka 内部机制与 Redis pub/sub 对比、分片、消息顺序以及故障处理。
作者:The Forward Deployed 编辑团队 已审阅
本文属于 Anthropic 系统设计题库。该题目对面试环节具有代表性。分析与解决方案为本站原创。
问题描述
用户打开应用,可以看到谁在线、发送消息,并在半秒内收到回复。离线的用户重新连接后,能收到错过的所有消息。群聊、媒体上传和端到端加密不在范围内,除非面试官额外提出。
澄清问题
- 多少用户? 练习场景:5000 万日活用户。
- 多少消息? 每位用户每天约发送 20 条。
- 投递目标? 双方都在线时,端到端延迟低于 500 毫秒。
- 消息顺序? 同一会话中的消息对双方都按发送顺序显示。
- 历史记录? 保留一年,按页加载。
- 每用户一台设备? 目前是。预期会有追问,要求支持第二台设备。
聊天系统的难点
一条聊天消息至少要经过两台服务器。发送方的连接落在某个网关上,接收方的连接落在另一个网关上,而且接收方可能根本没有连接。系统必须找到接收方的网关、按顺序投递、绝不丢消息,同时保持投递路径足够快。
这些目标相互冲突。快速路径——通过内存中的 pub/sub 层推送——可能丢消息。持久化路径——写入磁盘再读回——则更慢。因此核心矛盾是延迟与持久性之间的权衡。标准答案是两者并用:用持久化写入保证投递,用快速但有损的广播提供速度。
flowchart LR A([Alice]):::user <-->|WebSocket| G1[Gateway 1]:::svc B([Bob]):::user <-->|WebSocket| G2[Gateway 2]:::svc G1 --> CS[Chat service]:::svc CS --> DB[(Messages + inbox)]:::store CS --> PS[[Pub/sub]]:::svc --> G2 classDef user fill:#e6efec,stroke:#315e55,color:#171717; classDef svc fill:#f4f1e8,stroke:#315e55,color:#171717; classDef store fill:#fdf3dc,stroke:#c4492d,color:#171717;
核心思想。 用持久化写入保证投递,用有损广播实现快速送达。广播可能丢消息,因为持久化层会兜底。
关键概念
持久连接
WebSocket 在两个方向上保持一条 TCP 连接,因此服务器无需客户端请求即可推送消息。长轮询用重复请求来模拟这一点,既浪费请求又增加延迟。对于聊天系统,WebSocket 是默认选择。
投递保证
至多一次投递零次或一次;消息可能丢失。至少一次投递一次或多次;可能出现重复。恰好一次是用户想要的。在机器之间,它由至少一次投递加上接收端的去重来实现。
发布/订阅与日志
发布/订阅,如 Redis,将消息转发给当前订阅者,不保留任何内容。日志,如 Kafka,将消息按顺序存储在磁盘上;消费者按自己的节奏读取,并且可以回放。它们解决的是不同的问题,面试官会让你比较它们。
在线状态
在线状态是指显示谁在线。它需要一种方法来检测连接在未正常关闭的情况下断开,这正是心跳的作用。
核心要点。 用 WebSocket 做推送,用至少一次加去重实现恰好一次,用发布/订阅追求速度,用持久化表保证可靠性,用心跳实现在线状态。
需求
继续阅读之前。 列出需求。哪个属性绝不能被破坏,哪个约束决定了设计?
1.1 功能需求
- 向另一个用户发送文本消息。
- 在线时实时接收消息。
- 重连后接收错过的消息。
- 查看联系人是否在线,以及最后在线时间。
- 加载会话历史,按最新优先,逐页加载。
1.2 非功能需求
- 不丢消息。 服务器已确认的消息最终会被投递。
- 屏幕上不出现重复。 每条消息只显示一次。
- 按会话有序。
- 快速。 双方都在线时,端到端 p95 在 500 毫秒以内。
- 可用。 99.9% 或更高。
1.3 约束与属性
已确认消息的持久性是属性。 用户可以原谅一条慢的消息,但不能原谅一条丢失的消息。连接数是约束。 数百万个打开的套接字决定了你要运行多少个网关,以及如何找到用户所在的网关。
粗略估算
| 数量 | 数值 | 推导 |
|---|---|---|
| 日活跃用户 | 5000 万 | 给定 |
| 每日消息数 | 10 亿 | 5000 万 × 20 |
| 平均发送速率 | 每秒约 11,600 条 | 10 亿 / 86,400 |
| 峰值发送速率 | 每秒约 35,000 条 | 平均值的 3 倍 |
| 消息大小 | 300 字节 | 文本加元数据 |
| 每日存储量 | 300 GB | 10 亿 × 300 字节 |
| 每年存储量 | 约 110 TB | 复制之前 |
| 峰值并发连接数 | 500 万 | 日活跃用户的 10% |
| 网关数 | 至少 10 个 | 每台主机约 50 万个空闲套接字 |
两个数字决定了设计。500 万个套接字意味着需要很多网关,因此你需要一种方法来找到哪个网关持有某个用户。而每秒 35,000 次写入完全在分片数据库的能力范围内,因此在发送路径上进行持久化写入是可承受的。
核心要点。 连接数决定了网关集群的规模;写入速率足够低,因此每次发送都可以承担一次持久化写入。
API 设计
阅读前先思考。 客户端发送一条消息,网络在确认到达之前断开了。客户端重试。服务器如何避免存储两次?
客户端在发送前为每条消息生成一个 ID。服务器存储该消息,并对发送者和该 ID 设置唯一约束。携带相同 ID 的重试会找到已存在的消息,并返回相同的确认。
3.1 WebSocket 帧
client -> server
{type: "send", client_msg_id, to_user_id, body}
{type: "ack", message_id} // delivery ack for received messages
{type: "ping"}
server -> client
{type: "sent", client_msg_id, message_id, conversation_id, server_ts}
{type: "message", message_id, conversation_id, from_user_id, body, server_ts}
{type: "presence", user_id, online, last_seen}
{type: "pong"}3.2 HTTP 端点
GET /v1/conversations?cursor= -> 最近会话及最后一条消息 GET /v1/conversations/:id/messages?before=:message_id&limit=50 GET /v1/inbox -> 未送达的消息(重连时使用)
数据模型
conversations id(两个用户 ID 排序后的哈希值)、user_a、user_b、last_message_id、updated_at messages conversation_id、message_id、sender_id、client_msg_id、body、server_ts 主键 (conversation_id, message_id) 唯一 (sender_id, client_msg_id) inbox user_id、message_id、conversation_id、created_at、expires_at 主键 (user_id, message_id) users id、last_seen_at
message_id
在会话内递增。带序列号的时间有序 ID,或每个会话一个计数器,都可以。按它排序即为发送顺序。
会话 ID 由两个用户 ID 派生而来,因此两个用户无需查询即可计算出相同的 ID,同一对用户之间也不可能再出现第二个会话。
高层设计
5.1 一台服务器持有所有连接
flowchart LR A([Alice]):::user <--> S[Chat server<br/>in-memory user map]:::svc B([Bob]):::user <--> S S --> DB[(Messages)]:::store classDef user fill:#e6efec,stroke:#315e55,color:#171717; classDef svc fill:#f4f1e8,stroke:#315e55,color:#171717; classDef store fill:#fdf3dc,stroke:#c4492d,color:#171717;
只有一台服务器时,Alice 的消息可以在本地映射表中找到 Bob 的连接。但它无法承载 500 万个套接字,而且是单点故障。
5.2 修复 1:多个网关,以及一种找到用户的方法
将连接分散到四层负载均衡器后面的多个网关上。现在 Alice 和 Bob 位于不同的网关上,Alice 的网关不知道 Bob 在哪里。
最简单的修复方法:每个网关为每个已连接用户订阅一个发布/订阅频道,以用户 ID 命名。要发送给 Bob,就发布到 Bob 的频道。持有 Bob 连接的那个网关会收到它。
flowchart LR A([Alice]):::user <--> G1[Gateway 1]:::svc B([Bob]):::user <--> G2[Gateway 2]:::svc G1 -->|publish user:bob| PS[[Redis pub/sub]]:::new PS -->|subscribed to user:bob| G2 classDef user fill:#e6efec,stroke:#315e55,color:#171717; classDef svc fill:#f4f1e8,stroke:#315e55,color:#171717; classDef new fill:#ffffff,stroke:#c4492d,stroke-width:2px,stroke-dasharray:5 3,color:#171717;
如果没有按用户划分的频道,发送方的网关就必须向每个网关广播,随着集群规模的增长,这种方式的扩展性很差。
5.3 修复 2:持久化收件箱
当 Bob 离线或他的网关在错误时刻重启时,发布/订阅会丢失消息。在发布之前增加持久化写入:存储消息,并在 Bob 的收件箱中插入一行。Bob 的客户端对每条已送达的消息进行确认,服务器随后删除该收件箱行。重新连接后,Bob 读取他的收件箱,即可获取所有错过的消息。
flowchart LR G1[Gateway 1]:::svc --> CS[Chat service]:::svc CS -->|1. write message + inbox row| DB[(Messages, inbox)]:::new CS -->|2. ack to sender| G1 CS -->|3. publish| PS[[Pub/sub]]:::svc --> G2[Gateway 2]:::svc G2 -->|4. deliver| B([Bob]):::user B -->|5. ack| G2 -->|6. delete inbox row| DB classDef user fill:#e6efec,stroke:#315e55,color:#171717; classDef svc fill:#f4f1e8,stroke:#315e55,color:#171717; classDef new fill:#ffffff,stroke:#c4492d,stroke-width:2px,stroke-dasharray:5 3,color:#171717;
5.4 修复方案 3:在线状态
网关为每个已连接用户刷新一个带存活时间的在线状态键。当心跳停止时,该键过期,用户显示为离线。在线状态的变更会发布给该用户在线的联系人。
5.5 组合后的设计
sequenceDiagram
autonumber
actor A as Alice
participant G1 as Gateway 1
participant C as Chat service
participant D as Database
participant P as Pub/sub
participant G2 as Gateway 2
actor B as Bob
A->>G1: send {client_msg_id, to: Bob, body}
G1->>C: send
C->>D: insert message (dedup on client_msg_id)
C->>D: insert inbox row for Bob
C-->>G1: sent {message_id}
G1-->>A: sent
C->>P: publish user:bob
P->>G2: message
G2->>B: message
B->>G2: ack message_id
G2->>D: delete inbox row核心思想。 多个网关、按用户划分的频道来找到接收者、在发布前先写入持久化收件箱,以及通过客户端确认来清除收件箱。
深入探讨
6.1 消息总线用 Kafka 还是 Redis
继续阅读之前。 面试官问为什么你没有使用 Kafka。你的答案是什么?你对 Kafka 的工作原理是否足够了解,能够为你的选择辩护?
这是候选人反映最困难的地方。首先要了解 Kafka 的内部机制。
Kafka 的主题(topic)划分为分区(partition)。每个分区是一个有序的、只追加的日志,存储在磁盘上。每个分区在多个 broker 上有副本(replica)。其中一个副本是领导者(leader),负责处理所有读写。与它保持同步的副本组成同步副本集(in-sync replica set)。在
acks=all
配置下,只有当每个同步副本都收到写入后,写入才会被确认。如果领导者宕机,控制器会从同步副本集中选出新的领导者,因此已确认的消息不会丢失。
消费者组成消费者组(consumer group)。组内每个分区恰好由一个消费者读取,因此组会分摊工作。当消费者加入或离开时,组会再均衡(rebalance),分区在消费者之间转移,这会短暂暂停消费。每个消费者跟踪自己在每个分区中的偏移量(offset),并可以回退重放。日志压缩(log compaction)只保留每个键的最新记录,适合存储诸如每个用户最新在线状态之类的状态数据。
现在来看针对本系统的对比。
| 问题 | Redis 发布/订阅 | Kafka |
|---|---|---|
| 延迟 | 约一毫秒 | 使用 acks=all 时为几十毫秒 |
| 存储消息 | 否 | 是,可重放 |
| 每用户一个频道 | 便宜:频道只是一个订阅者列表 | 作为主题不可行;每主题的元数据无法扩展到数百万级别 |
| 投递 | 至多一次 | 至少一次 |
| 路由到一个网关 | 内置,按通道 | 需要你自己维护分区到网关的映射 |
对于一对一聊天,Redis pub/sub 更胜一筹。它直接路由到接收方的网关,几乎不增加延迟。它会丢消息,但收件箱已经保证了投递。Kafka 的持久化只会重复收件箱已经提供的功能,而通过固定的一组分区将用户路由到网关,意味着要构建一套映射并忍受再平衡带来的麻烦。
当其他系统也需要这条消息流时,Kafka 才有它的用武之地:搜索索引、滥用检测、数据分析。此时聊天服务同时也写入 Kafka,这些消费者按自己的节奏读取。网关仍然通过 Redis 投递。
区分答案高下的关键:消息总线
差只提到 Kafka 就停下了
因为规模选了 Kafka,却解释不了分区、副本或消费者组。
良好两者都解释,并做出选择
了解 Kafka 的日志、Leader 和同步副本(in-sync replicas)以及消费者组,并因为收件箱已覆盖持久化而选择 Redis 来投递。
优秀让每个组件发挥所长
通过 Redis 投递,通过 Kafka 供给其他消费者,并能解释按用户划分的 topic 为何行不通,以及一次再平衡的代价是什么。
6.2 在线状态:别把数据库压垮
继续阅读之前先思考。 每个客户端每 20 秒发送一次心跳。你把“最后在线时间”存在哪里?多久写一次?
500 万条连接每 20 秒一次心跳,就是每秒 25 万次。如果每次都
last_seen_at
写入数据库,那就是每秒 25 万次低价值数据的写入。
更好的做法是:由网关刷新一个 Redis 键,
presence:{user_id}其生存时间约为心跳间隔的两倍,即 40 秒。键存在,用户就在线。心跳停止后,键会自动过期。只在连接关闭时
last_seen_at
向数据库写一次,并且让网关使用条件更新来写入,这样过时的网关就无法在重连后覆盖更新的值。
心跳在应用层实现,pong 超时设为几秒。TCP keepalive 要到几分钟后才能检测到死连接,对一个在线状态小圆点来说太慢了。
要通知联系人,可以把在线状态变更发布到每个联系人的频道,但只发给在线的联系人,并对联系人很多的用户批量发布变更。
6.3 消息顺序
继续阅读之前先思考。 Alice 快速连发两条消息。Bob 有可能看到乱序吗?
有可能,如果两条消息走了不同的路径。只需保证每个会话内的顺序,也只按会话保证。聊天服务在会话内分配递增的消息 ID,客户端按 ID 而不是按到达时间插入消息。如果出现空洞,比如消息 7 先于消息 6 到达,客户端会稍等片刻,然后从服务器拉取缺失的范围。
跨所有会话的全局顺序代价高昂,而且没人能看到它。要明确说出这一点,并拒绝构建它。
6.4 分片
分片
messages
按会话 ID 分片,这样一个会话的历史记录只存在于一个分片上,一次范围查询即可加载。按
inbox
用户 ID 分片,这样重连的用户只需读取一个分片。不同的访问模式使用不同的键是正常的;要说明每个键的选择理由。
一个负载很重的会话,比如两个机器人不停聊天,会留在同一个分片上。在一对一的规模下这很少成为问题。如果真成了问题,就对发送方做速率限制。
6.5 故障处理
- 网关崩溃。 其客户端通过负载均衡器重连到另一个网关,订阅自己的频道并读取收件箱。不会丢失任何消息。
- 发布/订阅节点故障。 该节点频道上的用户会暂停实时消息投递。收件箱仍保存着每条消息,客户端会在重连时或下次轮询时补齐。
- 数据库分片故障转移。 发往该分片的消息会失败几秒钟。客户端使用相同的客户端消息 ID 重试,因此不会产生重复。
- 聊天服务在写入和发布之间崩溃。 消息已存储并进入收件箱。Bob 在下次读取收件箱时会收到它。客户端在重连时以及连接期间每隔几分钟会读取收件箱,这限制了延迟的上限。
区分答案优劣的关键:可靠性
弱:仅以发布/订阅作为唯一通道
只通过发布/订阅投递,因此离线用户或重启的节点会丢失消息。
良好:收件箱加确认机制
在发布前先写入收件箱记录,并在客户端确认后删除。
强:逐一分析每种故障
解释了用于幂等重试的客户端消息 ID、带缺口修复的按会话排序,以及每个崩溃点会发生什么。
6.6 在大规模下找到接收方的网关
继续阅读之前。 你的 Redis 发布/订阅集群必须能路由到 500 万在线用户。这个集群是如何布局的?当用户重连到另一个网关时会发生什么?
每个在线用户都有一个名为
user:{id}的频道,由持有该用户套接字的网关订阅。Redis 集群按哈希对频道分片,因此发布到
user:bob
的消息会到达一个节点,该节点再转发给 Bob 的网关。有 500 万个频道时,每个频道只是一条订阅记录,内存占用很小。
需要关注的是网关这一侧。每个网关订阅数十万个频道,每个本地用户一个。当网关重启时,它的所有用户会立即重连到别处并重新订阅:形成一波订阅洪峰。通过客户端的随机退避来分散重连,让重启变成一个缓坡而不是尖峰。
当 Bob 重连到另一个网关时,新网关会订阅
user:bob
,而旧网关(如果还在运行)会在旧套接字关闭时取消订阅。可能有一小段时间两者都处于订阅状态;Bob 可能会收到两次同一条消息,客户端会按消息 ID 忽略重复。
另一种方案可以避免每用户频道:在 Redis 中用一张路由表将每个用户映射到网关 ID,聊天服务按网关向对应频道发布消息。这种方案使用的频道更少,但每次连接和断开都必须更新路由表,而且过期条目会把消息路由到错误的网关。每用户频道更简单;请提及这种替代方案以及你没有选择它的原因。
flowchart LR CS[Chat service]:::svc -->|publish user:bob| RC[(Redis cluster: shard by channel hash)]:::store RC -->|subscriber: gateway 7| G7[Gateway 7]:::svc --> B([Bob]):::user B -. reconnects .-> G3[Gateway 3]:::svc G3 -. subscribe user:bob .-> RC classDef user fill:#e6efec,stroke:#315e55,color:#171717; classDef svc fill:#f4f1e8,stroke:#315e55,color:#171717; classDef store fill:#fdf3dc,stroke:#c4492d,color:#171717;
6.7 历史记录、分页与保留策略
历史记录按最新优先加载,每次 50 条消息。查询是对
(conversation_id, message_id)
的降序范围扫描,从已加载的最旧消息 ID 之前开始。基于游标的分页在新消息到达时不会跳过或重复消息,而基于偏移量的分页则会。
按每年 110 TB 的数据量,可将最近几个月的数据保留在快速存储上,并将较旧的分区迁移到更便宜的存储。大多数对话只会读取最后几页,因此一个小的热层就能满足几乎所有的读取需求。保留规则(例如一年后删除)通过删除旧的时间分区来执行,这比逐行删除便宜得多。
对话列表(“最近聊天”)需要自己的索引:按用户组织,对话按最后一条消息的时间排序。每次发送消息时都要为双方参与者更新它。这是一张小而写入频繁的表;应将其放在能很好地处理频繁更新的存储中。
区分答案优劣的关键:路由与存储
弱向所有网关广播
无法找到接收者的网关,并使用偏移量加载历史记录。
良好每用户频道与游标分页
通过每用户频道路由,并按消息 ID 对历史记录分页。
强为用户流失和增长做好规划
使用带抖动的退避策略处理重连风暴,解释重连期间的重复订阅问题,按数据新旧分层存储,并维护每用户的最近对话索引。
变体
7.1 第二台设备
收件箱行按用户和设备作为键。每台设备独立确认。限制每用户的设备数量(例如五台)以约束扇出规模。已读状态按用户维护,并通过一个小的“已读至消息 X”事件在设备间同步。
7.2 群聊
小型群组可以复用该设计:向每个成员的频道发布消息。大型群组需要不同的扇出方式:每个群组只发布一次,由各网关投递给其本地成员。这就是 Slack 的设计。
7.3 端到端加密
服务器只存储密文,无法读取消息内容。投递、收件箱和排序机制均保持不变。搜索转移到客户端,第二台设备则需要设备之间的密钥分发。
7.4 用户规模达到十倍时
当日活用户达到 5 亿时,峰值发送量约为每秒 35 万条,连接数约为 5000 万。网关扩展到约 100 台主机;Redis pub/sub 扩展为按频道分片的更大集群。数据库按会话 ID 扩展为多个分片,收件箱表保持很小,因为行在确认后即被删除。新的压力来自地域:用户遍布全球,因此需要按区域部署完整的技术栈,将每个用户的收件箱保存在其所属区域,并在各区域的聊天服务之间转发跨区域消息。
可迁移的模式
聊天是先持久化写入,再快速广播,并以每个接收者的队列作为安全网。同样的结构出现在通知、实时评论和订单状态更新中:用存储来保证,用可能丢消息的快速路径来投递,并让接收方进行确认和去重。
回顾:30 秒答案
- WebSocket 网关,每用户 pub/sub 频道。 发送方的网关通过一次发布即可触达接收方的网关。
- 发布前先写入持久化收件箱。 已确认的消息能在任何崩溃后幸存。
- 客户端消息 ID。 重试不会产生重复。
- 基于 Redis 并带存活时间的在线状态。 最后在线时间只在断开连接时写入一次。
- 仅按会话排序。 客户端使用递增 ID 和缺口修复。
测验
+为什么 pub/sub 层可以允许丢消息?
因为每条消息在发布之前都已写入数据库和接收者的收件箱。如果发布丢失,接收者仍会在重连或下次读取收件箱时收到该消息。
+从用户的角度看,系统如何做到恰好一次投递?
投递是至少一次的,同时来自 pub/sub 和收件箱。客户端会忽略已有的消息 ID,服务器会忽略重发的客户端消息 ID。两者结合,在屏幕上呈现的就是恰好一次。
+为什么不在每次心跳时都写入 last_seen?
在 500 万连接、每 20 秒一次心跳的情况下,这意味着每秒 25 万次数据库写入,而这些数据根本不需要那么新鲜。一个会过期的 Redis 键即可显示在线状态,断开连接时的一次写入即可记录最后在线时间。
+当 Kafka 的分区领导者宕机时会发生什么?
控制器会从同步副本中选举新的领导者。在使用 acks=all 的情况下,每条已确认的消息都存在于所有同步副本上,因此不会丢失任何消息。
+为什么为每个用户建一个 Kafka 主题是个坏主意?
每个主题和分区都带有集群必须跟踪的元数据。数百万个主题超出了集群的管理能力。将用户分组到固定分区是可行的,但那样你就必须把分区映射到网关,并忍受再平衡带来的影响。
+为什么历史记录使用基于游标的分页?
在两次页面加载之间新消息的到来会使偏移量发生偏移,因此基于偏移量的分页会跳过或重复消息。而游标——即已加载的最旧消息 ID——总是从同一位置继续。
+当网关在拥有数十万用户的情况下重启会发生什么?
所有用户会重新连接到其他网关并重新订阅。客户端随机的退避机制将重连分散成一个渐进的过程,在重叠期间客户端会根据 ID 丢弃任何重复的消息。
参考资料与延伸阅读
- Apache Kafka 文档:设计 涵盖了日志、副本机制、同步副本以及日志压缩。
- Redis pub/sub 记录了“发后即忘”的投递语义。
- RFC 6455:WebSocket 协议 定义了连接、帧格式以及 ping 和 pong 帧。
- 群聊版本在 设计 Slack 中已有解答。