群聊设计(二):写扩散与读扩散

群消息只有一份正文,但「谁能在自己的会话列表里看到它」需要显式表达。写扩散(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 检测兜底。

笔记加载中…