群聊设计(二):写扩散与读扩散
群消息只有一份正文,但「谁能在自己的会话列表里看到它」需要显式表达。写扩散(fan-out on write)在发送时把这层关系写进每个成员的会话行;读扩散(fan-out on read)在读取时按群维度现算。两者的差别不是风格偏好,而是写放大与读放大的直接换算,因此必须按群规模分档,而不是全局二选一。
先分清三件常被混在一起的事
- 消息正文:只有一条,存在群消息表里,是唯一权威副本;正文不复制给每个成员,否则存储放大直接等于成员数倍。
- 投递(delivery):把消息推到在线设备的连接上,靠路由表决定收件人,不产生持久化副本。
- 会话可见性:离线或未打开会话的成员,之后登录时怎么知道「这个群有新消息」。这才是写扩散与读扩散真正的分歧点。
把三者分开后,写扩散的代价可以精确表述为:每条消息要对每个成员产生一次「会话可见性」写入,而不是复制正文。
两种扩散的代价换算
假设:平均群规模 200 人、群消息 1000 条/s
写扩散:
会话行更新 = 1000 × 200 = 20 万次/s
若每次更新再走一条 MQ 消息 + 一次 Redis 未读刷新,实际放大 3 倍 ≈ 60 万次/s
写放大倍数 = 群成员数 N(正文仍只有 1 份)
读扩散:
写入 = 1000 次/s(群消息表 1 次 + 群 last_seq 1 次)
读取 = 用户打开会话时,按 gap = group.last_seq - member.last_read_seq 拉区间
读放大倍数 = 一次登录要处理的会话数(不是消息数),可用批量查询压平
结论很清楚:写扩散把成本摊在发送侧,读扩散把成本摊在读取侧。发送侧的瓶颈是存储写吞吐与热点行,读取侧的瓶颈是登录高峰的瞬时查询量,后者更容易用批量接口与缓存压住。
群规模分档的量化建议
| 群规模 | 策略 | 单条消息写放大 | 读取方式 | 取舍说明 |
|---|---|---|---|---|
| < 200 人 | 纯写扩散 | N(≤200) | 直接读自己的会话行 | 离线、未读、多端同步全部复用同一份数据,实现最简单,优先选它 |
| 200 ~ 2000 人 | 混合:在线成员写扩散 + 离线成员只留群维度 | 在线数,约 0.2N | 在线端直接读,离线端登录补拉 | 在线率通常 20% 左右,写放大降到写扩散的 1/5 |
| > 2000 人 | 读扩散 + 在线推送 | 1 | 按群位点区间拉取 | 写放大与群规模解耦,代价是登录拉取与补拉接口成为核心链路 |
分档阈值不是教条,取 200 与 2000 的依据是:200 以内的写放大(每次消息 200 次写)与落库吞吐同量级,尚可接受;超过 2000 后,一次 @全体成员就是 2000 次写,任何单库都无法在峰值下稳定。
关键流程
A 在 2000 人群发言
1) 校验成员与权限(见上一章)
2) 分配群内 seq:INCR group:seq:{gid} → 1024
3) 写群消息表 im_group_message(gid, seq, msg_id, sender, body) ← 唯一权威副本
4) 更新 im_group.last_seq = 1024
5) 按分档扩散:
小群 → 为每个成员更新 im_conversation(owner_id=uid, conv=g_{gid}).last_seq
中群 → 只为在线成员更新会话行,离线成员不写
大群 → 不更新任何成员会话行,只做在线推送
6) 路由表(routing table)查在线设备 → 逐设备下行(带 gid 与 seq)
7) 接收端按 (gid, seq) 去重、排序、本地落库
离线补拉是读扩散的核心读取路径,必须一次批量完成,不能逐群查询:
登录 / 打开会话时:
群列表 = 我加入的群(缓存或 im_group_member 索引)
gap = group.last_seq - my.last_read_seq
gap == 0 → 无新消息,跳过
0 < gap <= 200 → 按 seq 区间拉正文,合并展示
gap > 200 → 只拉最近 50 条 + 提示「还有 N 条未读」,避免一次拉爆客户端
批量接口:GET /conversations/snapshot?conv_ids=g_1001,g_1002,...&from_seq=...
一次查询返回多个会话的区间消息,把 100 次往返压成 1~2 次
逐群查询 100 个群就是 100 次网络往返,登录高峰会先把连接池打满;批量接口是读扩散能落地的前提。
多端与漫游怎么摆
| 已读位点维度 | 存储量 | 体验 | 适用 |
|---|---|---|---|
| (user, group) | 群数 × 用户数 | 一端口已读,其他端同会话也标记已读 | 微信式社交 IM,推荐 |
| (user, device, group) | 再乘设备数 | 每端独立未读,需额外的端同步协议 | 企业 IM 的审计诉求 |
写扩散天然是用户维度的(会话行只有一个 owner_id),所以多端一致不需要额外设计;读扩散下位点维度必须显式选择,否则会出现「手机上读完,电脑上还有红点」的争议。
一致性与补偿
- 成员快照竞态:扩散时用成员快照,同时以
seq为分界——消息seq < member.join_seq的不推给新成员,否则新人会收到入群前的历史消息推送。 - 扩散失败:记录
(gid, seq, 失败 uid 段)补偿任务异步重试,客户端侧的 gap 检测(序号缺口)是最后一道保险。 - 写扩散的失败语义:成员会话行更新失败不能回滚已经落库的消息;消息已落库即视为成功,会话行由补偿任务对齐。
- 消息落库失败:直接向发送者返回失败并允许重试,绝不假装成功。
极端场景与兜底
- @全体成员:在大群里等价于一次全量扩散请求,必须限流并按「只推在线」处理,具体见下一章。
- 抢红包、秒杀类消息:允许丢弃中间帧,只保证最终状态可见,不必为每条消息维持扩散完整性。
- 成员瞬间进出:判定以 seq 为界而非时间戳,避免同一秒内的加退群抖动导致重复或漏推。
- 撤回与编辑事件:同样占用 seq 并走同一条扩散链路,保证各端都能通过统一增量同步感知(见第 20 章)。
小结:群消息正文只存一份,写扩散与读扩散的差别在于「会话可见性」是否物化到每个成员;按规模分档——低于 200 人纯写扩散,200~2000 人只给在线成员写并依赖补拉,超过 2000 人完全读扩散加在线推送;读侧一律用批量快照接口与 seq 位点区间压平成本,写侧用成员快照加 seq 分界处理竞态,扩散失败靠补偿任务与客户端 gap 检测兜底。