面试题:设计一个 IM 即时通讯系统——百万长连接怎么撑住、消息怎么做到不丢不重不乱序、万人群的消息扩散怎么算账?
这是「每日一题」专栏的第二十三篇。每天一道面试题,后端 + AI 混合路线,从原理到代码一次讲清。上一篇是 MySQL 索引与深分页,文末预告的分布式 ID 生成器在 8 月 16 日已经出过一版 基础篇,再写一遍是重复劳动,所以今天换成同属系统设计方向、但更贴近日常后端的一道硬题:IM 即时通讯系统。
这题的杀伤力在于它「看起来谁都能画个框图」:客户端、网关、业务层、存储,几笔就画完了。但只要面试官往下一问——「单机百万连接,内存要多少 GB?端口够用吗?」「网关到底是不是无状态?」「你说消息不丢,丢在哪几个时刻?」「顺序用什么保证,时间戳行不行?」「一万人群发一条消息,写放大是多少倍?」——画框图的人立刻熄火。今天把这五个追问的账全部算一遍。
一、题目与考点拆解
面试官的原话通常是:
设计一个 IM 系统,支持单聊、群聊(最大万人群)、多端登录、离线消息、已读回执。要求:消息不丢、不重、不乱序;用户在线时延迟不超过 100ms。先讲整体架构,再讲消息怎么持久化、怎么做多端同步。如果单机要撑 100 万长连接,你怎么算资源?
这一题真正考的是六层,缺一层都算没答透:
- 连接层:为什么长连接不可替代、协议怎么选、单机连接数的资源账、网关有没有状态。
- 路由层:
user_id怎么找到它挂在哪个网关、路由表存哪、怎么过期、网关挂了怎么办。 - 可靠性:丢消息到底丢在哪几个时刻、需要几个 ACK、exactly-once 为什么不可能。
- 顺序与同步:消息序号用什么、为什么不用时间戳、多端增量同步的游标怎么设计。
- 扩散与算账:一条消息的成本是「存一份」还是「写 N 份」、万人群的写放大怎么压、未读数怎么算。
- 存储与容量:消息表怎么建、为什么这么分片、一天多少 TB、冷热怎么分。
二、先定架构:一条消息要走几跳
┌──────────┐ TLS/WebSocket ┌───────────────┐
│ Client │ ─────────────────► │ Access 层 │ 长连接网关(有状态)
└──────────┘ │ gateway × N │ 只管连接,不做业务
▲ └───────┬───────┘
│ 下行推送 │ RPC(按 conversation_id 路由)
│ ┌───────▼───────┐
│ │ Logic 层 │ 无状态,会话单 owner 串行
│ │ msg / sync │
│ └───────┬───────┘
│ │
│ ┌─────────────────┼─────────────────┐
│ ┌─────▼─────┐ ┌──────▼──────┐ ┌──────▼──────┐
│ │ 消息存储 │ │ 会话/游标 │ │ 路由表 Redis │
│ │ (分片 KV) │ │ (dialog) │ │ user→gateway │
└────────┤ │ └─────────────┘ └─────────────┘
└───────────┘
│
┌──────▼──────┐
│ 离线推送 │ APNs / FCM / 厂商通道
└─────────────┘
四条主线,后面的所有追问都挂在这四条上:
- 上行:client → gateway → logic → 落库 → 回 ACK 给发送方(先落库再 ACK,这是「不丢」的地基)。
- 下行:logic 查路由表 → 找到接收方所在网关 → 推送 → 客户端回 ACK → 未 ACK 则重推。
- 同步:客户端断线重连后不靠推送补数据,而是带着游标做增量拉取(pull),推送只是「提前告知」。
- 存储:消息按会话存一份,用户维度只维护「会话列表 + 游标」,这是理解写放大的关键(第九节)。
三、接入层:长连接为什么不可替代
3.1 短连接的根本问题
IM 的下行是服务端主动发起的。HTTP/1.1 短连接做不到,只能靠轮询,而轮询的代价是双向浪费:
- 轮询间隔 5 秒、1000 万在线 → 200 万 QPS 的空请求,99% 返回「无消息」。
- 间隔拉长到 60 秒,消息延迟中位数就是 30 秒,产品不可接受(IM 的体感阈值是 1 秒内)。
- 每一次请求都要建连、TLS 握手、鉴权,成本远高于「一直挂着」。
所以 IM 必须维持一条服务端可以随时写入的长连接。这是所有后续设计的起点。
3.2 协议选型
| 方案 | 下行能力 | 移动端友好度 | 中间设备穿透 | 适用 |
|---|---|---|---|---|
| HTTP 轮询 | 差(延迟等于间隔) | 耗电、耗流量 | 无问题 | 只做兜底降级 |
| HTTP 长轮询 | 中(有消息才返回) | 一般 | 无问题 | 兼容老客户端、Web 降级 |
| SSE | 单向服务端推 | 一般(部分浏览器连接数限制) | 无问题 | 只需下行(如通知流) |
| WebSocket | 双向、全双工 | 好 | 所有代理都支持升级 | Web / 跨端首选 |
| 自定义 TCP 二进制 | 双向、开销最小 | 好(需自研) | 部分企业代理封非标端口 | 移动端 IM 主链路 |
| QUIC / HTTP3 | 双向、0-RTT 重连 | 好 | UDP 可能被限速 | 弱网优化(可选叠加) |
工业界的典型组合是:移动端自定义二进制 TCP(4 字节长度 + 命令字 + Protobuf),Web 端 WebSocket,弱网再叠一层 QUIC。共同点是自己定义「命令字 + 序号 + ACK」的帧格式——因为你需要在这些语义上做可靠性,而 HTTP 语义给不了。
3.3 网关到底有没有状态
这是第一个高频追问,答错的人很多:
「网关是无状态的吧?可以随便水平扩容?」
网关的连接是有状态的,业务逻辑是无状态的——这两句必须分开说。
- 一条 TCP 连接绑定在某个进程的某个 fd 上,内核里的 socket 缓冲、TLS 会话、半包状态,全部只存在于这台机器。所以「这个 user 挂在哪个网关」是必须被记录的状态,网关不能像普通无状态服务那样随便漂移。
- 但网关不持有业务数据:它不查库、不算未读、不做鉴权之外的业务判断,只做「鉴权 → 注册路由 → 转发 → 心跳 → 断开清理」。所以它可以被随时杀掉,代价只是连接要重连,而不是数据不一致。
这个区分决定了扩容方式:网关扩容是「加机器 → 新连接按负载均衡自然分到新机器」,存量连接不会迁移(也没必要迁移),老机器随着用户断线重连自然排空,也就是滚动发布期间的优雅下线:先把自己从 LB 摘掉,通知客户端「请重连到别的节点」,等连接数降到阈值再退出。
3.4 单机百万连接:把账算出来
面试官问「单机 100 万连接怎么撑」,想听的是数字,不是「用 epoll」。
内存账:
| 项目 | 单连接成本 | 100 万连接 |
|---|---|---|
| 文件描述符 | 1 个 fd | 需 ulimit -n 与 fs.file-max 调到百万级 |
| 内核收发缓冲 | tcp_rmem / tcp_wmem 最小值各 4KB,合计约 8KB | 约 8GB |
| TCP 控制块 + 连接哈希表 | 约 1-2KB | 约 1-2GB |
| 应用层连接对象(读缓冲、状态机、定时器、TLS 会话) | 1-4KB | 约 1-4GB |
| 合计 | — | 约 10-14GB |
结论:百万连接不是 CPU 问题,是内存和 fd 问题。一台 32GB / 16 核的机器,纯接入层撑百万连接是现实的;把 TLS 会话缓存和读缓冲调小还能再省。所以接入层机器规格是「大内存、中等 CPU」,和业务层的「多核、中等内存」正好相反——这本身就是一个可以让面试官点头的观察。
端口账(这题最能筛人):
「100 万连接,65535 个端口够吗?」
不够,但服务端根本不需要那么多端口。TCP 连接由四元组唯一确定 (源 IP, 源端口, 目的 IP, 目的端口)。服务端只用一个监听端口,靠客户端不同的源 IP/源端口区分连接,所以服务端不受 65535 限制。
受限制的是客户端侧:一台机器连同一个「网关 IP + 端口」,源端口最多约 6 万个(要避开保留端口)→ 单机对同一目标最多约 6 万条连接。这也是当年 C10M/C1M 压测必须给压测机配多个虚拟 IP 的原因。生产上这个限制体现为:NAT 出口后的办公网、同一个 LB 后面的客户端集群,会遇到源端口耗尽,需要多 IP 或多端口出口。
CPU 账(重连风暴才是杀手):
稳态下 CPU 几乎不干活:心跳 30 秒一次,100 万连接约 3.3 万 QPS,纯内存读写,单核可扛几十万 QPS,一两个核就够。真正的 CPU 峰值在重连:每个重连要做 TCP 握手 + TLS 握手(ECDSA 约 0.1-1ms/次,单核约 1-2 万次/秒)→ 100 万连接同时重连,等于要 50-100 个核·秒。这就是为什么必须上 TLS 1.3 的 session resumption(PSK / 0-RTT),以及为什么第十一节要专门讲重连风暴的限流。
四、路由:user_id 怎么找到它挂在哪个网关
4.1 中心路由表 vs 一致性哈希
两条路:
| 方案 | 做法 | 优点 | 致命缺点 |
|---|---|---|---|
| 一致性哈希 | gateway = hash(user_id) % N | 无中心依赖,O(1) | 扩缩容时大量 user 的归属变化,但连接还在老网关 → 路由算出来的地址是错的,必须重连;连接数变化频繁时不适用 |
| 中心路由表 | Redis 存 user_id → device → gateway_id 的映射 | 与实际连接一致,扩缩容无影响 | 多一次 Redis 查询、需要 TTL 清理 |
生产用中心路由表。原因很实在:一致性哈希的「稳定」只在「节点集合不变」时成立,而 IM 的网关是经常滚动发布的,哈希方案会导致「算出来的网关没有这条连接」,然后你要么转发失败要么全局广播,反而更糟。路由表虽然多一跳,但它是唯一事实——连接在哪,表里就写哪。
4.2 表结构与续期
key: route:{user_id} (hash)
field: {device_id} (1=phone, 2=pc, 3=web)
value: {gateway_id}:{conn_id}
ttl: 90s(每次心跳续期到 90s)
三个要点:
- TTL 必须靠心跳续期,不能只靠「断开时删除」。进程被
kill -9、机器断电、网络分区,都来不及删——TTL 是唯一的兜底。90 秒的 TTL 配 30 秒心跳,容忍一次心跳丢失。 - value 里带 conn_id,因为同一台网关上同名用户可能有多条旧连接(重连时旧连接还没超时)。只存
gateway_id会导致消息推给一条已经半死的连接上。 - 一个 user 多个 device 用 hash 存,多端在线时路由表天然是「设备 → 连接」的多值映射,这正是第十节多端同步的基础。
4.3 跨机房
路由表按机房分片存(用户归属哪个机房是固定的,登录时按就近 + 一致性分配)。跨机房下行不直接 RPC 打到对端网关,而是丢进消息总线(Kafka,按 user_id 分区)让对方机房的 logic 消费后推——好处是机房之间只有单向依赖,对端挂掉不会把本机房拖死(同步 RPC 会把超时传导成雪崩)。
五、心跳:为什么 TCP keepalive 靠不住
5.1 三个必须知道的数字
- Linux
tcp_keepalive_time默认 7200 秒(2 小时),tcp_keepalive_intvl75 秒、net.ipv4.tcp_keepalive_probes9 次 → 最坏要 2 小时 11 分才发现对端死了。 - 家用路由器 / 运营商 NAT 的会话表项,空闲超时普遍在 5-30 分钟(UDP 更短,60-300 秒)。超时后 NAT 直接丢弃映射,不发任何 RST。
- 中间设备(企业防火墙、LB)也各有 idle timeout,常见 60-900 秒。
两者叠加的后果是半开连接(half-open):服务端以为自己还连着,往一个已经不存在的 NAT 映射里写数据,内核不报错(数据进了黑洞),业务层收不到任何反馈。用户侧表现为「消息发出去没反应,得手动切后台再回来」——这是 IM 最经典的 bug。
5.2 正确做法:应用层心跳
- 心跳间隔:30-60 秒,必须小于链路上最短的那个 idle timeout(取 60 秒是安全的,激进一点 30 秒)。
- 双向心跳:服务端也周期性 ping 客户端,不能只让客户端 ping。只有单向心跳时,服务端的「写成功」不代表对端可达(见上)。
- 读超时判死:服务端对每条连接设
last_recv_at,超过2.5 × heartbeat没有任何数据(连心跳都没有)就判死、关闭、清路由。 - 心跳要带轻量业务信息:很多实现会在心跳包上捎带「未读会话数」「是否有待拉取消息」,一条心跳省掉一次额外请求。
- 加抖动:100 万连接如果都从同一秒开始计时,会形成每 30 秒一次的 100 万 QPS 尖峰。心跳间隔要在 30±3 秒内随机。
# 服务端连接存活检查(每台网关一个定时器,1 秒扫一次)
HEARTBEAT_SEC = 30
DEAD_AFTER_SEC = HEARTBEAT_SEC * 2.5 # 75 秒没有任何数据就判死
def sweep(conns):
now = monotonic()
for c in conns: # 分片扫描,避免一次遍历百万对象
if now - c.last_recv_at > DEAD_AFTER_SEC:
c.close(reason="read_timeout")
route.delete(c.uid, c.device_id, expect=(c.gateway_id, c.conn_id))
# 注意 expect:只删自己这条,避免把客户端已经重连的新连接误删
elif now - c.last_send_at > HEARTBEAT_SEC + jitter(c.conn_id, 3):
c.send(PING)
最后那句注释是实战里踩过的坑:旧连接判死时删路由,可能删掉客户端重连后的新记录,所以删之前要校验 value 仍是自己。
六、不丢:消息到底在哪些时刻会丢
先把「丢」拆成四个时刻,每个时刻配一套对策,这样回答才不是背概念:
| 时刻 | 为什么丢 | 对策 |
|---|---|---|
| 上行:client 发出,server 没收到 | 网络断开 | 客户端本地待发队列 + 服务端 ACK,未 ACK 就重发(带同一个 client_msg_id) |
| 服务端:落库了但没推出去 | logic 崩溃、网关崩溃 | 先落库再回 ACK;推送失败不重试到底,靠客户端重连后按 seq 拉取补齐 |
| 下行:推了但 client 没收到 | 半开连接、切后台被系统冻结 | 下行 ACK(按 seq 确认)+ 未确认则在重连时补推 |
| 客户端:收到了但没存 | 进程被杀、闪退 | 消息先写入本地 DB 再回 ACK;本地 DB 与「已同步游标」在同一事务里更新 |
6.1 「先落库再 ACK」是地基
发送方的 ACK 语义必须是「服务端已持久化」,而不是「服务端已收到」。否则 logic 收到消息、回 ACK、然后崩溃 → 用户以为发出去了,实际谁也不知道这条消息存在,这是最难排查的一种丢消息。
顺序严格是:
client ──msg──► gateway ──► logic ──► 分配 seq ──► 落库(消息表 + 会话列表)
│
├──► 回 ACK 给发送方 (ack_seq)
└──► 异步:推送给接收方 + 写扩散
注意「推送」在 ACK 之后、且是异步的。因为推送是「尽力而为的加速手段」,不是可靠通路——可靠通路永远是「客户端带游标来拉」。
6.2 为什么不能靠服务端无限重推
下行消息如果客户端一直不 ACK(用户关机了),服务端不能无限重试:内存里堆着几十万条待重推消息,一次 GC 就把网关拖垮。所以工业界的做法是:
- 服务端只重推一个很短的窗口(比如 3 次、10 秒内),失败就放弃,把消息留在存储里。
- 客户端重连后走增量同步(第十节),从存储里把缺的补上。
换句话说:推送负责「实时」,拉取负责「可靠」。这句话是整道题的核心思想之一。
6.3 客户端本地存储与游标的一致性
客户端最容易忽略的一点:消息写本地 DB 和更新「本地已同步游标」必须在同一个本地事务里。否则出现「游标推进了但消息没落盘」→ 下次同步从新游标开始,中间那段永久丢失,而且客户端自己不知道。
七、不重:exactly-once 做不到,但幂等做得到
7.1 先承认理论上做不到
分布式系统里,端到端 exactly-once 需要「发送方知道接收方收到了」和「接收方知道发送方知道」无限递归,这就是两将军问题——在不可靠网络 + 可能崩溃的进程上,不可能有有限步骤的协议保证双方状态一致。所以任何声称「exactly-once 投递」的系统,本质都是 at-least-once 投递 + 幂等消费。
面试时说清这一点,比背「用 Redis 去重」高一个层次。
7.2 三层幂等
| 层 | 幂等键 | 实现 | 防的是什么 |
|---|---|---|---|
| 客户端 → 服务端 | client_msg_id(UUIDv7,客户端生成) | 消息表唯一索引 (sender_id, client_msg_id),冲突则返回已有 seq | 用户狂点发送、弱网自动重发 |
| 服务端 → 客户端(同一设备) | seq | 客户端本地按 (conversation_id, seq) 主键 upsert | 重推、重连补推重复 |
| 服务端 → 客户端(多设备) | msg_id | 各端独立 upsert,互不影响 | 多端同时在线 |
client_msg_id 用 UUIDv7(时间有序)而不是 UUIDv4,是为了在唯一索引上保持插入局部性——v4 随机分布会让 B+ 树每个新插入都落在不同页,写放大明显。
# 服务端处理上行消息:分配 seq 与幂等落库
def handle_send(uid, conv_id, client_msg_id, payload):
# 1) 幂等检查(唯一索引优先,Redis 只做前置挡板降低无效写)
row = db.query_one(
"select seq from message where sender_id=? and client_msg_id=?",
uid, client_msg_id)
if row:
return row.seq # 重发:直接回原来的 seq
# 2) 会话级连续 seq(见第八节)
seq = alloc_seq(conv_id)
# 3) 落库 + 更新会话列表,同一事务
db.tx(lambda c: (
c.execute("""insert into message
(conversation_id, seq, msg_id, sender_id, client_msg_id,
type, content, created_at)
values (?,?,?,?,?,?,?,?)""",
conv_id, seq, new_msg_id(), uid, client_msg_id,
payload.type, payload.body, now_ms()),
c.execute("""update dialog set last_seq=?, last_msg_id=?
where conversation_id=?""", seq, msg_id, conv_id),
))
return seq
这里的 try: insert except DuplicateKey: return existing_seq 才是真正的幂等实现;前面的 select 只是减少无效写,不能当作唯一保证(并发下两个重发都会查不到)。
八、不乱序:为什么要用「会话级连续 seq」
8.1 时间戳排序为什么不行
- 不同客户端时钟不同步,误差从几十毫秒到几分钟都有;用户手动改系统时间也是常事。
- 同一毫秒内多条消息没有区分度,需要额外字段打破平局。
- 服务端时间戳看似可用,但多台 logic 节点取到的时间仍然会因调度抖动而乱序(A 请求先在节点 1 取时间,B 请求在节点 2 取到更早的时间,但 B 实际后到)。
结论:顺序必须由一个单调递增的序号表达,且必须由同一个权威点分配。
8.2 为什么不直接用全局自增 ID
- 全局自增需要单点(或号段),10 万 QPS 的消息量下会成为瓶颈与单点。
- 分片后全局自增变复杂(Snowflake 是「趋势递增」不是「严格连续」)。
- 更重要的是:跨会话的顺序没有意义。用户只关心「这个群里第 100 条和第 101 条谁先」,不关心它和另一个群的消息谁先。全局序号白白引入了耦合。
所以消息序号是会话维度的:seq 只在 conversation_id 内单调、严格连续(+1 递增,不允许空洞)。
8.3 为什么要「严格连续」——三个依赖它
- 未读数 O(1) 算出来:
unread = last_seq - last_read_seq。不需要为每个成员维护一个计数器(第九章会讲这有多重要)。 - 完整性校验:客户端收到 100、101、103,立刻知道 102 缺了,可以主动补洞。
- 分页与游标:
seq就是天然游标,where seq > cursor order by seq limit 20一次范围扫描。
如果 seq 允许空洞(比如用 Snowflake),这三个性质全部失效,未读数只能靠计数器(考虑分布式计数的一致性),补洞也无从判断「是真丢了还是本来就没有」。
8.4 seq 怎么分配才不出空洞
三种做法,按可靠性递增:
| 做法 | 优点 | 坑 |
|---|---|---|
Redis INCR(key 为会话 ID) | 极快、单线程天然原子 | 主从切换可能回退 → seq 重复 → 消息互相覆盖;拿到 seq 后进程崩溃 → 空洞 |
| 号段模式(Leaf-segment) | 减少 Redis 往返,DB 里 durable 记录 max | 号段用完没用完都会浪费(空洞) |
DB 单行原子自增(update ... set seq = seq + 1 加 select) | 绝对连续 | 单行热点,吞吐上限约几千 TPS/会话;但单个会话本来就不需要那么高 |
实战取舍很关键:会话级 seq 的热点范围很窄(一个 500 人活跃群,峰值也就每秒几十条),所以第三条路完全够用,而它同时保证了连续性与持久性。大流量会话再叠 Redis 号段(一次预取 100 个,只在极端并发时用)。
配套的两条硬规则:
- seq 分配必须与「谁持有会话」绑定:同一个
conversation_id的所有写操作路由到同一个 logic 节点(按conversation_id哈希,actor 模型 / 单线程队列串行处理),否则两个节点并发分配会乱序。 - 空洞必须有自愈机制:分配了 seq 但落库失败的记录,写一条「占位消息」进消息表(
type = placeholder,内容为空,客户端跳过不显示)。这样连续性不被破坏,未读数也不会算错——这是比「让客户端容忍空洞」更干净的做法,工业界确实这么干。
8.5 客户端侧:顺序、补洞与乱序容忍
# 客户端收到一批消息:按 seq 排序、检测空洞、必要时主动拉取
def on_batch(conv_id, msgs):
expected = local_max_seq(conv_id) + 1
for m in sorted(msgs, key=lambda x: x.seq):
if m.seq < expected: # 重复投递,幂等丢弃
continue
if m.seq > expected: # 发现空洞
schedule_gap_fill(conv_id, expected, m.seq - 1, delay=0.2)
store_and_display(conv_id, m)
expected = m.seq + 1
advance_cursor(conv_id, expected - 1)
两个细节:
- 空洞不要立刻拉,等 200ms(乱序到达的批次可能随后就到),否则每条乱序消息都触发一次请求,弱网下会把客户端打成 DDoS。这就是 Android/iOS 上「消息补拉风暴」的成因。
- 服务端要在每次推送里带
max_seq,客户端才知道「空洞是不是真的已经没有后续」。如果expected到max_seq之间拿到了所有的,就是真缺,可以放心推进游标。
九、扩散与算账:万人群的成本在哪
这是本题最能拉开差距的一节。很多人一上来就背「写扩散 vs 读扩散」,但 IM 的答案和 Feed 流完全不一样,照搬会错。
9.1 消息本身:只存一份(读扩散)
如果每条群消息都往 800 个成员的收件箱里写一份,那么 1 亿 DAU、人均 100 条、平均群规模 20 人的场景下,日写入就是 100 亿 × 20 = 2000 亿次,写入放大 20 倍,任何存储都扛不住。
所以消息只按 conversation_id 存一份,谁要读就按会话去拉。这是 IM 和 Feed 的本质区别:Feed 是「一次写、N 次读、读极其频繁且时间敏感」,IM 是「读的时机由用户点击决定,且只读最近一小段」。
9.2 会话列表:这里才是真正的写扩散
那什么会扩散?每个用户每个会话一条的「会话列表(dialog)」:
| 字段 | 含义 |
|---|---|
user_id + conversation_id | 主键(按 user_id 分片) |
last_seq / last_msg_id | 会话里最新一条消息 |
last_read_seq | 我已读到哪(未读 = last_seq - last_read_seq) |
unread_mention | @我 的未读数 |
pinned / muted / draft | 用户态 |
一条群消息进来,要更新 N 个成员 的 dialog 记录(last_seq 推进、红点变化、会话列表置顶排序)。这就是万人群真正的成本:一条消息 = 一万次 UPDATE。
算账:一个万人群,每分钟 10 条消息 → 10 × 10000 = 10 万次 UPDATE/分钟 ≈ 1700 TPS,只为一个群。100 个这样的群就是 17 万 TPS,纯写扩散直接破产。
9.3 三条压成本的杠杆
杠杆一:未读数不写,算出来。
因为 seq 严格连续,unread = last_seq - last_read_seq,所以不需要给每个成员维护一个未读计数器。只需要把 last_seq 推进一次。这一条把「一次 UPDATE 改多个字段」变成「只改一个数」,也让「已读」变成客户端上报 last_read_seq(一次写自己的人,不扩散)。这是第八节强调「严格连续」的最大回报。
杠杆二:只更新「活跃成员」。
dialog 记录不需要为「一个月没打开过这个群」的人实时维护:
- 在线成员:实时推进(走网关推送 + 批量写)。
- 7 天内说过话的活跃成员:异步批量推进(Kafka 消费,200ms 合并窗口,同一用户的多次更新合并成一次 UPDATE)。
- 长期不活跃成员:不维护
last_seq,只维护last_read_seq。等他下次打开会话时,用「服务端已知的会话max_seq−last_read_seq」现算一次,然后写回。
这就是「懒计算 + 写时修复」,把万人群的写放大从 O(成员数) 降到 O(在线数 + 活跃数),通常是一个数量级的下降(大群的日活/成员比经常只有 3-5%)。
杠杆三:大群关掉精确能力。
产品上必须让步,否则成本无解:
- 万人群不显示精确未读数,只显示「有人@我」的红点(
unread_mention只对 @ 到的少数人更新)。 - 关掉已读回执,或只在小群(500 人以下)开启——已读回执是
O(成员数)的扇出,且信息价值随规模迅速衰减。 - 关掉「谁正在输入」,或者只在 200 人以下的群开启。
面试时主动说出「这三个产品功能在大群里必须砍掉,因为它们的成本都是成员数的一次方」,比背架构图有说服力得多。
9.4 汇总:一条消息的成本
| 规模 | 扩散方式 | 单条消息写入量 |
|---|---|---|
| 单聊 | 双方 dialog 各一条 | 2 |
| 小群(500 人内) | dialog 实时 + 精确未读 + 已读回执 | 成员数 × 3 |
| 大群(万人) | dialog 只推进在线/活跃成员,未读靠算,无已读回执 | 在线数 + 活跃数(约成员数 5%) |
十、多端同步:双 seq 模型
10.1 为什么需要两个 seq
上一节讲了会话内 seq,它解决「一个会话里的消息顺序」。但客户端启动/重连时需要的是跨会话的所有变更:新会话出现、某个会话置顶变了、消息撤回、好友删除、群成员变动……
用「遍历我所有会话、每个都带一个 seq 去拉」的写法,1000 个会话就是 1000 次请求,同步一次要几秒。
所以还需要第二个序号:用户级同步序列(sync seq),每个用户一条单调递增的变更流,任何影响该用户的事件都往这条流里追加一条记录(只记「哪个会话发生了什么」,不记消息体)。
| 序号 | 维度 | 作用 |
|---|---|---|
seq | 会话 | 消息在会话内的严格顺序、补洞、未读计算 |
sync_seq | 用户 | 跨会话的变更顺序,多端增量同步的游标 |
10.2 同步协议
GET /sync?since_sync_seq=12345&limit=500
→ {
events: [
{sync_seq: 12346, type: "new_message", conv: 88, seq: 1021},
{sync_seq: 12347, type: "read", conv: 12, read_seq: 300},
{sync_seq: 12348, type: "conv_created", conv: 91}
],
next_sync_seq: 12348,
has_more: false,
server_time: 1762998000000
}
客户端拿到事件后,对「有新消息」的会话再做一次消息拉取(或直接把消息体塞进事件里省一跳,代价是事件体变大——小消息可以内联,大消息只带 conv + seq)。
三个工程细节:
sync_seq的分配必须按用户串行,否则同一用户的两个变更事件可能拿到乱序的 seq(用「用户归属分区 + 单写者」解决)。- 事件流要能裁剪:一个用户一年能产生几十万条变更事件,不能全留。裁剪规则是「合并同类事件」——同一个会话连续 10 次
read事件可以合并成最后一条;同时保留「最小可用 seq」(服务端记录每个用户已裁剪到哪,客户端游标更老就退回全量同步)。 - 全量同步是兜底:客户端游标比服务端保留点还老(比如卸载重装、长期离线),直接返回「全量快照 + 当前 sync_seq」,客户端覆盖本地。
10.3 多端已读与漫游
- 已读是「每用户」还是「每设备」?产品上通常每用户(一个端读了,其他端都变已读),因为人只有一个。但「未读数」往往是每设备(在手机上读了,PC 上可能还想看到红点)——这需要明确决策,最好统一为每用户,避免多端来回打架。
- 漫游:消息存储保留 N 天(比如 3 年),超出后可清理或降级到冷存储。多端拉取历史走同一套「按
conversation_id + seq范围扫」的接口,不需要额外机制 —— 这就是「消息只存一份」的红利。
十一、离线推送与触达
长连接一旦断开(用户切后台、网络切换、系统杀进程),服务端只能走系统级推送:
logic ──推送到「离线」用户──► push service ──► APNs(iOS) / FCM / 华为 / 小米 / OPPO / vivo
五个必须处理的点:
- 推送内容最小化:只推「你有新消息」,不推消息正文。原因有三:厂商通道不保证加密、正文会出现在锁屏(隐私)、正文可能被中间人缓存。客户端收到推送后再唤醒长连接拉取。
- 多设备多 token:一个用户可能有 5 个设备 token,每条离线消息推 5 次 → 流量和电量浪费。策略是「只推最近活跃的 1-2 个设备」(服务端记录
last_active_at),其余设备等下次启动走 pull 同步。 - 推送与长连接的竞态:客户端刚从后台回来、长连接刚建好,离线推送也刚好到达 → 同一条消息被「推送拉起 + 长连接推送」双份投递。靠
msg_id幂等去重(第七节)。 - 厂商通道的限额:国内厂商通道对「非透传消息」有每日限额(按应用评级),超了会被静默丢弃;所以高优先级消息要走「透传 + 厂商通道 + 长连接保活」多路并发,并接受「可能延迟到下次打开 App」。
- 送达不等于已读:推送成功只代表到达设备,不代表用户看到。产品上的「已读」必须由客户端上报,不能拿推送回执当已读。
十二、存储:消息表怎么设计
create table message (
conversation_id bigint not null,
seq bigint not null,
msg_id varchar(32) not null,
sender_id bigint not null,
client_msg_id varchar(40) not null,
type tinyint not null,
content blob,
created_at bigint not null,
primary key (conversation_id, seq), -- 聚簇:范围扫描即历史消息
unique key uk_msg (msg_id),
unique key uk_client (sender_id, client_msg_id) -- 幂等键
) engine=InnoDB;
四个设计决策的理由:
- 主键
(conversation_id, seq):拉最近 20 条 = 一次范围扫描 + 倒序读,无排序、无回表(如果查询列都被覆盖);拉「seq 1000 之后的所有」也是同一形状。 uk_client放在消息表而不是单独的幂等表:少一次跨表写入,重发时唯一索引冲突直接返回原seq。- 按
conversation_id分片:一个会话的所有消息在同一个分片,保证「拉一个会话」永远只打一个分片,避免跨分片归并排序。常见做法是shard = hash(conversation_id) % N。 - 不要按时间分表:按时间分表会让「拉最近 20 条」跨两张表(月末月初尤其恶心),而按会话分片天然把热点数据聚在一起。
宽表 vs KV:消息体(尤其富媒体、引用、@ 列表)是半结构化且字段频繁变化,用 content blob(Protobuf/JSON)比一堆 nullable 列更实际;需要检索的字段(type、sender_id)才提成列。
容量估算(面试必算):
| 输入 | 值 |
|---|---|
| DAU | 1 亿 |
| 人均日发消息 | 100 条 |
| 日消息量 | 100 亿条 |
| 单条平均大小(含索引与行开销) | 约 250 字节 |
| 日增原始数据 | 约 2.5 TB |
| 压缩后(列存 / LZ4,消息体重复度高) | 约 0.7-1 TB |
| 保留 3 年(按压缩后) | 约 0.8-1 PB |
结论:单一 MySQL 集群不可能承载,必须走「分布式 KV / 宽表(HBase、Cassandra、TiKV)」,MySQL 只存近 7 天热数据和会话元数据。这也是为什么很多 IM 的存储选型答案是 Cassandra 系:
Cassandra 模型:
partition key : conversation_id
clustering : seq DESC
ttl : 按会话热度分级(热会话 30 天,冷会话 3 天,全量进冷存储)
分区大小控制(Cassandra 的经典坑):一个活跃万人群一天 2 万条 × 250B ≈ 5MB,一个月就是 150MB,超过单分区 100MB 的经验上限会拖慢读。解法是加桶:partition key = (conversation_id, bucket),bucket = seq / 100000,读的时候先算桶号。
十三、故障与降级
13.1 重连风暴
网关滚动发布 / 机房网络抖动 / LB 重启,会导致大量客户端同时重连。风险点:
accept队列打满(somaxconn太小 → SYN 被丢 → 客户端重试更凶)。- TLS 握手吃满 CPU(第 3.4 节的账)。
- 重连后立刻全量同步 → 存储层瞬间打满。
四个对策:
- 客户端指数退避 + 全抖动(full jitter):
sleep = random(0, min(cap, base × 2^attempt)),把重连时间摊平到几十秒。这是 AWS 那篇著名的「Exponential Backoff and Jitter」的结论——全抖动优于等抖动优于无抖动。 - 服务端分批放行:网关返回「请 X 秒后重连」(
retry_after),主动控制接入速率。 - 同步限流:
/sync接口按用户维度限速(比如 1 次/秒),重连后不允许「全量拉」除非游标失效。 - LB 用
least_conn而不是round_robin,避免新连接全砸在同一台上;滚动发布时先摘流量、等存量连接自然排空(preStop里发「重连」指令 + 等 30 秒)。
13.2 依赖故障的降级矩阵
| 故障 | 影响 | 降级 |
|---|---|---|
| 路由表 Redis 不可用 | 无法下行推送 | 网关本地缓存最近路由(TTL 60s);消息只落库,客户端靠 pull 同步(可用性优先) |
| 消息存储主分片不可用 | 无法发消息 | 上行消息进本地磁盘队列 / Kafka 缓冲,返回「发送中」,恢复后重放(幂等键保证不重) |
sync_seq 服务不可用 | 客户端同步失败 | 退回「按会话逐个拉」(慢但可用) |
| 离线推送通道不可用 | 后台消息收不到 | 只能等长连接恢复;对时效敏感的消息(红包、转账)走短信兜底 |
降级的核心原则:任何依赖挂掉,都不能让「消息丢失」,只能让「延迟变大」。这与用户「优雅关机必须保存完数据」的偏好是一致的——宁可慢,不可丢。
十四、面试追问三连
- 为什么网关不直接查库、不回 ACK,非要绕一层 logic? —— 网关的职责是「连接管理 + 协议解析」,它的伸缩维度是「连接数」(大内存),logic 的伸缩维度是「消息 QPS」(多核)。混在一起会导致「为了扛连接而加一堆 CPU」或「为了扛 QPS 而加一堆内存」。职责分离让两者的容量模型独立,也让 logic 可以无状态滚动发布而不影响在线连接。
- 消息顺序为什么不能用服务端接收时间? —— 多节点时钟有漂移,同一毫秒内无区分度,且重试会改变「接收时间」;只有单调、严格连续的序号能同时满足「排序 + 补洞 + 未读计算」三个需求。
- 如果让你把延迟压到 50ms,你会改哪里? —— 缩短链路上的串行跳数:网关到 logic 用同机部署 / RDMA 或共享内存;落库改成「先写 WAL/本地日志即回 ACK,异步刷存储」;路由表放网关本地缓存(TTL 30s);同城双活让接收方和发送方尽量同机房;客户端侧预建连 + 连接复用。
十五、收尾
一句话总结这题:IM 的所有设计都在回答同一个问题——「怎么用最少的写放大,换到『消息一定在、顺序一定对、多端一定齐』」。 长连接解决「实时」,心跳与 TTL 解决「连接是真的活着」,先落库后 ACK 加幂等键解决「不丢不重」,会话级严格连续 seq 同时解决了「顺序、补洞、未读」三件事(这是全篇最划算的一个设计),而双 seq 模型把「多端同步」从 1000 次请求压成 1 次游标拉取;最后,万人群的成本根本不在消息存储,而在「每条消息要碰 N 个成员的会话列表」,所以压成本的三条杠杆是「未读算出来、只维护活跃成员、大群砍掉精确功能」。
明日预告:按轮换回到 AI/LLM 方向 ——「LLM 推理的 PD 分离(Prefill / Decode Disaggregation):为什么 prefill 是算力密集、decode 是显存带宽密集,两者混部为什么会互相拖累(decode 被 prefill 的长请求卡住、TTFT 与 TPOT 互相牺牲)、拆开部署之后 KV cache 怎么跨机传输(NVLink / RDMA / 分层缓存)、什么规模下拆才划算、以及 PD 分离带来的新问题:KV 传输带宽成为新瓶颈、请求二次调度、显存碎片。」
相关阅读