聊天应用坐落在系统设计最苛刻的交叉点之一:它必须处理数十亿用户、每天数千亿条消息、亚秒投递延迟、可靠的离线排队和复杂的群组语义——同时维护端到端加密和准确的在线状态信息。核心挑战是每条消息必须被投递到一个特定设备,而不只是一条数据库记录——这意味系统必须跨数千台服务器跟踪实时连接状态,并实时把消息路由到恰好正确的服务器。

⚡ 速览要点
  • WebSocket——持久全双工连接消除轮询延迟;每客户端单连接支持真正的服务端推送。
  • WebSocket Manager(Redis KV)——映射 user_id → websocket_server:port;让任何服务能跨 65K+ 端口把消息路由到正确服务器。
  • Snowflake ID——64 位:41 位 ms 时间戳 + 10 位机器 ID + 12 位序列;分布式唯一 ID 带内建时间排序用于消息显示。
  • 在线 vs 离线投递——在线:直接 WebSocket 投递;离线:Pending 状态 + 推送通知(APNs/FCM)直到重连。
  • 写扇出 vs 读扇出——写扇出在发送时把消息复制到每个接收者的信箱;读扇出在读时查询共享消息存储。小群写扇出胜,大群读扇出胜。
  • 群大小上限——有界扇出成本(微信 500 人上限);每条群消息需每成员一次投递。
  • last seen 采样——~1 分钟间隔轮询在线状态;接受轻微精度损失以避免 4B 用户下的写风暴。
tldr

用 WebSocket 做全双工、持久的客户端-服务器连接。通过一个把用户 ID 映射到服务器的 WebSocket Manager(键值存储)路由消息。用 Snowflake 生成消息 ID(时间有序、分布式、64 位)。对离线用户把消息排队为"pending"并经推送通知投递。1:1 和小群用写扇出;大群切到读扇出(共享消息日志)。给群大小设上限以约束最坏扇出成本。

聊天应用高层架构
聊天应用高层架构

第 1 步 — 澄清需求

聊天应用从简单的 1:1 短信式消息到实时协作工作区不等。画任何图前界定问题。

功能

非功能

第 2 步 — 容量估算

WhatsApp / 微信规模:40 亿用户,大约 10 亿日活(DAU)。每 DAU 发 ~100 条消息/天。

消息量

WebSocket 连接

存储

第 3 步 — API 设计

HTTP + WS
# Auth
POST /api/auth/login
     { phone_number, otp }
     → { access_token, refresh_token }

# WebSocket 连接(升级)
GET  /ws/connect
     Authorization: Bearer {access_token}
     → 101 Switching Protocols
        注册: user_id → ws_server:port 进 WebSocket Manager

# 发消息(经 WS 帧,非 REST)
-- 客户端发 WS 帧:
{ type: "message", idempotency_key: "uuid",
  receiver_id: 456, content: "hello", media_url: null }
-- 服务器回复:
{ type: "ack", message_id: 7234567890123, status: "sent" }

# REST: 消息历史(按 cursor 分页)
GET  /api/conversations/{conv_id}/messages?before_id=7234567890123&limit=50

# REST: 创建群
POST /api/groups
     { name, member_ids: [1, 2, 3 ...] }

# REST: 上传媒体(返回 CDN URL)
POST /api/media/upload
     Content-Type: multipart/form-data
     → { media_url, media_id }

发消息经 WebSocket 连接发生,不经 REST,因为连接已开着、加 REST 往返会浪费持久连接。idempotency_key 由客户端生成(UUID)并在重试时防止重复消息创建——若客户端发同键两次(如因为 ACK 丢失),服务器返回已创建的消息 ID 而不创建第二条消息。

第 4 步 — 协议选择:为什么 WebSocket

聊天的核心挑战是服务器到客户端的消息推送。存在三种方法:

协议问题
短轮询延迟由轮询间隔决定;频繁轮询浪费资源,不频繁轮询感觉卡
长轮询反复连接建立/拆除开销;多个同时请求能造成消息有序问题
WebSocket持久全双工连接;消除轮询开销;支持真正的实时双向消息
Server-Sent Events (SSE)仅服务端推送;客户端无法经同一连接发送;发送仍需单独 HTTP 通道

WebSocket 胜出,因为它们每客户端维护一条持久连接,使服务器能在消息到达瞬间推送、无轮询延迟、无连接重建开销。连接在用户会话生命周期内保持开着;每 30 秒一次心跳 ping/pong 检测死连接而无需客户端轮询。

第 5 步 — 核心服务

WebSocket 连接管理与消息路由
WebSocket 连接管理与消息路由

第 6 步 — 数据模型

CQL / schema
-- 消息表:按会话分区以高效读历史
CREATE TABLE messages (
  conversation_id BIGINT,
  message_id      BIGINT,    -- Snowflake ID(时间有序)
  sender_id       BIGINT,
  content         TEXT,     -- 静态加密
  media_url       TEXT,     -- 纯文本则 null
  status          ENUM,     -- Sent | Delivered | Read
  idempotency_key UUID,    -- 重试去重
  created_at      TIMESTAMP,
  PRIMARY KEY     ((conversation_id), message_id)
) WITH CLUSTERING ORDER BY (message_id DESC);

-- 用户收件箱:每个用户的会话列表带未读计数
CREATE TABLE user_inbox (
  user_id         BIGINT,
  conversation_id BIGINT,
  last_message_id BIGINT,
  unread_count    INT,
  updated_at      TIMESTAMP,
  PRIMARY KEY     (user_id, updated_at)
) WITH CLUSTERING ORDER BY (updated_at DESC);

-- 群成员
CREATE TABLE group_members (
  group_id        BIGINT,
  user_id         BIGINT,
  role            TEXT,     -- member | admin
  joined_at       TIMESTAMP,
  PRIMARY KEY     (group_id, user_id)
);

-- 投递回执:每消息每接收者状态
CREATE TABLE receipts (
  message_id      BIGINT,
  recipient_id    BIGINT,
  status          TEXT,     -- Delivered | Read
  updated_at      TIMESTAMP,
  PRIMARY KEY     (message_id, recipient_id)
);

消息表按 conversation_id 分区,使最常见查询——"加载这个会话的最后 50 条消息"——是单分区扫描。聚簇键 message_id(一个 Snowflake ID)在分区内按时间保持消息有序。Cassandra 是这里正确的存储,因为写模式(高吞吐、仅追加)和读模式(分区内按时间范围扫描)完美映射到 Cassandra 的强项。

消息对象 schema
消息对象 schema

第 7 步 — 消息 ID 生成:Snowflake

消息 ID 必须全局唯一、时间有序(使消息在 UI 里正确排序而无需单独时间戳排序),且无需协调生成(使没有单点故障 ID 生成器)。Snowflake ID 满足全部三个需求:

bit layout
Snowflake 64 位 ID 布局:
┌─────────────────────────────┬──────────────┬──────────────────┐
│   41 位: ms 时间戳          │ 10 位 nodeID │  12 位序列       │
│   (epoch 偏移, ~69 年)      │  (1024 节点) │  (4096/ms/节点)  │
└─────────────────────────────┴──────────────┴──────────────────┘

最大吞吐: 1024 节点 × 4096 ID/ms = 4.19 百万 ID/ms
时间排序: 较晚生成的 ID 数值上总更大
无协调: 每个节点独立生成

41 位时间戳字段(自定义 epoch 以来的毫秒)意味 ID 无需单独 created_at 字段就可排序——直接比较两个 Snowflake ID 就告诉你哪条消息先来。10 位节点 ID(每个消息服务实例启动时配置)保证跨机群唯一而无任何全局锁或集中 ID 服务。

第 8 步 — 消息投递:在线 vs 离线

一条消息被发送时,消息服务通过查询 WebSocket Manager 检查接收者是否有活跃 WebSocket 连接。两条路径分叉:

flow
发送者经 WebSocket 发消息
  → WebSocket 网关接收 WS 帧
  → 消息服务:
      1. 去重检查: idempotency_key 在 Redis(TTL 24h)
      2. 生成 Snowflake message_id
      3. 持久到 Cassandra(status: Sent)
      4. 给发送者发 ACK: { message_id, status: "sent" }
      5. 在 WebSocket Manager 查接收者

  [接收者在线]
    → 转发到接收者的 WebSocket 服务器(经内部 RPC)
    → WebSocket 服务器推到客户端 WS 连接
    → 接收者客户端 ACK 回执
    → 消息服务更新 status: Delivered
    → 经 WS 把 Delivered 回执推给发送者

  [接收者离线]
    → 消息留在 Cassandra(status: Pending)
    → 通知服务发推送通知(APNs/FCM)
    → 接收者重连时:
        客户端发 { last_seen_message_id: 7234567890000 }
        服务器返回所有 id > last_seen 的消息
        服务器更新 status: Delivered
        发送者收到 Delivered 回执

At-Least-Once 投递与去重

系统在网络级别保证至少一次投递——一条消息在重试时可能被尝试多次。应用层必须去重以达到用户可见的 exactly-once 保证。去重键是客户端提供的 idempotency_key(UUID),存在 Redis 里带 24 小时 TTL。重试时,消息服务在写 Cassandra 前检查 Redis;若键存在,它返回已存在的消息 ID 而不创建重复。

第 9 步 — 扇出:写扇出 vs 读扇出

扇出是决定消息如何从发送者到达所有接收者的架构决策。有两种根本不同的方法,各有不同取舍:

方法怎么工作写成本读成本最适合
写扇出(推模型)发送时把消息复制进每个接收者的信箱/收件箱O(N) 写,N = 接收者O(1) 每用户收件箱扫描小群、DM;N 有界
读扇出(拉模型)消息在共享日志里存一次;每个接收者读时取O(1) 写O(成员) 每读(每个读者扫描共享日志)大群、公开频道;写成本会爆炸

混合策略

生产系统(WhatsApp、Slack、Telegram)用混合:

一句话说扇出取舍

写扇出用写放大换快读;读扇出用写简单换读复杂。交叉点大约是 100–200 成员:低于此,写扇出胜;高于此,读扇出避免 O(N) 写风暴。

第 10 步 — 群聊与有序保证

群消息把 receiver_id 换成 conversation_id 且必须同时投递给所有群成员。这个扇出是群聊主要的可扩展性挑战:一条给 500 人群的消息需要 500 次单独投递尝试。设计给群大小设上限(微信 500 人上限是参考)以约束最大扇出成本和投递滞后。

会话内消息有序

两个用户同时发消息能都"赢"并产生有序冲突。解法是用 Snowflake ID 作权威排序键——消息总按 message_id 升序显示。因为 Snowflake ID 含一个毫秒时间戳,同一毫秒从不同节点发的两条消息由节点 ID 位区分,即便并发发送也确保一个确定全序。

对严格因果有序要紧的会话(如"回复消息 X"),消息 schema 里含一个 reply_to_message_id 字段。客户端 UI 通过跟随这些链接渲染线程视图,无论消息的到达顺序。

第 11 步 — 已读回执

标志性的双勾 / 蓝勾指示(已发送 → 已送达 → 已读)是一个已读回执系统。实现细节:

对群消息,发送者只在每个成员都在 receipts 表里有 Delivered 回执时才看到"已送达所有人"。这是每次"已送达所有人"检查 O(成员) 次读——WhatsApp 对群显示每成员下钻列表而非单个聚合指示的原因。

批处理回执写

在 100B 消息/天,单个回执写会再给数据库加 200–300B 写/天(每消息两个回执)。通知服务批处理回执更新:投递 ACK 在 Redis 累积 500 ms,然后在单个批写里刷到 Cassandra。这把写放大降低 ~50–100×。

第 12 步 — 在线状态与 Last Seen

跟踪"这个用户在线吗?"和"他们上次何时用应用?"听起来简单,但在 40 亿用户的聊天系统里是最难的可扩展性问题之一。

朴素方法及为什么失败

最简单的实现会让每个用户每秒把当前时间戳写到数据库。1B DAU × 1 写/秒 = 10 亿写/秒——这显然不可能。

经心跳采样的在线状态

在线状态服务用一个采样方法:

在线状态订阅(联系人的在线状态)

用户想知道哪些联系人在线。朴素方法(每次页面加载为每个联系人查在线状态服务)每用户打开产生 O(联系人) 个请求。生产方法用一个 pub-sub 模型:

第 13 步 — 媒体存储

图片、音频和视频不内联存在消息对象里。相反,消息记录持有一个 CDN URL,实际二进制内容住在对象存储。上传流程:

  1. 客户端调 POST /api/media/upload
  2. 媒体服务生成一个预签名 S3 上传 URL 并连同一个 media_id 返回。
  3. 客户端经预签名 URL 直接上传到 S3(完全绕过应用服务器)。
  4. S3 触发一个 Lambda/事件通知媒体服务上传完成。
  5. 媒体服务记录 media_id → CDN URL 映射并把 CDN URL 返回给客户端。
  6. 客户端把 CDN URL 包含在消息载荷里。

媒体去重:上传前,客户端计算文件的 SHA-256 哈希并发给媒体服务。若哈希已存在数据库(另一用户已上传同文件),服务器返回已存在的 CDN URL 而无新上传——重复零字节传输。这对病毒媒体(表情包、语音)尤其有效,那里同一文件被数千用户分享。

第 14 步 — 扩展与容错

WebSocket 服务器扩展

消息的 Cassandra 分片

消息队列与重放

消息服务在持久到 Cassandra 前把每条进来的消息发布到 Kafka。这服务两个目的:它把写路径与下游消费者(通知服务、分析、搜索索引器)解耦,且它支持重放——若一个消费者落后或崩溃,它能从它最后提交的 Kafka offset 重放而不丢消息。

关键取舍

决策选择接受的取舍
实时投递WebSocket(持久)有状态服务器;比无状态 HTTP 更难横向扩展
消息 ID 生成Snowflake(分布式)节点 ID 必须谨慎分配;时钟偏移能造成罕见有序问题
扇出策略混合(写 <100,读 >100)两条代码路径的复杂度;每平台需调阈值
消息存储Cassandra(按会话分区)无跨会话查询;分析必须用单独 OLAP 存储
在线状态跟踪采样心跳(60s)在线状态略陈旧;"last seen 5 分钟前"可能偏差最多 60s
投递保证至少一次 + 客户端去重客户端必须处理并丢弃重复(idempotency_key 检查)
总结

聊天设计主要关于三个难问题:实时投递(WebSocket + WebSocket Manager 做路由)、离线排队(Pending 状态 + 推送通知 + 重连时追赶),和扇出策略(小群写扇出,大群读扇出)。Snowflake ID 用内建时间排序解决分布式唯一 ID 生成。群大小上限是一个显式可扩展性杠杆。在线状态采样用轻微精度换写吞吐——经典的可用性-一致性取舍应用到一个不明显的维度。

🎯 面试速答

当数百万 WebSocket 连接散布在许多服务器上时,怎么把消息路由到正确服务器?一个 WebSocket Manager(Redis 支撑)存 user_id → server:port;任何服务能 O(1) 查目标服务器并经内部 RPC 转发消息。
为什么给群聊大小设上限?一条群消息扇出到每个成员——1,000 人群意味 1,000 次单独投递。设上限约束最坏扇出延迟并防止单条消息饱和投递管道。
消息发给离线用户会怎样?消息以 Pending 状态存储;一个推送通知(APNs/FCM)唤醒接收者设备;重连时客户端发它的 last-seen ID,服务器投递所有漏掉的消息,状态更新为 Delivered。
写扇出 vs 读扇出——何时用各个?写扇出在发送时复制到每个收件箱(快读,O(N) 写)——用于 DM 和小群。读扇出在共享日志里存一次(O(1) 写,O(成员) 读)——用于写放大会过高的大群。
怎么在重试时防止重复消息?客户端生成的幂等键(UUID)存在 Redis 里带 24 小时 TTL。消息服务写前检查键;若已存在,返回已存在的消息 ID 而不创建重复。

← 上一篇
设计视频流平台