群聊实时聊天系统生产落地指南

2 阅读37分钟

版本:2026.03 | 面向:有实际落地需求的工程团队 | 聚焦:Kafka + Redis + MySQL 成熟可用方案


目录

  1. 核心认知:群聊系统的本质挑战
  2. 整体架构设计
  3. 技术选型与职责划分
  4. 消息发送链路详解
  5. 消息接收与推送链路详解
  6. Redis 数据模型设计
  7. Kafka 主题设计与消费策略
  8. MySQL 表结构设计
  9. 离线消息与未读数方案
  10. 消息可靠性保障
  11. 扩展性与性能指标
  12. 常见生产问题与根本解法

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) │
└─────┴──────────────────────────────────────────┴────────────┴────────────┘
 141位可表示约 69 年(从某个起始时间算起)     1024台机器   每毫秒4096个

示例:
时间戳:2026-01-01 00:00:00.000 对应某个41位整数
机器ID:服务器编号(0-1023)
序列号:这台机器在这一毫秒内生成的第几个ID(0-4095)

生成的 ID 示例:1234567890123456789
                ├── 前缀是时间,所以天然有序
                └── 后缀是机器+序列,所以不同机器不重复

Snowflake 的两个关键特性

  1. 有序性:ID 数字越大,生成时间越晚,可以直接按 ID 排序消息
  2. 分布式唯一:不同机器、同一毫秒内的不同序号,组合保证全局唯一

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"  → 返回 0key 已存在,说明处理过了)
直接返回之前的处理结果,不重复写 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-group32个消费者)    push-consumer-group32个消费者)
  消费者 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+ 条的写放大。这是微信、钉钉实际采用的混合策略。