针对 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 GB10 亿 × 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 丢弃任何重复的消息。

参考资料与延伸阅读

NextData 平台与敏感度分级