如何设计一个支撑亿级用户的即时通讯(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 连接。

心跳机制

心跳的作用:

  1. 检测连接存活:及时发现断开的连接
  2. NAT 保活:防止 NAT 设备超时关闭映射
  3. 快速重连:检测到断开后立即重连
参数 推荐值 说明
心跳间隔 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 需要重启或下线时:

  1. 从负载均衡器中摘除该 Gateway(停止接受新连接)
  2. 向所有连接的客户端发送重连通知
  3. 客户端收到通知后,主动断开并重连到其他 Gateway
  4. 等待所有连接迁移完成(或超时后强制关闭)

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 超时:

  1. 消息存入离线消息表
  2. 通过 APNs/FCM 发送推送通知
  3. 接收方上线后,拉取离线消息
  4. 拉取成功后,清除离线消息标记

消息去重

网络不稳定时,客户端可能重复发送同一条消息。使用客户端生成的消息 ID 进行去重:

1
2
3
4
5
6
7
8
9
10
11
// 客户端生成全局唯一的消息 ID
String messageId = UUID.randomUUID().toString();

// 服务端去重
if (redis.setnx("msg:dedup:" + messageId, "1", 5, TimeUnit.MINUTES)) {
// 首次收到,正常处理
processMessage(message);
} else {
// 重复消息,忽略但返回 ACK
sendAck(message);
}

消息有序性

单会话内有序的保证:

  1. 服务端分配序列号:每个会话维护一个递增的序列号(Sequence ID)
  2. 客户端排序:按序列号排序显示消息
  3. 乱序检测:客户端检测到序列号不连续时,主动拉取缺失的消息
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;

实现流程

  1. 接收方阅读消息时,客户端发送已读确认
  2. 服务端更新已读状态表
  3. 服务端向发送方推送"已读"通知
  4. 发送方更新 UI 显示

群聊已读回执

对于群聊,需要跟踪每个成员的已读状态。采用序列号方式优化:

1
2
3
4
5
6
7
8
9
10
11
-- group_member 表已有 last_ack_seq 字段
-- 更新已读状态
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 重复或生成失败。解决方案:

  1. 时钟回拨检测:记录最后一次生成 ID 的时间戳,每次生成前检查当前时间是否大于上次时间
  2. 容忍阈值:允许 5ms 内的回拨,直接使用上次时间戳并增加序列号
  3. 回拨超过阈值
    • 丢弃当前请求,等待时钟追回
    • 使用备用机器 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 的 BitmapSet 存储在线状态:

方案一: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) {
// 首条消息,设置延迟推送(等待 3 秒看是否有更多消息)
redis.expire(pushKey, 3);
scheduler.schedule(() -> sendPushNotification(userId), 3, TimeUnit.SECONDS);
}
// 后续消息只增加计数,不重复推送

Part 10: 性能优化

消息压缩

对于文本消息,使用 Protocol BuffersMessagePack 替代 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. 消息聚合:将 1 秒内的多条消息合并为一个批次推送
  2. 分级推送:只推送给最近活跃的成员
  3. 本地缓存:客户端缓存群消息,减少拉取频率
  4. 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 + 厂商通道 折叠去重

核心设计原则

  1. 分层解耦:接入层、逻辑层、存储层独立扩展
  2. 读写分离:写扩散保证实时性,读扩散降低写入压力
  3. 最终一致性:消息投递保证 at-least-once,客户端去重
  4. 弹性伸缩:无状态服务水平扩展,有状态服务分片

参考资料