如何设计一个支撑亿级用户的即时通讯(IM)系统?这是系统设计面试中最经典的题目之一,也是实际工程中最复杂的分布式系统之一。本文将从需求分析出发,逐步构建一个完整的 IM 系统架构,涵盖长连接管理、消息投递、在线状态、多设备同步、群聊优化等核心问题。
Part 1: 需求分析与容量估算
功能需求
| 功能 |
优先级 |
说明 |
| 单聊 |
P0 |
一对一实时消息 |
| 群聊 |
P0 |
多人群组消息(最大 500 人) |
| 在线状态 |
P1 |
显示用户是否在线 |
| 消息已读 |
P1 |
已读/未读状态 |
| 多设备同步 |
P1 |
手机、电脑、平板同时在线 |
| 离线消息 |
P0 |
离线用户上线后拉取未读消息 |
| 消息类型 |
P0 |
文本、图片、语音、视频、文件 |
| 消息搜索 |
P2 |
全文搜索历史消息 |
| 推送通知 |
P1 |
离线时通过 APNs/FCM 推送 |
非功能需求
| 指标 |
目标 |
| DAU |
1 亿 |
| 同时在线 |
2000 万 |
| 消息延迟 |
P99 < 200ms |
| 消息可靠性 |
不丢消息(at-least-once) |
| 消息有序性 |
单会话内有序 |
| 可用性 |
99.99%(全年停机 < 53 分钟) |
容量估算
1 2 3 4 5 6 7 8 9 10 11 12 13
| DAU: 1 亿 每用户每天发送消息: 40 条 每日消息总量: 40 亿条 QPS (平均): 40 亿 / 86400 ≈ 46,000 QPS (峰值): 46,000 × 5 = 230,000
每条消息大小: ~200 字节(文本) 每日存储增量: 40 亿 × 200B = 800 GB 年存储增量: 800 GB × 365 = 292 TB
长连接数: 2000 万 每台服务器支撑连接数: ~50,000(考虑内存和 CPU) 需要的网关服务器: 2000 万 / 50,000 = 400 台
|
Part 2: 整体架构
分层架构
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19
| ┌─────────────────────────────────────────────────────────┐ │ 客户端层 │ │ iOS / Android / Web / Desktop │ └──────────────────────┬──────────────────────────────────┘ │ WebSocket / TCP ┌──────────────────────▼──────────────────────────────────┐ │ 接入层(Gateway) │ │ 长连接管理 / 协议解析 / 鉴权 / 心跳 │ └──────────────────────┬──────────────────────────────────┘ │ gRPC / HTTP ┌──────────────────────▼──────────────────────────────────┐ │ 逻辑层(Logic) │ │ 消息路由 / 会话管理 / 群组管理 / 在线状态 │ └──────────────────────┬──────────────────────────────────┘ │ ┌──────────────────────▼──────────────────────────────────┐ │ 存储层(Storage) │ │ 消息存储 / 用户数据 / 关系链 / 索引 │ └─────────────────────────────────────────────────────────┘
|
核心组件
| 组件 |
职责 |
技术选型 |
| Gateway |
长连接管理、协议解析 |
Netty / Go net |
| Logic Server |
业务逻辑处理 |
Java / Go 微服务 |
| Router |
用户→网关的路由映射 |
Redis Cluster |
| Message Queue |
异步消息投递 |
Kafka / RocketMQ |
| Message Store |
消息持久化 |
MySQL + TiDB / HBase |
| Cache |
热点数据缓存 |
Redis Cluster |
| Push Service |
离线推送 |
APNs / FCM / 厂商通道 |
| Search |
消息全文搜索 |
Elasticsearch |
Part 3: 长连接管理
WebSocket vs TCP
| 维度 |
WebSocket |
原生 TCP |
| 协议层 |
应用层(基于 HTTP 升级) |
传输层 |
| 浏览器支持 |
✅ 原生支持 |
❌ 不支持 |
| 穿透性 |
好(走 80/443 端口) |
差(可能被防火墙拦截) |
| 性能 |
略低(有帧头开销) |
最高 |
| 适用场景 |
Web 端、跨平台 |
移动端、桌面端 |
推荐方案:Web 端使用 WebSocket,移动端使用自定义 TCP 协议(如 MQTT 变体)。
连接建立流程
1 2 3 4 5 6
| 1. 客户端发送 `Sec-WebSocket-Key`(Base64 编码的随机 16 字节) 2. 服务端将 Key 与 GUID `258EAFA5-E914-47DA-95CA-C5AB0DC85B11` 拼接 3. 对拼接结果进行 SHA-1 哈希,再进行 Base64 编码得到 `Sec-WebSocket-Accept` 4. 客户端验证 Accept 值确认握手成功
这种机制确保握手过程的安全性,防止跨协议攻击。握手完成后,连接升级为全双工的 WebSocket 连接。
|
心跳机制
心跳的作用:
- 检测连接存活:及时发现断开的连接
- NAT 保活:防止 NAT 设备超时关闭映射
- 快速重连:检测到断开后立即重连
| 参数 |
推荐值 |
说明 |
| 心跳间隔 |
30-60 秒 |
移动端可根据网络状态动态调整 |
| 超时时间 |
心跳间隔 × 3 |
连续 3 次未收到心跳判定断开 |
| 智能心跳 |
动态调整 |
WiFi 下 60s,4G 下 30s,弱网下 15s |
连接路由表
每个用户的连接信息存储在 Redis 中:
1 2 3 4 5 6 7 8
| Key: user:route:{userId} Value: { "deviceId": "iPhone-xxx", "gatewayId": "gateway-01", "gatewayAddr": "10.0.1.1:8080", "connectTime": 1690000000, "platform": "iOS" }
|
多设备场景下,一个用户可能有多条连接:
1 2 3 4 5 6
| Key: user:route:{userId}:devices Value: Hash { "iPhone-xxx": "gateway-01|10.0.1.1:8080", "MacBook-yyy": "gateway-03|10.0.1.3:8080", "iPad-zzz": "gateway-02|10.0.1.2:8080" }
|
优雅关闭与连接迁移
当 Gateway 需要重启或下线时:
- 从负载均衡器中摘除该 Gateway(停止接受新连接)
- 向所有连接的客户端发送重连通知
- 客户端收到通知后,主动断开并重连到其他 Gateway
- 等待所有连接迁移完成(或超时后强制关闭)
Part 4: 消息投递模型
写扩散 vs 读扩散
| 模型 |
写入 |
读取 |
适用场景 |
| 写扩散(Fan-out on Write) |
写入每个接收者的收件箱 |
直接读取自己的收件箱 |
小群、好友列表 |
| 读扩散(Fan-out on Read) |
只写入发送者的发件箱 |
读取时聚合所有相关发件箱 |
大群、公众号 |
| 混合模型 |
小群写扩散,大群读扩散 |
按场景选择 |
生产环境推荐 |
量化分析与临界点计算:
假设:
- 群成员数:N
- 消息平均读/写比:R/W(读次数 / 写次数)
- 写扩散单次写入成本:C_w
- 读扩散单次读取成本:C_r
- 读扩散聚合 K 个发件箱的成本:C_r × K
写扩散总成本:
- 写入:C_w × N(每个成员一份)
- 读取:C_r × 1(直接读取自己的收件箱)
- 总成本:C_w × N + C_r
读扩散总成本:
- 写入:C_w × 1(仅一份)
- 读取:C_r × K(聚合 K 个发件箱)
- 总成本:C_w + C_r × K
临界点计算:
当 C_w × N + C_r = C_w + C_r × K 时,两种模型成本相等。
简化假设 C_w ≈ C_r(单次读写成本相近),则临界点为:
N ≈ K
实际应用:
- 小群(N < 200):写扩散优势明显,读取实时性好
- 大群(N > 500):读扩散更优,写入压力小
- 临界点通常在 200-500 之间,需根据实际读写比例调整
混合策略:
- 群成员数 < 200:写扩散
- 群成员数 200-500:动态选择(根据活跃度)
- 群成员数 > 500:读扩散
单聊消息投递流程
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
| 发送方 Alice 接收方 Bob │ │ │── 1. 发送消息 ──────► Gateway A │ │ │ │ │ │── 2. 转发 ──► Logic Server │ │ │ │ │ │ │ │── 3. 存储消息 ──► Message Store │ │ │ │ │ │ │── 4. 查路由 ──► Redis (Bob 在 Gateway B) │ │ │ │ │ │ │── 5. 推送 ──► Gateway B │ │ │ │── 6. 推送消息 ──►│ │ │ │ │ │ │◄── 7. ACK ─────────────│◄───────────────│ │ │ │ │ │◄── 8. ACK ──│◄─────────────────│
|
消息可靠性保证
三次握手确认机制:
1 2 3 4 5 6
| 发送方 服务端 接收方 │ │ │ │── 消息 ──────►│ │ │ │── 推送 ──────►│ │ │◄── ACK ──────│ (接收方确认收到) │◄── ACK ──────│ │ (服务端确认已投递)
|
如果接收方不在线或 ACK 超时:
- 消息存入离线消息表
- 通过 APNs/FCM 发送推送通知
- 接收方上线后,拉取离线消息
- 拉取成功后,清除离线消息标记
消息去重
网络不稳定时,客户端可能重复发送同一条消息。使用客户端生成的消息 ID 进行去重:
1 2 3 4 5 6 7 8 9 10 11
| String messageId = UUID.randomUUID().toString();
if (redis.setnx("msg:dedup:" + messageId, "1", 5, TimeUnit.MINUTES)) { processMessage(message); } else { sendAck(message); }
|
消息有序性
单会话内有序的保证:
- 服务端分配序列号:每个会话维护一个递增的序列号(Sequence ID)
- 客户端排序:按序列号排序显示消息
- 乱序检测:客户端检测到序列号不连续时,主动拉取缺失的消息
1 2
| 会话 seq 生成: Redis: INCR conversation:{convId}:seq → 返回递增的序列号
|
消息已读回执实现方案
设计目标:
- 精确跟踪每条消息的已读状态
- 支持单聊和群聊场景
- 低延迟、高并发
单聊已读回执:
1 2 3 4 5 6 7 8 9 10
| CREATE TABLE message_read_status ( id BIGINT AUTO_INCREMENT PRIMARY KEY, message_id VARCHAR(64) NOT NULL COMMENT '消息 ID', conversation_id BIGINT NOT NULL COMMENT '会话 ID', sender_id BIGINT NOT NULL COMMENT '发送者', receiver_id BIGINT NOT NULL COMMENT '接收者', read_time BIGINT NOT NULL COMMENT '已读时间戳', PRIMARY KEY (message_id, receiver_id), INDEX idx_conv (conversation_id, receiver_id) ) ENGINE=InnoDB;
|
实现流程:
- 接收方阅读消息时,客户端发送已读确认
- 服务端更新已读状态表
- 服务端向发送方推送"已读"通知
- 发送方更新 UI 显示
群聊已读回执:
对于群聊,需要跟踪每个成员的已读状态。采用序列号方式优化:
1 2 3 4 5 6 7 8 9 10 11
|
UPDATE group_member SET last_ack_seq = {maxSeq} WHERE group_id = {groupId} AND user_id = {userId};
SELECT COUNT(*) FROM group_member WHERE group_id = {groupId} AND last_ack_seq >= {messageSeq};
|
批量已读优化:
- 客户端批量上报已读序列号(如每 5 秒或每 10 条消息)
- 减少网络请求和数据库写入
- 服务端合并更新
已读回执推送策略:
- 单聊:实时推送
- 小群:实时推送(可配置)
- 大群:不推送或仅推送"已读人数变化"
Part 5: 消息存储设计
消息表设计
消息内容表(写扩散模型下的收件箱):
1 2 3 4 5 6 7 8 9 10 11 12 13 14
| CREATE TABLE message_inbox ( id BIGINT AUTO_INCREMENT PRIMARY KEY, owner_id BIGINT NOT NULL COMMENT '收件箱所有者', conversation_id BIGINT NOT NULL COMMENT '会话 ID', message_id VARCHAR(64) NOT NULL COMMENT '消息唯一 ID', sender_id BIGINT NOT NULL COMMENT '发送者', content_type TINYINT NOT NULL COMMENT '消息类型:1文本 2图片 3语音', content TEXT NOT NULL COMMENT '消息内容(或 URL)', seq_id BIGINT NOT NULL COMMENT '会话内序列号', send_time BIGINT NOT NULL COMMENT '发送时间戳', status TINYINT DEFAULT 0 COMMENT '0未读 1已读 2撤回', INDEX idx_owner_conv (owner_id, conversation_id, seq_id), UNIQUE INDEX uk_msg_id (message_id, owner_id) ) ENGINE=InnoDB;
|
分库分表策略
按 owner_id 分库分表:
1 2 3
| 分库:owner_id % 64 → 64 个库 分表:owner_id % 64 / 8 → 每库 8 张表 总计:64 × 8 = 512 张表
|
优势:同一个用户的所有消息在同一个库中,查询不需要跨库。
消息 ID 生成
使用**雪花算法(Snowflake)**生成全局唯一、趋势递增的消息 ID:
1 2 3
| ┌─────────┬──────────────┬────────────┬──────────────┐ │ 符号位(1)│ 时间戳(41) │ 机器ID(10) │ 序列号(12) │ └─────────┴──────────────┴────────────┴──────────────┘
|
- 41 位时间戳:支持约 69 年
- 10 位机器 ID:支持 1024 台机器
- 12 位序列号:每毫秒每台机器 4096 个 ID
时钟回拨问题及解决方案:
分布式环境中,服务器时钟可能发生回拨(NTP 同步、人工调整等),导致 ID 重复或生成失败。解决方案:
- 时钟回拨检测:记录最后一次生成 ID 的时间戳,每次生成前检查当前时间是否大于上次时间
- 容忍阈值:允许 5ms 内的回拨,直接使用上次时间戳并增加序列号
- 回拨超过阈值:
- 丢弃当前请求,等待时钟追回
- 使用备用机器 ID 生成 ID
- 报警并人工介入
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32
| public synchronized long nextId() { long currentTimestamp = timeGen(); if (currentTimestamp < lastTimestamp) { long offset = lastTimestamp - currentTimestamp; if (offset <= 5) { currentTimestamp = tilNextMillis(lastTimestamp); } else { throw new RuntimeException("Clock moved backwards"); } } if (currentTimestamp == lastTimestamp) { sequence = (sequence + 1) & SEQUENCE_MASK; if (sequence == 0) { currentTimestamp = tilNextMillis(lastTimestamp); } } else { sequence = 0; } lastTimestamp = currentTimestamp; return ((currentTimestamp - EPOCH) << TIMESTAMP_SHIFT) | (workerId << WORKER_ID_SHIFT) | sequence; }
|
大文件消息处理
图片、语音、视频等大文件不直接存储在消息表中:
1 2 3 4
| 1. 客户端上传文件到 OSS/S3 2. 获取文件 URL 3. 将 URL 作为消息内容发送 4. 接收方根据 URL 下载文件
|
消息内容示例:
1 2 3 4 5 6 7 8
| { "type": "image", "url": "https://oss.example.com/msg/2025/07/29/abc123.jpg", "thumbnail": "https://oss.example.com/msg/2025/07/29/abc123_thumb.jpg", "width": 1920, "height": 1080, "size": 2048576 }
|
Part 6: 群聊设计
小群 vs 大群
| 维度 |
小群(< 200 人) |
大群(200-2000 人) |
超大群/频道 |
| 写入模型 |
写扩散 |
读扩散 |
读扩散 |
| 消息存储 |
每人一份 |
群共享一份 |
群共享一份 |
| 在线推送 |
逐个推送 |
批量推送 |
仅推送活跃用户 |
| 离线推送 |
全部推送 |
仅 @我 和重要消息 |
不推送 |
群消息投递流程(写扩散)
1 2 3 4 5 6 7 8 9 10 11 12 13 14
| 发送方 Logic Server Gateway 集群 │ │ │ │── 群消息 ────────────►│ │ │ │── 查群成员列表 ──► DB │ │ │◄── 成员列表 ────────────│ │ │ │ │ │── 写入每个成员的收件箱 ──► Message Store │ │ │ │ │── 查在线成员路由 ──► Redis│ │ │◄── 路由信息 ────────────│ │ │ │ │ │── 批量推送 ────────────►│── 推送给在线成员 │ │ │ │ │── 离线推送 ────────────► Push Service
|
群消息投递流程(读扩散)
1 2 3 4 5 6 7 8 9 10 11 12 13
| 发送方 Logic Server Message Store │ │ │ │── 群消息 ────────────►│ │ │ │── 写入群时间线 ─────────►│ │ │ │ │ │── 通知在线成员 ──► Gateway 集群 │ │ (仅发送通知,不发消息体) │ │ │ 接收方 │ │── 拉取新消息 ────────►│ │ │ │── 查询群时间线 ─────────►│ │ │◄── 返回新消息 ──────────│ │◄── 返回消息 ──────────│ │
|
群成员管理
1 2 3 4 5 6 7 8 9 10 11
| CREATE TABLE group_member ( group_id BIGINT NOT NULL, user_id BIGINT NOT NULL, role TINYINT DEFAULT 0 COMMENT '0普通 1管理员 2群主', nickname VARCHAR(64) COMMENT '群内昵称', mute_until BIGINT DEFAULT 0 COMMENT '禁言截止时间', join_time BIGINT NOT NULL, last_ack_seq BIGINT DEFAULT 0 COMMENT '最后确认的消息序列号', PRIMARY KEY (group_id, user_id), INDEX idx_user (user_id) ) ENGINE=InnoDB;
|
last_ack_seq 用于计算未读消息数:未读数 = 群最新 seq - last_ack_seq。
Part 7: 在线状态服务
设计挑战
- 2000 万同时在线用户的状态需要实时维护
- 每个用户的好友列表平均 200 人
- 状态变更需要通知所有在线好友
状态存储
使用 Redis 的 Bitmap 或 Set 存储在线状态:
方案一:Bitmap
1 2 3 4 5 6 7 8 9 10 11 12
| Key: online:bitmap Bit offset: userId Value: 1 (在线) / 0 (离线)
SETBIT online:bitmap {userId} 1
GETBIT online:bitmap {userId}
BITCOUNT online:bitmap
|
优势:内存极省(1 亿用户只需 ~12.5 MB)。
劣势:无法存储额外信息(如在线设备、最后活跃时间)。
方案二:Hash(推荐)
1 2 3 4 5 6 7 8 9
| Key: online:status:{userId % 1000} Field: {userId} Value: {"platform": "iOS", "lastActive": 1690000000, "status": "online"}
// 设置在线 HSET online:status:{userId % 1000} {userId} '{"platform":"iOS",...}'
// 查询 HGET online:status:{userId % 1000} {userId}
|
状态变更通知
拉模型(推荐):
- 客户端打开聊天列表时,批量查询好友的在线状态
- 定期轮询(如每 30 秒)更新状态
- 减少服务端推送压力
推模型:
- 用户上线/下线时,通知所有在线好友
- 实时性好,但对于好友多的用户,通知量巨大
- 适合小规模场景
混合模型(生产推荐):
- 亲密好友(最近聊天的 Top 20)使用推模型
- 其他好友使用拉模型
- 大群不推送成员状态变更
Part 8: 多设备同步
同步模型
每个设备维护一个同步位点,表示该设备已同步到的最新消息序列号:
1 2 3 4 5 6 7 8
| 用户 Alice 的设备同步状态: ┌──────────┬──────────────┬──────────────┐ │ 设备 │ 同步位点 │ 未同步消息数 │ ├──────────┼──────────────┼──────────────┤ │ iPhone │ seq = 1000 │ 0 │ │ MacBook │ seq = 998 │ 2 │ │ iPad │ seq = 990 │ 10 │ └──────────┴──────────────┴──────────────┘
|
同步流程
1 2 3 4 5 6 7 8 9 10 11 12
| 设备上线或切换到前台时: 1. 上报当前同步位点 (last_sync_seq) 2. 服务端返回 last_sync_seq 之后的所有消息 3. 设备更新同步位点
增量同步: GET /sync?last_seq=998&limit=100 Response: { "messages": [...], // seq 999, 1000 的消息 "has_more": false, "latest_seq": 1000 }
|
消息在多设备间的互斥操作
部分操作需要在所有设备间同步:
| 操作 |
同步方式 |
| 已读状态 |
一个设备标记已读,其他设备同步 |
| 消息撤回 |
所有设备删除该消息 |
| 会话置顶/免打扰 |
同步到所有设备 |
| 草稿 |
可选同步 |
Part 9: 推送通知
离线推送架构
1 2 3 4 5 6
| Logic Server ──► Push Service ──┬── APNs (iOS) ├── FCM (Android 海外) ├── 小米推送 ├── 华为推送 ├── OPPO 推送 └── vivo 推送
|
推送策略
| 场景 |
策略 |
| 单聊消息 |
立即推送 |
| 小群消息 |
立即推送(可设置免打扰) |
| 大群消息 |
仅 @我 时推送 |
| 系统通知 |
合并推送(每 5 分钟最多 1 条) |
| 频繁消息 |
折叠推送(“张三发来 5 条消息”) |
推送去重与折叠
1 2 3 4 5 6 7 8 9
| String pushKey = "push:pending:" + userId; Long pendingCount = redis.incr(pushKey); if (pendingCount == 1) { redis.expire(pushKey, 3); scheduler.schedule(() -> sendPushNotification(userId), 3, TimeUnit.SECONDS); }
|
Part 10: 性能优化
消息压缩
对于文本消息,使用 Protocol Buffers 或 MessagePack 替代 JSON:
| 格式 |
大小 |
编解码速度 |
| JSON |
100% |
基准 |
| Protocol Buffers |
~30% |
3-5x 快 |
| MessagePack |
~50% |
2-3x 快 |
连接复用与批量操作
1 2 3 4 5 6 7 8 9
| // 批量拉取多个会话的未读消息 POST /batch/sync { "syncs": [ {"conversation_id": 1001, "last_seq": 100}, {"conversation_id": 1002, "last_seq": 200}, {"conversation_id": 1003, "last_seq": 50} ] }
|
热点群优化
对于万人群或频道:
- 消息聚合:将 1 秒内的多条消息合并为一个批次推送
- 分级推送:只推送给最近活跃的成员
- 本地缓存:客户端缓存群消息,减少拉取频率
- CDN 加速:群公告、群文件通过 CDN 分发
数据库读写分离
1 2
| 写入路径:客户端 → Gateway → Logic → Master DB 读取路径:客户端 → Gateway → Logic → Slave DB / Cache
|
消息写入后,先更新 Redis 缓存,再异步写入数据库。读取时优先从缓存读取。
Part 11: 安全与合规
端到端加密(E2EE)
1 2 3 4 5 6 7 8 9
| 发送方 接收方 │ │ │── 1. 生成密钥对 (公钥A, 私钥A) ──────────────────│ │ │── 2. 生成密钥对 (公钥B, 私钥B) │── 3. 交换公钥 ──────────────────────────────────►│ │◄── 4. 交换公钥 ──────────────────────────────────│ │ │ │── 5. 用公钥B加密消息 ──► 服务端(密文) ──► 用私钥B解密 ──►│ │ (无法解密) │
|
服务端只传输密文,无法读取消息内容。Signal Protocol 是目前最广泛使用的 E2EE 协议。
消息审计与合规
对于企业 IM,需要支持:
- 消息存档:所有消息保留指定时间(如 7 年)
- 关键词过滤:实时检测敏感内容
- 审计日志:记录所有管理操作
总结
| 模块 |
核心技术 |
关键指标 |
| 接入层 |
WebSocket/TCP + Netty |
单机 5 万连接 |
| 消息投递 |
写扩散 + 读扩散混合 |
P99 < 200ms |
| 消息存储 |
MySQL 分库分表 + Redis 缓存 |
日增 800GB |
| 群聊 |
小群写扩散,大群读扩散 |
支持 2000 人群 |
| 在线状态 |
Redis Bitmap/Hash |
2000 万在线 |
| 多设备同步 |
同步位点 + 增量拉取 |
秒级同步 |
| 推送 |
APNs/FCM + 厂商通道 |
折叠去重 |
核心设计原则:
- 分层解耦:接入层、逻辑层、存储层独立扩展
- 读写分离:写扩散保证实时性,读扩散降低写入压力
- 最终一致性:消息投递保证 at-least-once,客户端去重
- 弹性伸缩:无状态服务水平扩展,有状态服务分片
参考资料