实时通讯 架构模板
代表产品:WhatsApp、微信、Slack、各类 IM / 群聊 一句话定位:让一条消息又快、又不丢、又有序地从一个人到达另一个人(或一群人)。
1. 一句话定位
一个实时通讯产品 = 一张「永远在线的消息投递网」:几百万、上千万人同时挂着一条「随时能收到推送」的长连接,系统要保证任何人发出的每一条消息,都尽快、不丢、按正确顺序送到该收的人手里——对方在线就立刻送达,不在线就先存好、等他上线再补。
架构上最反直觉的一点:难点不在「发」,而在「维持海量长连接」和「保证投递的可靠与有序」。一条消息很小,但「让千万条持久连接同时挂着、还能精准找到要把消息推给哪条连接」,以及「在网络随时会断的现实里做到不丢、不重、不乱序」,会把整套架构逼向有状态连接、序号、确认重试这些核心机制。
2. 业务本质:它在解决什么问题
用户要的是**「我发的消息,对方很快就能看到;对方发的,我立刻就收到;一条都不能丢、顺序还不能乱」。它取代的是「打电话/发邮件」那种要么打断、要么很慢的沟通——IM 提供的是即时、异步、有记录**的连续对话。
价值与钱从哪来:
- 粘性与网络效应:你的关系链都在这,迁移成本极高,这是最强的护城河;
- 企业版 / 团队协作(如 Slack):按席位订阅,卖的是团队沟通效率;
- 增值:表情、会员、企业服务、平台生态。
关键事实:这类系统的负载形态是「海量并发的持久连接 + 持续的小消息流」,而不是「短平快的请求-响应」。 普通网站的连接「来了就走」,这里的连接「来了就一直挂着」。这一条决定了为什么「长连接网关」「连接是有状态的」「怎么找到该把消息推给哪台机器上的哪条连接」会成为架构的核心命题。
3. 核心需求与约束
功能性需求(系统要能做什么):
- [ ] 发送 / 接收消息:一对一、一对多(群)。
- [ ] 实时送达:对方在线时,消息近乎瞬间到达。
- [ ] 离线消息:对方不在线时,消息不丢,上线后补齐。
- [ ] 消息有序:同一会话里,消息按发送顺序呈现。
- [ ] 可靠投递:不丢、不重(发出去的最终都到、且只呈现一次)。
- [ ] 在线状态(Presence):显示对方在线/离线/正在输入。
- [ ] 群组:成员管理 + 群消息扩散到每个成员。
- [ ] 多媒体:发图片、语音、文件。
- [ ] 多端同步:手机、电脑同时在线,消息和已读状态一致。
非功能性需求 / 质量属性(这才是架构的主战场):
| 质量属性 | 目标 | 为什么对这类系统重要 |
|---|---|---|
| 消息时延 | 在线时 < 几百毫秒 | IM 的灵魂就是「即时」,慢了就不叫即时通讯。 |
| 可靠投递 | 不丢、不重 | 丢一条重要消息=信任崩塌。这是底线。 |
| 消息有序 | 单会话内严格有序 | 顺序乱了对话就读不懂(「好啊」出现在「要不要吃饭」之前)。 |
| 海量并发连接 | 百万~千万级长连接 | 连接数是这类系统独有的核心规模指标。 |
| 可用性 | 99.9%+ | 连不上=用不了,用户立刻焦虑。 |
| 在线状态实时性 | 最终一致即可 | 状态变化极频繁,允许短暂不准——这是宝贵的「松弛空间」。 |
关键约束(不可逾越的边界):
- 🔴 长连接是「有状态」的。一条连接物理上挂在某一台网关机器上,系统必须知道「某用户此刻连在哪台机器」才能把消息推给他。这与无状态 Web 服务的扩展方式完全不同,是头号约束。
- 🔴 网络不可靠。连接随时会断、消息可能丢、可能重发,可靠与有序必须靠应用层机制(序号、确认、重试、去重)自己保证。
- 🔴 大群扩散:一条消息发进万人大群,要触达每个成员——这又是一个「扇出」问题,和社交信息流的大 V 问题同源。
- 消息写入量大且持续:每条消息都要持久化以支持离线/历史/多端。
4. 架构全景图
发送方(手机/电脑,挂着长连接) 接收方(在线 / 离线)
│ ① 发消息 ▲
▼ │ ⑤ 推送(在线时)
┌──────────────────────────────────────────────────────────────────────┐
│ 长连接网关层(有状态:每台机器挂着一批持久连接) │
│ 网关A 网关B 网关C ... (水平扩展,可挂百万级连接) │
└────┬───────────────────────────────────────────────┬──────────────────┘
│ ② 上行消息 ▲ ④ "推给用户U" → 查路由
▼ │ → 找到U连在网关C → 投递
┌────────────────────┐ 查询/更新 │
│ 消息服务(核心) │ ◀──── 用户↔网关 路由表 ────────┘
│ • 分配会话内序号 │ (用户U 此刻连在 网关C)
│ • 持久化消息 │ ▲
│ • 判断收方在/离线 │ │ 连接建立/断开时更新
│ • 在线→投递 / 离线→存│
└───┬──────────┬───────┘
│ │ ③ 写入 离线分支
│ ▼ ┌────────────────────┐
│ ┌──────────────┐ │ 离线消息存储 │
│ │ 消息存储 │ │ + 离线推送服务 │
│ │ (持久/有序) │ │ (唤起厂商推送通道) │
│ └──────────────┘ └────────────────────┘
│ ▲ 收方上线后来拉取
│ 群消息扩散
▼
┌──────────────┐ ┌────────────────┐ ┌──────────────────────────────┐
│ 群组/成员服务 │ │ 在线状态服务 │ │ 媒体存储 │
│ (扇出到成员) │ │ (presence, │ │ (图/语音/文件,只传引用) │
│ │ │ 最终一致) │ │ │
└──────────────┘ └────────────────┘ └──────────────────────────────┘灵魂部件是两块:「长连接网关层」(有状态地挂着海量持久连接)和它依赖的**「用户↔网关路由表」(要把消息推给某人时,先查到他此刻连在哪台网关)。整套系统的核心机制都围绕这两点,以及消息服务里的「序号(保序)+ 持久化(不丢)+ 在线/离线分流」**展开。
5. 组件职责
- 长连接网关层:维持海量客户端的持久连接,做心跳保活,上行收消息、下行推消息。它是有状态的——每条连接物理绑定在某台网关上。为什么需要:实时推送要求服务端能主动找到客户端并推过去,这只能靠一直挂着的连接;轮询既慢又费。它是这类系统区别于普通 Web 的根本所在。
- 用户↔网关路由表:记录「某用户此刻连在哪台网关机器上」。连接建立/断开时更新。为什么需要:连接有状态,要把消息投给用户 U,必须先知道 U 连在哪,才能把投递指令发到那台网关。这是有状态连接得以水平扩展的关键拼图。
- 消息服务(核心):为每条消息在会话内分配单调递增序号(保序),持久化消息(不丢),判断收方在线与否并分流(在线直接投递、离线转存 + 触发推送)。为什么需要:可靠与有序不能靠网络自带,必须由这一层用序号、落库、确认来保证;它是消息流转的中枢。
- 消息存储:持久保存消息,支持离线补齐、历史回溯、多端同步。写多、按会话有序读。为什么需要:消息不能只活在内存/连接里,否则一断线就丢;持久化是「不丢」和「多端一致」的基础。
- 离线消息存储 + 离线推送服务:收方不在线时,消息存进他的「离线信箱」,并通过系统级推送通道唤醒对方;上线后他来拉取补齐。为什么需要:用户大量时间不在线,必须保证「人不在也不丢、回来能补上」。
- 群组 / 成员服务:维护群成员关系,群消息要扇出到每个成员。为什么需要:群消息本质是「一条消息要投递给 N 个收方」,这是个扇出问题(和社交信息流的大 V 同源),大群尤其要小心。
- 在线状态服务(Presence):维护在线/离线/正在输入等状态。为什么需要:社交临场感的关键。但状态变化极频繁,最终一致即可——晚一两秒不致命。
- 媒体存储:图片/语音/文件存对象存储,消息里只带引用(地址),不把字节塞进消息流。为什么需要:大文件不能挤占轻量、要求低时延的消息通道,分开传输。
6. 关键数据流
场景一:一对一发消息,收方在线(最核心的实时路径)
1. 发送方(连在 网关A) ── ① 发消息 ──▶ 网关A ── ② 上行 ──▶ 消息服务
2. 消息服务:
a. 给这条消息分配【会话内序号 seq=N】(保证顺序)
b. 持久化这条消息(写入消息存储) ← 先落库,保证不丢
c. 查【路由表】:收方 U 此刻连在 网关C,且在线
3. 消息服务 ── ④ "投递给U" ──▶ 网关C ── ⑤ 推送 ──▶ 收方屏幕
4. 收方收到后回一个【ack(收到了 seq=N)】
5. 没收到 ack → 消息服务按策略重试投递;收方靠 seq 去重(重复的丢弃)注意:先落库、再投递(不丢);会话内单调序号(有序);ack + 重试 + 按序号去重(可靠且不重)。可靠投递的三件套全在这条路径里。
场景二:收方离线(存 + 推 + 上线补齐)
1~2. 同上,消息服务落库、查路由表 ── 发现收方 U 不在线(没有活动连接)
3. 消息服务:把消息写入 U 的【离线信箱】
4. ──▶ 离线推送服务:通过系统级推送通道,给 U 的设备发一条唤醒推送
5. (一段时间后)U 打开 App、建立长连接 ──▶ 网关更新路由表(U 现在连在 网关B)
6. U 上线后主动【拉取离线信箱】里 seq 大于"我已收到的最大序号"的所有消息
7. 按 seq 排好序呈现 → 一条不少、顺序正确注意第 6 步:靠「我已收到的最大序号」做断点续传式拉取,既不漏也不重——序号在这里同时解决了「补齐」和「去重」。
场景三:群消息扩散(扇出问题)
某人在群里发一条消息 ──▶ 消息服务(分配该群会话的序号、落库一次)
──▶ 群组服务:取出群成员列表
──▶ 对每个成员:在线的 → 查路由表投递;离线的 → 进各自离线信箱 + 推送
小群:直接逐个扇出,没问题。
万人大群:一条消息瞬间要触达上万人 → "扇出爆炸"(同社交流的大V问题)
✅ 大群优化:消息只存"一份共享的群消息流",成员各自维护"读到哪了"的位点,
上线/在群内时按位点来拉,而非给每人都推一份/存一份。大群扩散和社交信息流的「大 V 写扩散爆炸」是同一个问题:都是「一次写要触达海量接收方」。破解思路也同源——别给每人都拷一份(写扩散),改成共享一份、各自按位点来拉(读扩散)。
7. 数据模型与存储选择
核心实体:用户;会话(一对一 / 群);消息(message,带会话内序号 seq);用户↔网关 路由(谁连在哪);会话成员关系;在线状态;已读位点。
| 数据 | 存储类型 | 为什么 |
|---|---|---|
| 消息(持久) | 有序追加存储(按会话分片) | 写多、按会话有序读、要支持离线补齐与历史;序号天然适合追加 |
| 用户↔网关 路由表 | 内存级 KV | 极高频读写(每条消息投递都要查)、要极低延迟、连接变动频繁 |
| 在线状态(presence) | 内存级 KV(带过期) | 变化极频繁、读频繁、可最终一致,适合带 TTL 的高速缓存 |
| 离线信箱 | KV / 有序队列(按用户) | 按用户存待收消息,上线后按序号拉取 |
| 会话 / 群成员关系 | 关系型 / 宽列 | 成员管理要一致;大群成员列表可能很长 |
| 媒体(图/语音/文件) | 对象存储 | 文件大、不可变;消息里只存引用 |
| 用户账户 | 关系型 | 要事务、强一致(账号安全) |
教学点:消息流和「连接路由表」是两类访问形态完全不同的数据。 消息要持久、有序、可回溯,适合有序追加存储;路由表要的是「每次投递都极速查一下 U 连在哪」,是典型的内存级 KV。用「内存级 KV」描述路由表与 presence,是因为它们的访问形态是「超高频、低延迟、可接受最终一致」,与具体产品无关。 把路由表放进慢速持久库,会让每条消息的投递都被拖慢。
8. 关键架构决策与权衡 ⭐
决策 1:长连接怎么维持与水平扩展?(本架构的命门)
- 问题根源:长连接是有状态的——一条连接物理挂在某台网关机器上。无状态 Web 服务「随便哪台机器都能处理任意请求」的扩展方式在这里失效了,因为「要把消息推给 U」必须送到「U 实际连着的那台机器」。
- 选项 A(单机/纵向):一台超强机器挂所有连接。简单,但连接数有物理天花板,且单点故障=全员掉线。
- 选项 B(水平扩展 + 路由层):多台网关分担连接,外加一张用户↔网关路由表记录「谁连在哪」;投递时先查路由、再把消息送到对应网关。
- 取向:必然走 B。代价是:① 多了一层路由表,且它要随连接的建立/断开实时更新(高频写);② 投递路径多一跳查询;③ 网关机器故障时,其上所有连接要能重连并刷新路由。「把有状态的连接 + 一张‘状态在哪’的路由表」组合起来,是让有状态服务水平扩展的通用套路——它把「状态绑定」与「按状态路由」显式分离了出来。
决策 2:消息时序怎么保证?
- 靠到达时间排序:网络抖动、重发会让到达顺序≠发送顺序,乱序。
- 靠会话内单调递增序号:每条消息在其所属会话里拿一个递增的 seq,接收端按 seq 排序呈现,而非按到达顺序。
- 取向:用单调序号保序,且保序的范围限定在「单个会话内」(不需要全局有序——全局有序代价极高且没必要,你只关心「这个对话里的顺序」)。代价是要有一个「为某会话发号」的机制(且要避免它成为瓶颈,通常按会话分片发号)。「在真正需要顺序的最小范围内保序」——这是个关键的范围收敛智慧。
决策 3:可靠投递(不丢、不重)怎么做?
- 发出去就不管:最简单,但网络一抖就丢消息,违背底线。
- 确认 + 重试 + 幂等去重:① 收方收到后回 ack;② 发送侧没收到 ack 就重试;③ 重试会导致重复,所以接收侧靠 seq / 消息 ID 去重(同一条只呈现一次)。再叠加「先持久化、再投递」保证即使投递失败,消息也没丢、可重投。
- 取向:三件套(ack + 重试 + 去重)缺一不可,因为重试和去重是一对——你为了「不丢」而重试,就必然引入「重复」,于是必须去重。代价是协议变复杂、每条消息要带 ID/seq、要维护 ack 状态。「至少一次投递 + 幂等去重 = 恰好一次的效果」,这是分布式投递的经典组合。
决策 4:群消息扩散——写扩散还是共享读取?
- 写扩散(给每个成员各投/各存一份):小群很自然、读时简单。但大群爆炸——万人群一条消息瞬间产生上万次投递/存储。
- 共享读取(只存一份群消息流,成员各记「读到哪」):写极轻(存一份),成员按已读位点来拉。大群友好,但读时要按位点聚合。
- 取向:小群写扩散、大群共享读取(和社交流「普通人推、大 V 拉」同构)。代价是系统里同时存在两套群消息处理路径。这再次印证:任何「一次写触达海量接收方」的扇出问题,解法都在‘拷贝一份 vs 共享一份按需拉’之间权衡。
决策 5:在线状态(presence)要多准?
- 强一致、实时精确:状态变化极频繁(每次切前后台、断网、心跳都变),强一致代价极高,且没必要。
- 最终一致 + 高速缓存 + 过期:状态写进带 TTL 的内存级 KV,允许短暂不准(对方刚下线你可能还显示在线一两秒)。
- 取向:最终一致足矣。presence 是典型的「可以不那么准」的数据,把它当强一致来做是巨大的浪费。识别出「哪些数据允许短暂不准」,能省下大量成本——这是宝贵的设计自由度。
9. 规模化与瓶颈
和普通系统不同:这里的头号瓶颈是「长连接数」和「连接路由」,其次才是消息写入与大群扩散。
- 第一个瓶颈:长连接数。 单机能挂的持久连接有上限,用户一多就触顶。 破解:① 网关水平扩展(多台分担连接);② 连接路由层(用户↔网关路由表)让消息能找到对应网关;③ 就近接入、连接负载均衡。
- 第二个瓶颈:大群扩散。 一条消息要触达海量成员。 破解:大群共享读取(读扩散)+ 已读位点拉取,而非给每人推/存一份(决策 4)。
- 第三个瓶颈:消息存储写入量。 每条消息都要落库,量极大且持续。 破解:① 按会话分片(把写入压力打散);② 冷消息归档到更廉价的存储层;③ 写入路径异步化、批量化。
- 第四个瓶颈:路由表 / presence 的高频读写。 每次投递查路由、状态频繁变更。 破解:放内存级 KV、按用户分片、presence 用最终一致 + TTL 降低写压。
- 媒体流量:图/语音/文件走对象存储 + (必要时)CDN,不挤占消息通道。
10. 安全与合规要点
- 端到端加密(取决于产品定位):像 WhatsApp 这类强隐私定位的产品,消息在两端加解密,服务器只转发密文、看不到内容。这是最强的隐私承诺,但代价是:服务端无法做内容审核、无法在服务端做全文搜索、多端同步密钥管理复杂。是否上 E2EE 是一个产品级取舍。
- 消息隐私与最小留存:消息是高度敏感数据,要做传输与存储加密、访问控制、明确的留存与删除策略。
- 传输加密:即便不做端到端,客户端到服务器的链路也必须加密。
- 滥用与垃圾消息:防骚扰、防垃圾、防批量拉群,需要风控与限流。
- 元数据隐私:即便消息内容加密,「谁和谁在何时聊」这类元数据本身也敏感,要尽量少留、严控。
- 群与权限:谁能进群、谁能看历史消息,权限要在服务端强制,不能只靠客户端隐藏。
11. 常见误区 / 反模式
- ❌ 用轮询代替长连接 → ✅ 轮询要么慢(间隔长)、要么费(间隔短狂打服务器),都做不到真正实时。实时推送要靠长连接。
- ❌ 不分配序号,按到达顺序展示 → ✅ 网络抖动/重发必然乱序。用会话内单调序号保序。
- ❌ 不做去重,重试导致消息重复 → ✅ 为「不丢」而重试必然带来重复,必须靠 seq/消息 ID 幂等去重。
- ❌ 发出去就不管,不做 ack/重试 → ✅ 网络一抖就丢。ack + 重试 + 先落库再投递才能不丢。
- ❌ 大群同步写扩散 → ✅ 万人群一条消息瞬间上万次投递,卡死。大群改共享读取 + 位点拉取。
- ❌ 把连接当无状态、随意路由 → ✅ 连接是有状态的,必须有路由表知道用户连在哪,否则消息推不到人。
- ❌ presence 当强一致来做 → ✅ 状态变化极频繁,最终一致足够,强一致是巨大浪费。
- ❌ 把媒体字节塞进消息流 → ✅ 大文件挤占低时延消息通道,应存对象存储、消息里只带引用。
12. 演进路线:MVP → 成长期 → 成熟期
| 阶段 | 用户/规模量级 | 架构长什么样 | 此时该操心什么 |
|---|---|---|---|
| MVP | 验证想法 / 几万用户 | 单机长连接(或简单轮询)+ 单一消息存储;序号、ack、离线信箱的基本机制先有;无大群优化 | 先把「实时、不丢、有序」的基本盘做对,连接都在一台机器上还撑得住 |
| 成长期 | 百万级连接 | 分布式长连接网关 + 路由表;离线消息存储 + 离线推送;消息存储按会话分片;presence 用高速缓存 | 长连接水平扩展、连接路由、可靠投递在分布式下依然成立;盯住第一批大群 |
| 成熟期 | 千万级连接 / 海量消息 | 大群优化(共享读取+位点) + 多活/多区域 + 就近接入 + 完善的多端同步 + (按定位)端到端加密 + 风控合规 | 大群扇出、连接规模、容灾多活、消息存储成本、隐私与合规 |
13. 可复用要点
- 💡 有状态服务靠「状态绑定 + 路由表」来水平扩展。 长连接挂在某台机器上,就用一张「状态在哪」的路由表来定位它。任何有状态资源(会话、连接、分片主副本)的扩展,都可借鉴这种「把状态绑定与按状态路由显式分离」的套路。
- 💡 在「真正需要顺序的最小范围」内保序,别追求全局有序。 单会话内用序号保序就够,全局有序代价高且无意义。范围收敛是性能与复杂度的关键。
- 💡 至少一次投递 + 幂等去重 = 恰好一次的效果。 重试(防丢)与去重(防重)是一对孪生机制——任何「不可靠信道上要可靠送达」的场景都适用,从消息到支付回调到事件投递。
- 💡 「一次写触达海量接收方」永远是扇出问题,解法在「各拷一份 vs 共享一份按需拉」之间。 大群扩散、社交大 V、广播通知,本质同源,可套同一套权衡。
- 💡 识别「允许不那么准/不那么实时」的数据,把它降级处理。 presence 用最终一致省下大量成本。哪里能松弛,哪里就能换来弹性与省钱。
- 💡 先落库、再投递。 把「持久化」放在「送出」之前,是「不丢」的根基——这条次序原则在任何要求可靠的写-投递链路里都成立。
🎯 随堂检验
- A只重试
- B至少一次投递 + 幂等去重
- C只去重
参考原型与延伸阅读
本模板基于以下官方工程博客整理。
📖 工程博客:
- How Discord Stores Trillions of Messages — 海量消息存储从 Cassandra 迁到 ScyllaDB、请求合并降延迟。
- Slack: Flannel, an edge cache to make Slack scale — 承载数百万 WebSocket 长连接的边缘缓存。
- WhatsApp: Scaling to Millions of Simultaneous Connections (Rick Reed) — 单机支撑百万级并发长连接(每连接一进程)。
📌 一句话记住实时通讯:它不是「能发消息的网站」,而是「一张永远在线的消息投递网」——核心难点是『维持海量有状态长连接 + 在不可靠网络上做到不丢、不重、不乱序』,所有架构机制(网关路由、序号、ack/重试/去重、离线存推)都在为这两件事服务。