版本:2026.03 | 面向:有实际落地需求的工程团队 | 聚焦:Kafka + Redis + MySQL 成熟可用方案
目录
- 核心认知:群聊系统的本质挑战
- 整体架构设计
- 技术选型与职责划分
- 消息发送链路详解
- 消息接收与推送链路详解
- Redis 数据模型设计
- Kafka 主题设计与消费策略
- MySQL 表结构设计
- 离线消息与未读数方案
- 消息可靠性保障
- 扩展性与性能指标
- 常见生产问题与根本解法
1. 核心认知:群聊系统的本质挑战
什么是"扇出"?为什么它是根本问题?
扇出(Fan-out) 是指"一条输入变成多条输出"的过程,就像风扇的扇叶从一个中心向外扩散。
在单聊场景中,用户 A 发消息给用户 B,服务器只需要把这条消息推送给 1 个人——扇出比是 1:1。
在群聊场景中,用户 A 在 500 人群里发消息,服务器需要把消息推给另外 499 个人——扇出比是 1:499。
单聊: 群聊(500人群):
A ──→ 服务器 ──→ B A ──→ 服务器 ──→ B1
│──→ B2
一条输入,一条输出 │──→ B3
│──→ ...
└──→ B499
一条输入,499条输出
这就是为什么群聊系统比单聊复杂得多——它的计算量随成员数线性增长。
三个核心矛盾
矛盾一:实时性 vs 可靠性
矛盾本质:消息要么"快但可能丢",要么"慢但保证到",鱼和熊掌不可兼得。
极端方案一(纯追求实时):消息直接推送,不写数据库。
- 优点:延迟极低(毫秒级)
- 缺点:用户如果在消息推送时恰好断网,消息永久丢失
极端方案二(纯追求可靠):发送消息后同步等待数据库写入确认,写完再推。
- 优点:绝对不丢消息
- 缺点:数据库写入慢(毫秒到几十毫秒),用户感知到明显卡顿
生产答案:先写 Kafka 确认,异步消费写库,客户端乐观展示
用户点击发送
│
▼
消息服务收到,写入 Kafka(10ms 左右返回)
│
▼
立即告诉客户端"已接受"(客户端 UI 立即显示消息,状态是"√")
│
▼(异步,不影响发送方)
Kafka 消费者读取消息,写入 MySQL(持久化)
Kafka 消费者同时推送给接收方
这样用户体验上感觉"立刻发出去了",数据层面消息也不会丢(Kafka 有持久化,即使推送失败,消息在 MySQL 里还在)。
矛盾二:读扩散 vs 写扩散
这是群聊系统最核心的架构决策,需要详细解释。
写扩散(Push 模式)
消息发出时,立刻为每个成员生成一条个人收件箱记录。
用户 A 发消息到 500 人群
│
▼
存储消费者收到消息
│
├── 写 inbox_A (A 的发件记录)
├── 写 inbox_B1 (B1 的收件记录)
├── 写 inbox_B2
├── ...
└── 写 inbox_B499 (总共 500 条写入)
用户 B1 打开群聊:
SELECT * FROM inbox_B1 WHERE group_id = ? ORDER BY time DESC LIMIT 50
// 直接查自己的收件箱,非常快
- 优点:读取时只查自己的表,性能极好
- 缺点:500 人群一条消息 = 500 条数据库写入,写放大严重
读扩散(Pull 模式)
消息发出时,只写一条公共消息记录。每个用户读取时自己去拉取。
用户 A 发消息到 500 人群
│
▼
存储消费者收到消息
│
└── 写 messages 表 1 条记录(仅此一条)
用户 B1 打开群聊:
SELECT * FROM messages WHERE group_id = ? AND time > B1的上次已读时间 LIMIT 50
// 查公共的消息表,过滤出自己需要的
- 优点:写入只有 1 条,无写放大
- 缺点:读取时需要查公共消息表,热点大群的消息表会成为读热点
生产答案:混合模式
| 群类型 | 成员数 | 策略 | 原因 |
|---|---|---|---|
| 小群 | < 200 人 | 写扩散 | 写放大可接受(最多 200 条),读取体验好 |
| 大群 | ≥ 200 人 | 读扩散 + 游标拉取 | 200 条以上写放大太严重,改为客户端主动拉取 |
微信、钉钉均采用此混合策略。
矛盾三:强一致性 vs 高可用性
问题场景:用户 A 和用户 B 几乎同时在同一个群发消息,谁的消息应该排在前面?
如果要强求全局有序,就需要一个"全局排队"机制,所有消息串行处理,性能极差。
生产答案:群级别有序,不要求全局有序
- 同一个群的消息,通过 Kafka 分区保证有序(见第 7 节)
- 不同群之间的消息,没有顺序要求,可以并行处理
- 客户端展示时,按消息 ID(Snowflake,天然有序)排序显示
2. 整体架构设计
完整数据流向图
用户 A 发消息(手机/浏览器)
│
│ WebSocket 长连接 或 HTTP 请求
▼
┌─────────────────────────────┐
│ 接入层 Gateway(多台) │ ← 只管连接,不懂业务
│ - 维护 WebSocket 连接池 │
│ - 无状态,可随时扩容 │
│ - 记录"我这里连着哪些用户" │
└─────────────┬───────────────┘
│ HTTP 或 gRPC(发消息请求)
▼
┌─────────────────────────────┐
│ 消息服务 Message Service │ ← 核心业务逻辑
│ - 幂等检查(防重复) │
│ - 生成 Snowflake 消息 ID │
│ - 写入 Kafka │
│ - 返回 ACK 给客户端 │
└──────┬──────────────────────┘
│ 写入 Kafka
▼
┌──────────────────────────────────────┐
│ Kafka Topic: chat-messages │ ← 消息的"中转站"
│ 分区 Key = group_id(同群消息有序) │
└──────┬───────────────────┬───────────┘
│ │
▼ ▼
┌────────────┐ ┌──────────────────────┐
│ 存储消费者 │ │ 推送消费者 │
│ 写 MySQL │ │ 查 Redis 路由 │
│ 更新未读数 │ │ → 找到目标 Gateway │
└─────┬──────┘ └──────────┬───────────┘
│ │ gRPC 推送
▼ ▼
┌──────────┐ ┌────────────────┐
│ MySQL │ │ Gateway 节点 │ ← 查内存,推送到 WebSocket
└──────────┘ └───────┬────────┘
│ WebSocket 推送
▼
用户 B(接收方)
关键设计原则(为什么这样设计)
原则 1:Gateway 只管连接,不管业务
Gateway 的唯一职责是维护 WebSocket 连接。它不知道消息内容是什么,不做任何业务逻辑判断。这样 Gateway 可以随意水平扩展(加机器),业务逻辑改动不影响 Gateway。
原则 2:Kafka 写入成功 = 消息接受成功
消息服务把消息写入 Kafka 后,立即返回 ACK 给发送方。后续的存储和推送都是异步的,不影响发送方的体验。
原则 3:推送路径必须经过 Redis 路由表
用户 A 连在 Gateway-1,用户 B 连在 Gateway-2。推送消费者要把消息推给用户 B,必须先知道 B 在哪个 Gateway 上。Redis 就是这个"地址本"。
Redis 中存储的路由信息:
user:1001:gateway = "gateway-node-01"
user:1002:gateway = "gateway-node-02"
user:1003:gateway = "gateway-node-01"
推送消费者:
1. 获取群成员列表: [1001, 1002, 1003]
2. 批量查 Redis: gateway-node-01, gateway-node-02, gateway-node-01
3. 按 Gateway 分组: {gateway-01: [1001, 1003], gateway-02: [1002]}
4. 分别向两个 Gateway 发 gRPC,让它们推送给对应用户
3. 技术选型与职责划分
三个组件的核心职责
| 组件 | 核心职责 | 不适合做的事 |
|---|---|---|
| Kafka | 消息队列削峰、保证消费顺序、解耦发送与存储/推送 | 存储历史消息、做在线状态查询 |
| Redis | 在线状态路由、未读计数、热点会话缓存、分布式锁 | 持久化消息历史、处理复杂查询 |
| MySQL | 消息持久化、历史记录查询、群成员管理 | 高频写入热点、实时状态维护 |
为什么是这三个,不是别的?
为什么用 Kafka 而非 RabbitMQ?
两个关键差异:
差异一:吞吐量
Kafka 的设计核心是顺序磁盘写入(Append-only),单节点吞吐可达百万 TPS。RabbitMQ 每条消息需要做更多状态管理,吞吐约低 5-10 倍。群聊场景消息量大,必须选 Kafka。
差异二:消息重放能力
RabbitMQ 消息生命周期:
生产者写入 → 消费者消费 → 消息删除(不可回放)
Kafka 消息生命周期:
生产者写入 → 消费者消费(消息仍然保留)
↑
可以重置 Offset 重新消费
这对于故障恢复至关重要。假设推送消费者崩溃了,重启后可以从上次的 Offset 继续消费,不会丢消息。RabbitMQ 做不到这一点。
为什么用 Redis 做路由而非直接广播?
直接广播的问题:
推送消费者 → 广播到所有 Gateway(10 台)
每台 Gateway 检查自己有没有目标用户的连接
有 → 推送
没有 → 丢弃(浪费了 9/10 的请求)
广播会产生大量无效请求,Gateway 节点越多浪费越多。
Redis 路由的方案:
推送消费者 → 查 Redis → 得到 "用户 B 在 gateway-02"
→ 只向 gateway-02 发一次 gRPC
→ 精准推送,零浪费
为什么 MySQL 不用 MongoDB?
消息历史查询的核心操作是范围查询:
-- 查某群最近 50 条消息
SELECT * FROM messages WHERE group_id = 'g_123' ORDER BY send_time DESC LIMIT 50;
-- 向上翻页(查更早的消息)
SELECT * FROM messages WHERE group_id = 'g_123' AND send_time < 1700000000 LIMIT 50;
这类查询 MySQL 的 B+ 树索引天然支持,性能极好。MongoDB 在这个场景下没有优势,反而牺牲了运维熟悉度(DBA 更熟悉 MySQL)和生态成熟度。
4. 消息发送链路详解
4.1 完整发送流程
步骤一:客户端发请求
POST /api/v1/message/send
Content-Type: application/json
{
"group_id": "g_10086",
"content": "大家好",
"client_msg_id": "cli_uuid_abc123", // 客户端自己生成的唯一ID,用于防重
"content_type": 1 // 1=文本 2=图片 3=语音 4=视频
}
步骤二:消息服务处理
a. 幂等检查(防重复发送)
Redis SETNX chat:idempotent:cli_uuid_abc123 ""
如果返回 0,说明这个 client_msg_id 处理过了
直接返回之前处理结果,不重复写 Kafka
b. 生成服务端消息 ID
调用 Snowflake 算法,生成 msg_id = 1234567890123456789
c. 构造 Kafka 消息
{
"msg_id": 1234567890123456789,
"group_id": "g_10086",
"sender_uid": 1001,
"content": "大家好",
"content_type": 1,
"send_time": 1700000000000 // 毫秒时间戳
}
写入 Kafka,Key = "g_10086"(保证同群消息有序)
d. 返回给客户端
{
"msg_id": 1234567890123456789,
"server_time": 1700000000000,
"status": "accepted" // 注意:是"已接受",不是"已发送到对方"
}
步骤三:客户端乐观展示
客户端收到 accepted 状态后,立即在 UI 上展示这条消息(状态为"√"发送中)。用户感觉消息立刻发出去了,实际上后台还在异步处理。
消息状态流转:
sending(发出请求,等待服务端响应)
↓ 服务端返回 accepted
accepted(服务端已接受,UI 显示"√")
↓ 接收方客户端回复 ACK
delivered(已送达,UI 显示"√√")
如果超时没收到响应:
failed(显示红色叹号,用户可点击重发)
4.2 Snowflake ID 详解
为什么消息 ID 不能用普通整数?
| 方案 | 问题 |
|---|---|
| 数据库自增 ID | 分库分表后,不同库的自增 ID 会重复(两个库都有 id=1) |
| UUID | 纯随机,无法按顺序排列消息,查询性能差 |
| 时间戳 | 同一毫秒内多台服务器可能生成相同时间戳 |
Snowflake 的结构(64 位整数):
二进制位:
┌─────┬──────────────────────────────────────────┬────────────┬────────────┐
│ 0 │ 时间戳(毫秒,41位) │ 机器ID(10) │ 序列号(12) │
└─────┴──────────────────────────────────────────┴────────────┴────────────┘
1位 41位可表示约 69 年(从某个起始时间算起) 1024台机器 每毫秒4096个
示例:
时间戳:2026-01-01 00:00:00.000 对应某个41位整数
机器ID:服务器编号(0-1023)
序列号:这台机器在这一毫秒内生成的第几个ID(0-4095)
生成的 ID 示例:1234567890123456789
├── 前缀是时间,所以天然有序
└── 后缀是机器+序列,所以不同机器不重复
Snowflake 的两个关键特性:
- 有序性:ID 数字越大,生成时间越晚,可以直接按 ID 排序消息
- 分布式唯一:不同机器、同一毫秒内的不同序号,组合保证全局唯一
4.3 幂等设计详解
为什么会有重复消息?
正常情况:
客户端发请求 → 服务器处理 → 返回响应 ✓
网络问题情况:
客户端发请求 → 服务器处理 → 返回响应
│
▼ 响应在网络上丢失
客户端超时,以为失败 → 重发请求
服务器收到重发请求 → 又处理了一遍 → 消息重复!
Redis SETNX 幂等去重的原理:
SETNX (SET if Not eXists) = 如果 key 不存在,则设置并返回 1;如果已存在,不设置返回 0
第一次收到 client_msg_id = "cli_abc123":
SETNX chat:idempotent:cli_abc123 "1" → 返回 1(设置成功,说明是新消息)
处理消息,写 Kafka,正常流程继续
第二次收到同一个 client_msg_id = "cli_abc123"(重传):
SETNX chat:idempotent:cli_abc123 "1" → 返回 0(key 已存在,说明处理过了)
直接返回之前的处理结果,不重复写 Kafka
设置 TTL 为 24 小时(防止 key 无限积累占内存):
EXPIRE chat:idempotent:cli_abc123 86400
5. 消息接收与推送链路详解
5.1 推送消费者工作流程详解
// 伪代码:推送消费者的核心逻辑
func handleMessage(msg KafkaMessage) {
// 1. 解析消息
groupID := msg.GroupID
senderUID := msg.SenderUID
msgID := msg.MsgID
// 2. 获取群成员列表(先查 Redis 缓存,没有再查 MySQL)
members := getGroupMembers(groupID) // 例如返回 [1001, 1002, 1003, ...]
// 3. 批量查询在线状态(关键优化:用 Pipeline 一次请求获取所有人的 Gateway 地址)
gatewayMap := redisPipelineMGET(members)
// 结果: {1001: "gateway-01", 1002: "", 1003: "gateway-01", 1004: "gateway-02"}
// 空字符串 = 用户离线
// 4. 按 Gateway 分组(避免向同一 Gateway 发多次 gRPC)
gwGroups := groupByGateway(gatewayMap)
// 结果: {"gateway-01": [1001, 1003], "gateway-02": [1004]}
// 5. 并发向各 Gateway 发 gRPC 推送(关键优化:并发而非串行)
wg := WaitGroup{}
for gw, uids := range gwGroups {
go func(gateway string, userIDs []int64) {
defer wg.Done()
grpcClient.Push(gateway, PushRequest{
UserIDs: userIDs,
Message: msg,
})
}(gw, uids)
wg.Add(1)
}
wg.Wait()
// 6. 手动提交 Offset(必须等推送完成后再提交)
kafka.CommitOffset(msg.Partition, msg.Offset)
}
5.2 Gateway 推送机制详解
Gateway 内存数据结构:
Map<uid, WebSocketSession>
例如:{
1001: <ws://连接对象>,
1003: <ws://连接对象>,
1008: <ws://连接对象>
}
收到推送消费者的 gRPC 请求后:
请求: {userIDs: [1001, 1003], message: {...}}
for uid in [1001, 1003]:
conn = connectionPool[uid] // 内存查找,微秒级
if conn != nil:
conn.Write(messageJSON) // 写入 WebSocket 缓冲区
WebSocket 连接的维护:
用户上线时:
1. 客户端建立 WebSocket 连接到 Gateway-01
2. Gateway-01 在内存 Map 中记录:connectionPool[uid] = conn
3. Gateway-01 向 Redis 写入路由:SET user:{uid}:gateway "gateway-01" EX 300
4. 每 30 秒发送心跳包,续期 Redis TTL
用户下线时(正常断开):
1. WebSocket 连接关闭事件触发
2. Gateway-01 从内存 Map 删除:delete connectionPool[uid]
3. 删除 Redis 路由:DEL user:{uid}:gateway
用户下线时(异常断开,如网络中断):
1. 心跳超时,Gateway 检测到连接已死
2. 同上执行清理
3. Redis TTL 到期(最多 5 分钟)自动过期
5.3 双向 ACK 详解
很多人分不清两种 ACK,这里用图解说明:
发送方 A 服务器 接收方 B
│ │ │
│── 发消息请求 ──────→ │ │
│ │── 写入 Kafka ──────→ │
│ ←── ACK1: accepted ──│ │(Kafka 写成功)
│ │ │
│(发送方 UI 显示"√") │ │
│ │ │
│ │── 推送消息 ──────────→│
│ │ │(接收方收到消息)
│ │ ←── ACK2: delivered ─│
│ ←── 状态更新: "√√" ──│ │
│ │ │
- ACK1(accepted):消息服务收到请求,成功写入 Kafka,返回给发送方。表示"服务器已收到你的消息"。
- ACK2(delivered):接收方客户端收到推送,主动回复服务器。表示"消息已到达接收方设备"。
- 已读回执(微信中的"已读"):接收方用户实际查看了消息后触发,需要用户主动操作,实现更复杂,一般 IM 不做这个功能。
6. Redis 数据模型设计
6.1 在线状态与路由
# ========== 用户上线时执行 ==========
# 记录该用户连接在哪个 Gateway(5分钟TTL,心跳续期)
SET user:1001:gateway "gateway-node-01" EX 300
# 加入在线用户集合(用于统计在线人数)
SADD online:users 1001
# ========== 推送消费者查询时执行 ==========
# 批量查询多个用户的 Gateway(Pipeline 模式,一次网络往返)
# 假设群成员是 [1001, 1002, 1003]
MGET user:1001:gateway user:1002:gateway user:1003:gateway
# 返回: ["gateway-01", nil, "gateway-01"]
# nil 表示用户离线(key 不存在)
# ========== 用户下线时执行 ==========
DEL user:1001:gateway
SREM online:users 1001
为什么用 MGET 而不是循环 GET?
循环 GET(慢):
客户端 ──→ GET user:1001:gateway ──→ 服务器
客户端 ←── "gateway-01" ←── 服务器 (1次往返)
客户端 ──→ GET user:1002:gateway ──→ 服务器
客户端 ←── nil ←── 服务器 (2次往返)
... (N个成员需要 N 次往返,500人群需要500次网络IO)
MGET / Pipeline(快):
客户端 ──→ MGET user:1001:gateway user:1002:gateway ... ──→ 服务器
客户端 ←── ["gateway-01", nil, "gateway-01", ...] ←── 服务器
(只需 1 次网络往返,无论多少用户)
6.2 未读消息计数
# ========== 有新消息投递给用户时 ==========
# 某用户在某群的未读数 +1
INCR unread:1001:g_10086
# 用 Hash 聚合"用户所有群的未读数",便于一次查询
HSET user:1001:unread g_10086 5 # g_10086 群有5条未读
HSET user:1001:unread g_20001 12 # g_20001 群有12条未读
# ========== 用户打开群聊时 ==========
# 清零该群未读数
SET unread:1001:g_10086 0
HSET user:1001:unread g_10086 0
# ========== 用户打开 App 首页时 ==========
# 一次获取所有群的未读数(用于显示角标)
HGETALL user:1001:unread
# 返回: {"g_10086": "0", "g_20001": "12"}
未读数允许短暂不准:
Redis 宕机重启后,未读数从 MySQL 重建(查 inbox 表中 is_read=0 的数量)。消息本身不会丢失,只是未读计数可能短暂为 0,用户打开群聊后一切恢复正常。这个代价是可以接受的。
6.3 热点会话与消息缓存
# ========== 有新消息时维护缓存 ==========
# 向列表头部插入消息(最新消息在最前面)
LPUSH group:g_10086:recent_msgs '{"msg_id":123,"content":"hello","sender":1001}'
# 只保留最近 200 条,超出的自动丢弃(裁剪列表)
LTRIM group:g_10086:recent_msgs 0 199
# ========== 用户打开群聊时 ==========
# 取最近 50 条(用于首屏展示)
LRANGE group:g_10086:recent_msgs 0 49
# 如果用户要看更早的消息,再从 MySQL 查
这个缓存的作用:用户打开群聊时,先从 Redis 快速加载最近消息显示出来,用户向上滑动翻看历史消息时再去 MySQL 查。绝大多数用户只看最近消息,MySQL 压力大大降低。
6.4 分布式锁
# SET key value NX EX seconds
# NX = Not eXists(只在 key 不存在时才设置)
# EX = 设置超时时间(防止持锁者崩溃导致锁永久不释放)
# 防止同一消息被多个消费者实例重复处理
SET lock:msg:123456789 "worker-01" NX EX 10
# 返回 OK → 加锁成功,处理消息
# 返回 nil → 锁被别人持有,放弃处理(或等待重试)
# 防止群成员列表并发更新冲突(例如同时有人加群和退群)
SET lock:group:g_10086:members "worker-02" NX EX 5
分布式锁的典型使用场景:
场景:群成员列表更新
Worker-01 读取群成员列表 Worker-02 读取群成员列表
↓ ↓
Worker-01 加入新成员 Worker-02 踢出成员
↓ ↓
Worker-01 写回 Redis Worker-02 写回 Redis(覆盖了 Worker-01 的修改!)
使用分布式锁:
Worker-01 加锁 → 读取 → 修改 → 写回 → 释放锁
Worker-02 等���锁 → 加锁 → 读取(最新数据)→ 修改 → 写回 → 释放锁
7. Kafka 主题设计与消费策略
7.1 Topic 与分区设计
什么是 Kafka 的 Topic 和 Partition?
类比:
Topic(主题)= 一个邮箱系统
Partition(分区)= 邮箱里的不同格子(槽位)
chat-messages Topic(32个分区):
┌─────────────────────────────────────────────────────┐
│ chat-messages │
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────┐ │
│ │分区 0 │ │分区 1 │ │分区 2 │...│分区31│ │
│ │[g_1的消息]│ │[g_2的消息]│ │[g_5的消息]│ │ │ │
│ └──────────┘ └──────────┘ └──────────┘ └──────┘ │
└─────────────────────────────────────────────────────┘
消息 Key = group_id
Kafka 用 hash(group_id) % 32 决定放哪个分区
效果:同一个群的所有消息都在同一个分区里,天然有序
分区数估算:
场景:假设系统峰值 TPS = 10,000 条消息/秒
单个 Kafka 分区吞吐 ≈ 10MB/s ≈ 10,000 条/秒(按每条消息 1KB 估算)
需要分区数 = 峰值TPS / 单分区吞吐 = 10,000 / 10,000 = 1 个分区
但要留余量(3倍):1 × 3 = 3 个分区
实际生产建议:初始 32 个分区(大于需求,但分区数只能增加不能减少)
7.2 消费者组设计
什么是消费者组?
Kafka 的消费者组机制:
一个 Topic 可以被多个消费者组独立消费
每个消费者组维护自己的"消费进度"(Offset)
互不干扰
chat-messages(32个分区)
│
├──────────────────────────────────────────┐
│ │
▼ ▼
storage-consumer-group(32个消费者) push-consumer-group(32个消费者)
消费者 0 → 处理分区 0 消费者 0 → 处理分区 0
消费者 1 → 处理分区 1 消费者 1 → 处理分区 1
... ...
消费者 31 → 处理分区 31 消费者 31 → 处理分区 31
两个组的 Offset 完全独立:
storage 消费到第 1000 条,push 消费到第 1002 条,互不影响
为什么消费者数量要等于分区数?
规则:一个分区在同一时刻只能被同一消费者组的一个消费者消费
分区数 = 32,消费者 = 32:完美,每人负责一个分区,最大并行度
┌──────────────────────────────┐
分区 0 ─────→ │ 消费者 0 │
分区 1 ─────→ │ 消费者 1 │
... │ ... │
分区 31 ────→ │ 消费者 31 │
└──────────────────────────────┘
分区数 = 32,消费者 = 16:并行度减半,每个消费者负责 2 个分区
分区数 = 32,消费者 = 64:有 32 个消费者空闲(没有分区分���给他们)
7.3 消费者故障恢复
手动提交 Offset 的必要性:
自动提交(危险):
消费者拉取到消息
↓
Kafka 自动提交 Offset("我已消费到这里了")
↓
消费者开始处理消息
↓
消费者崩溃!!
↓
重启后,Offset 已经提交过了
Kafka 认为这条消息已经处理,不会重发
→ 消息丢失!
手动提交(安全):
消费者拉取到消息
↓
消费者处理消息(写 MySQL / 推送到 Gateway)
↓
处理成功 → 手动提交 Offset
↓
如果中途崩溃,Offset 没有提交
重启后,Kafka 从上次提交的 Offset 重新发送
→ 消息至少被处理一次(at-least-once),不会丢失
关键配置说明:
# 关闭自动提交(改为手动)
enable.auto.commit = false
# 消费者心跳超时时间(超过这个时间没有心跳,认为消费者挂了)
session.timeout.ms = 30000
# 一次 poll 的最大等待时间(如果处理逻辑较慢,要设大一些)
max.poll.interval.ms = 300000
# 每次 poll 最多获取的消息数(控制批处理大小)
max.poll.records = 500
8. MySQL 表结构设计
8.1 消息表(分库分表)
为什么要分表?
单表问题:
假设系统每天新增 100 万条消息,一年就是 3.65 亿条
MySQL 单表在千万行以上时查询性能开始下降
B+ 树索引的深度增加,每次查询需要更多 IO
分表策略:按 group_id 取模分 256 张表
group_id = "g_10086"
表名 = messages_{hash("g_10086") % 256} = messages_42
同一个群的所有消息都在同一张表里(便于范围查询)
不同群分散到不同表(减少单表数据量)
-- 按 group_id 分表,共 256 张表
-- 表名规则: messages_{group_id % 256}
-- 以下是 messages_00 的表结构(其余 255 张结构相同)
CREATE TABLE messages_00 (
id BIGINT UNSIGNED NOT NULL, -- Snowflake ID,业务主键
group_id VARCHAR(64) NOT NULL, -- 群 ID
sender_uid BIGINT UNSIGNED NOT NULL, -- 发送者用户 ID
content TEXT NOT NULL, -- 消息内容
content_type TINYINT NOT NULL DEFAULT 1, -- 1=文本 2=图片 3=语音 4=视频
send_time BIGINT NOT NULL, -- 发送时间(毫秒时间戳)
is_deleted TINYINT NOT NULL DEFAULT 0, -- 软删除标记(0=正常 1=已删除)
PRIMARY KEY (id),
-- 核心查询索引:"查某群某时间之后的消息"
KEY idx_group_send_time (group_id, send_time),
-- 辅助索引:"查某用户发送的消息"
KEY idx_sender (sender_uid, send_time)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
为什么 send_time 用 BIGINT 而非 DATETIME?
-- DATETIME 方式(不推荐)
send_time DATETIME(3) -- 精确到毫秒
-- 存储:'2026-01-01 12:00:00.123'
-- 范围查询:WHERE send_time > '2026-01-01 00:00:00.000'
-- BIGINT 毫秒时间戳方式(推荐)
send_time BIGINT -- 存储毫秒时间戳
-- 存储:1735689600123
-- 范围查询:WHERE send_time > 1735689600000
优势:
1. 整数比较比字符串比较快
2. 与 Snowflake ID 对齐(Snowflake 前缀也是毫秒时间戳)
可以直接用 msg_id 推算出大致时间,或用时间推算出 msg_id 范围
3. 跨时区没有歧义(DATETIME 存储时涉及时区转换问题)
8.2 收件箱表(写扩散模式,小群使用)
-- 按 uid 分表,共 256 张表
-- 用户 1001 的收件记录在 inbox_{1001 % 256} = inbox_233
CREATE TABLE inbox_00 (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
uid BIGINT UNSIGNED NOT NULL, -- 收件人用户 ID
group_id VARCHAR(64) NOT NULL, -- 来自哪个群
msg_id BIGINT UNSIGNED NOT NULL, -- 关联 messages 表的 id
is_read TINYINT NOT NULL DEFAULT 0, -- 是否已读(0=未读 1=已读)
created_at BIGINT NOT NULL, -- 写入时间(毫秒时间戳)
PRIMARY KEY (id),
-- 幂等防重:同一用户的同一消息只能有一条收件记录
UNIQUE KEY uk_uid_msg (uid, msg_id),
-- 核心查询索引:"查用户在某群的消息列表"
KEY idx_uid_group_created (uid, group_id, created_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
写扩散的数据写入示意:
500 人群,用户 A (uid=1001) 发了一条消息 (msg_id=9999)
存储消费者的操作:
① 向 messages_42 写入一条消息记录(公共消息表)
② 向 inbox_233 写入:uid=1001, msg_id=9999(发送者自己的记录)
③ 向 inbox_xxx 写入:uid=1002, msg_id=9999
④ 向 inbox_xxx 写入:uid=1003, msg_id=9999
...
共 500 条 inbox 写入(写扩散)
8.3 群组表与成员表
-- 群基本信息表
CREATE TABLE `groups` (
group_id VARCHAR(64) NOT NULL, -- 群唯一 ID
group_name VARCHAR(128) NOT NULL, -- 群名称
owner_uid BIGINT UNSIGNED NOT NULL, -- 群主的 uid
member_count INT UNSIGNED NOT NULL DEFAULT 0, -- 当前成员数(冗余字段,避免频繁 COUNT)
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (group_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- 群成员表(记录"谁在哪个群")
CREATE TABLE group_members (
group_id VARCHAR(64) NOT NULL,
uid BIGINT UNSIGNED NOT NULL,
role TINYINT NOT NULL DEFAULT 0, -- 0=普通成员 1=管理员 2=群主
join_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
-- 联合主键:(group_id, uid) 唯一确定一条记录
PRIMARY KEY (group_id, uid),
-- 用于查询"这个用户加入了哪些群"(我的群列表功能)
KEY idx_uid (uid)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
两个查询方向:
-- 查某群的所有成员(推送消息时使用)
SELECT uid, role FROM group_members WHERE group_id = 'g_10086';
-- 查某用户加入的所有群(用户登录后获取群列表)
SELECT group_id FROM group_members WHERE uid = 1001;
8.4 历史消息查询(游标分页)
为什么禁止用 OFFSET 分页?
-- 危险写法(OFFSET 分页)
SELECT * FROM messages_42
WHERE group_id = 'g_10086'
ORDER BY send_time DESC
LIMIT 50 OFFSET 10000;
-- 数据库实际执行过程:
-- 1. 按索引找到该群的消息,按 send_time 排序
-- 2. 扫描前 10050 条记录
-- 3. 丢弃前 10000 条
-- 4. 返回第 10001-10050 条
-- → 越翻越慢!翻到第100页要扫描 5000 条
-- 推荐写法(游标分页)
-- 第一次加载(打开群聊)
SELECT * FROM messages_42
WHERE group_id = 'g_10086'
AND is_deleted = 0
ORDER BY send_time DESC
LIMIT 50;
-- 记录返回的最后一条消息的 send_time,作为游标
-- 向上翻页(加载更早的消息)
SELECT * FROM messages_42
WHERE group_id = 'g_10086'
AND send_time < {上次最后一条的send_time} -- 游标
AND is_deleted = 0
ORDER BY send_time DESC
LIMIT 50;
-- 原理:WHERE send_time < cursor 直接从索引定位到游标位置
-- 每次查询都是 O(log N) 的索引查找,不随翻页深度增加而变慢
9. 离线消息与未读数方案
9.1 离线消息拉取策略
为什么不能依赖 Kafka 补推离线消息?
Kafka 的消息保留策略:
默认保留 7 天,之后自动删除
场景:用户离线了 30 天
Kafka 中已经没有这 30 天的消息了
但 MySQL 里有!
所以:用户重新上线时,必须从 MySQL 拉取离线消息
用户上线的完整流程:
用户 B 重新上线
│
│ 客户端携带:"我上次在线时间 = 2026-03-01 10:00:00"
│ 或者:"我最后收到的消息 ID = 1234567890"
▼
服务器查询用户加入的所有群:
SELECT group_id FROM group_members WHERE uid = 1001;
→ [g_10086, g_20001, g_30001]
分别查每个群的离线消息:
SELECT * FROM messages_42
WHERE group_id = 'g_10086'
AND send_time > 1700000000000 -- 上次在线时间
AND is_deleted = 0
ORDER BY send_time ASC
LIMIT 200; -- 每群最多返回200条,超出显示"有N条未读"而不是全量返回
批量返回所有群的离线消息
客户端按时间排序,渲染出完整的聊天记录
清零 Redis 中该用户的未读数
为什么每群限制 200 条?
如果用户离线 1 个月,活跃大群可能有几万条消息。全部返回会导致:
- 网络传输量巨大(可能几十 MB)
- 客户端渲染卡顿
- 用户根本不会看完
实际产品做法:显示"有 1234 条未读消息",用户点击后再按需加载。
9.2 未读数的最终一致性保证
正常路径(Redis 可用时):
有新消息投递:
存储消费者 → INCR Redis unread:1001:g_10086
HSET user:1001:unread g_10086 {新值}
用户打开群聊:
SET Redis unread:1001:g_10086 0
HSET user:1001:unread g_10086 0
UPDATE MySQL inbox_xx SET is_read=1 WHERE uid=1001 AND group_id='g_10086'
异常路径(Redis 宕机重建):
SELECT COUNT(*) FROM inbox_233
WHERE uid = 1001
AND group_id = 'g_10086'
AND is_read = 0
→ 得到真实未读数,写回 Redis
10. 消息可靠性保障
10.1 可靠性等级定义
| 场景 | 可靠性要求 | 实现方式 |
|---|---|---|
| 消息存储 | 不丢失 | Kafka acks=all + MySQL 持久化 |
| 消息推送 | 至少一次 | Kafka 手动 Commit + 客户端去重 |
| 未读数 | 最终一致 | Redis 允许短暂不准,MySQL 作为 ground truth |
| 消息顺序 | 群内有序 | Kafka 同分区有序,客户端按 msg_id 排序展示 |
10.2 三层防丢失机制
第一层:Kafka Producer 配置
acks = all
// 含义:Leader 和所有 Follower 副本都写成功才返回 ACK
// 默认 acks=1 只等 Leader 写成功,Leader 崩溃时数据可能丢失
retries = 3
// 写入失败时自动重试最多 3 次
enable.idempotence = true
// Producer 端幂等:Kafka 自动去重,防止重试导致重复写入
// 启用后,每个 Producer 会分配唯一 ID,每条消息带序列号
第二层:消费者手动 Commit
enable.auto.commit = false // 禁用自动提交
// 处理流程:
for message in kafka.poll():
success = processMessage(message) // 处理消息
if success:
kafka.commitSync(message.offset) // 手动提交
else:
// 不提交,Kafka 会在下次重新发送这条消息
logError(message)
第三层:客户端幂等去重
// 客户端维护已接收的消息 ID 集合
Set<Long> receivedMsgIds = new HashSet<>();
void onMessageReceived(Message msg) {
if (receivedMsgIds.contains(msg.getMsgId())) {
return; // 重复消息,直接丢弃
}
receivedMsgIds.add(msg.getMsgId());
displayMessage(msg); // 展示消息
}
10.3 消息状态机
发送方看到的消息状态:
客户端发出请求
│
▼
┌──────────┐
│ sending │ 等待服务端 ACK
└──────────┘
│ 服务端返回 accepted 超时未收到响应
▼ ▼
┌──────────┐ ┌──────────┐
│ accepted │ UI 显示"√" │ failed │ UI 显示红色叹号
└──────────┘ └──────────┘
│ 接收方客户端回复 ACK 用户可点击重发
▼
┌───────────┐
│ delivered │ UI 显示"√√"
└───────────┘
11. 扩展性与性能指标
11.1 各组件的扩展策略
Gateway 水平扩展(无限扩展,最简单):
┌─── Gateway-01 (10万连接)
│
Nginx 负载均衡 ─┼─── Gateway-02 (10万连接)
│
└─── Gateway-03 (10万连接)
新增 Gateway-04 后:
- Nginx 配置更新,新连接会分配到 Gateway-04
- 已有连接不受影响(用户不会感知到)
- Redis 路由表自动被新连接写入
Kafka 扩展(增加 Broker,增加分区):
注意:Kafka 分区数只能增加,不能减少!
增加分区后,原来路由到某分区的群,可能路由到新分区(Hash 变了)
这会导致同一群的消息短暂出现在两个分区,消费者要处理好这个 Edge Case
建议:初始就设置足够多的分区(32个),避免频繁扩容
MySQL 扩展:
1. 读写分离
主库(Master):只处理写请求(INSERT/UPDATE)
从库(Slave):只处理读请求(SELECT)
写 → 主库 ←→ 主从复制 ←→ 从库-1
└─── 从库-2
└─── 查询请求
2. 历史消息归档
超过 6 个月的消息从热存储迁移到冷存储(对象存储 / 归档 MySQL)
3. 分库
按 group_id 范围分库:
group_id 0-1000 → MySQL-01
group_id 1001-2000 → MySQL-02
...
11.2 生产关键指标参考
| 指标 | 目标值 | 报警阈值 | 监控意义 |
|---|---|---|---|
| 消息发送 P99 延迟 | < 200ms | > 500ms | 发送链路性能 |
| 消息推送 P99 延迟 | < 500ms | > 2000ms | 端到端体验 |
| Kafka 消费 Lag | < 1000 条 | > 10000 条 | 消费者是否跟得上 |
| Redis 命令 P99 延迟 | < 5ms | > 20ms | 缓存层健康度 |
| MySQL 慢查询率 | < 1% | > 5% | 数据库性能 |
| Gateway 单节点连接数 | < 50,000 | > 80,000 | 单机负载 |
P99 延迟的含义:在所有请求中,99% 的请求在这个时间内完成。例如 P99 = 200ms 表示只有 1% 的请求超过 200ms。
12. 常见生产问题与根本解法
问题一:消息顺序错乱
现象:群内消息展示顺序与发送顺序不一致,出现"消息倒序"。
根因分析:
Timeline:
t=100ms 用户A发消息M1(msg_id=100),写入 Kafka 分区-0
t=101ms 用户B发消息M2(msg_id=101),写入 Kafka 分区-0
分区-0 内顺序:[M1, M2] ← Kafka 保证了这个顺序
但推送消费者:
线程-1 处理 M1(查路由,发 gRPC),需要 50ms
线程-2 处理 M2(查路由,发 gRPC),需要 10ms
M2 先到达客户端!M1 后到达客户端!
如果客户端直接按到达顺序展示:[M2, M1] ← 错误!
解法:
客户端收到消息后,不立即追加展示
而是插入到按 msg_id 排序的有序列表中
收到 M2(msg_id=101):列表 = [M2]
收到 M1(msg_id=100):插入到 M1 前面,列表 = [M1, M2]
渲染时按列表顺序展示:[M1, M2] ← 正确!
Snowflake ID 天然有序:msg_id 越小 = 发送时间越早
问题二:大群推送延迟飙升
现象:1000 人群发消息,部分成员 5 秒后才收到。
根因分析:
串行处理(慢):
推送消费者收到消息
↓
GET user:1001:gateway → 等待 1ms
GET user:1002:gateway → 等待 1ms
GET user:1003:gateway → 等待 1ms
...(1000次 Redis GET = 1000ms = 1秒!)
↓
gRPC 推送 gateway-01 → 等待 10ms
gRPC 推送 gateway-02 → 等待 10ms
...(500人在线 = 500次gRPC = 5000ms = 5秒!)
总计:1s + 5s = 6秒 → 太慢了
三个优化点:
优化一:Redis Pipeline 批量查询(1000次 GET → 1次网络往返)
// 伪代码
pipeline = redis.pipeline()
for uid in memberList:
pipeline.get(f"user:{uid}:gateway")
results = pipeline.execute() // 一次往返获取所有结果
// 1000ms → 5ms(提升200倍)
优化二:并发推送(500次串行gRPC → 并发)
// 伪代码
goroutines = []
for gateway, uids in groupByGateway(onlineUsers):
goroutines.append(
go: grpcClient.push(gateway, uids, message)
)
wait(goroutines) // 并发执行,耗时 = 最慢的一次gRPC(约10ms)
// 5000ms → 10ms(提升500倍)
优化三:大群改读扩散
成员 > 200 人时,不逐人推送
而是把消息 ID 写到 Redis List:
LPUSH group:g_10086:msg_queue {msg_id}
客户端定期轮询(每500ms):
GET /api/v1/group/poll?group_id=g_10086&last_msg_id=xxx
服务端返回新消息
问题三:热点群成员列表查询压垮 MySQL
现象:每条消息触发 MySQL 查群成员列表,QPS 飙升。
解法:群成员列表全量缓存到 Redis
# 用 Hash 存储群成员信息
HSET group:g_10086:members 1001 "0" # uid=1001, role=0(普通成员)
HSET group:g_10086:members 1002 "2" # uid=1002, role=2(群主)
HSET group:g_10086:members 1003 "1" # uid=1003, role=1(管理员)
# 设置1小时过期(群成员变化频率低,1小时内不会过期)
EXPIRE group:g_10086:members 3600
# 获取所有成员 UID
HKEYS group:g_10086:members
# 返回: ["1001", "1002", "1003"]
群成员变更时主动更新缓存(不等 TTL 过期):
用户 1004 加入群 g_10086:
① INSERT INTO group_members (group_id, uid) VALUES ('g_10086', 1004)
② HSET group:g_10086:members 1004 "0" ← 立即更新缓存
③ HINCRBY groups g_10086 member_count 1 ← 更新成员数
用户 1001 退出群 g_10086:
① DELETE FROM group_members WHERE group_id='g_10086' AND uid=1001
② HDEL group:g_10086:members 1001 ← 立即从缓存删除
③ HINCRBY groups g_10086 member_count -1
防缓存击穿(大量并发同时缓存失效):
// 伪代码:用分布式锁防止缓存击穿
func getGroupMembers(groupID string) []int64 {
// 先查缓存
members = redis.HKEYS(f"group:{groupID}:members")
if members != nil {
return members
}
// 缓存不存在,加锁重建
lockKey = f"lock:group:{groupID}:members"
if redis.SET(lockKey, "1", NX=true, EX=5) == "OK" {
// 获取锁成功,从 MySQL 加载
members = mysql.query("SELECT uid FROM group_members WHERE group_id=?", groupID)
// 写入 Redis
for uid in members:
redis.HSET(f"group:{groupID}:members", uid, "0")
redis.EXPIRE(f"group:{groupID}:members", 3600)
redis.DEL(lockKey)
return members
} else {
// 获取锁失败,等待 100ms 后重试
sleep(100ms)
return getGroupMembers(groupID) // 重试
}
}
问题四:消息重复推送
根因:推送消费者在推送成功后、Commit Offset 前崩溃,重启后重新处理同一条消息。
解法:客户端幂等去重(服务端的 at-least-once 是必然的,客户端去重是必须的)
// 客户端代码
const receivedMsgIds = new Set(); // 内存中维护已接收的消息ID
websocket.onmessage = (event) => {
const msg = JSON.parse(event.data);
// 检查是否重复
if (receivedMsgIds.has(msg.msg_id)) {
console.log('重复消息,忽略:', msg.msg_id);
return;
}
// 记录已接收
receivedMsgIds.add(msg.msg_id);
// 持久化到本地数据库
localDB.insertMessage(msg);
// 展示消息
renderMessage(msg);
};
问题五:Redis 宕机导致所有推送失败
解法:多层降级方案
正常状态:Redis 路由 → 精准推送
Redis 不可用时:
Level 1: Sentinel 自动切换(约 10-30s 恢复)
这段时间消息存入 Kafka,消费者暂停推送
Redis 恢复后,消费者继续消费 Kafka 中积压的消息,补推
Level 2: 广播降级(Redis 不可用 > 30s)
推送消费者切换为广播模式:
向所有 Gateway 发 gRPC,传入 "target_uid" 和消息
每个 Gateway 查自己的内存连接池判断是否需要推送
性能下降(N个Gateway都收到请求),但不中断服务
Level 3: 客户端轮询兜底
客户端检测到 WebSocket 断开后,降级为每 3 秒 HTTP 拉取:
GET /api/v1/group/poll?last_msg_id=xxx
确保消息最终到达,只是实时性稍差
附录:面试高频追问与简洁回答
Q: 为什么不直接用 WebSocket 广播,而要经过 Kafka?
A: WebSocket 广播是同步操作,发送方等待所有成员推送完成才能返回,500 人群意味着 500 次网络 IO 串行或并发阻塞。Kafka 把"接受消息"和"推送消息"解耦,发送方写 Kafka 成功即返回(毫秒级),推送异步完成,互不阻塞。同时 Kafka 提供消息持久化和重放,WebSocket 断连后的补推场景无法用纯广播解决。
Q: 消息 ID 用自增还是 UUID 还是 Snowflake?
A: Snowflake。自增依赖数据库,高并发下是瓶颈;UUID 无序,无法用于消息排序和游标分页;Snowflake 是时间戳前缀的有序 ID,不依赖数据库,单机每毫秒 4096 个 ID,完美契合消息场景。
Q: 群成员是 500 人,但只有 50 人在线,如何高效找到在线成员?
A: Redis Pipeline 批量 MGET user:{uid}:gateway,一次网络往返获取所有 500 人的网关信息,过滤出有值的(在线)50 人,再并发 gRPC 推送。批量查询避免了 500 次串行 Redis 请求。
Q: 如果 Kafka 积压了,怎么办?
A: 先判断积压原因。如果是消费者处理慢,扩容消费者实例(数量不超过分区数);如果是业务高峰期临时积压,观察 Lag 是否在下降,不需要干预;如果是消费者有 Bug 导致持续积压,修复 Bug 后考虑是否需要跳过部分历史消息(调整 Offset)。不要轻易删除 Topic 或重置 Offset,会导致消息丢失。
Q: 怎么保证消息不丢失?
A: 三层保障:Producer 端 acks=all 确保 Kafka 写入成功;消费端手动 Commit Offset 确保处理完再确认;MySQL 持久化作为最终 ground truth。推送失败(网络抖动)不算丢失,客户端重连后从 MySQL 拉取离线消息补全。
Q: 写扩散和读扩散怎么选择?
A: 看群规模。小群(< 200 人)用写扩散——发消息时为每个成员写一条收件箱记录,读取时直接查自己的收件箱,写放大可接受,读性能好。大群(≥ 200 人)用读扩散——发消息时只写一条公共记录,成员读取时主动拉取并按游标过滤,避免 200+ 条的写放大。这是微信、钉钉实际采用的混合策略。