4.3 群消息:读扩散与写扩散怎么选

场景

群功能上线半年时,最大的群从两百人涨到五千人,随后是万人。写入侧立刻出现异象:一条群消息落库要顺带给每个成员写一条收件箱记录,万人群一次发送就是一万次写入,消息表的写入吞吐直接被群扩散吃掉。同时未读数的更新量也跟着人群规模放大:一轮群消息的未读 INCR 从几十次变成上万次。产品同学还在后面追着问:能不能给群消息加已读回执、能不能支持群禁言、能不能万人群消息必达?所有这些需求都压在扩散模式这一个决策上。

扩散模式的选择决定了群消息的写入与拉取成本结构,而且一旦上线再改成本极高:从写扩散迁到读扩散意味着重写拉取接口并做历史数据迁移,从读扩散迁到写扩散意味着要把历史会话全部补写一遍。所以这个决策必须在万人群出现之前就想清楚边界条件。

我们调研了一圈市面做法后没有照搬任何一家:开源 IM 大多默认写扩散(简单),云 IM 服务普遍按读扩散做大规模群(成本)。小团队的选择面其实更窄——没有人力维护两套方案的迁移工具,就只能走混合并把切换做在路由层。这也意味着设计和验收阶段要把两种模式各自的故障面都测一遍:写扩散怕群人数暴涨,读扩散怕热点群的高频拉取,两边都要压测过才敢上线。

方案

两种扩散模式各有代价。写扩散:一条群消息为每个成员写一条收件箱行,拉取时按用户维度一次查,写入成本随群规模线性放大;读扩散:消息只写一份到群的公共时间线,拉取时用户按自己的已读位置去群时间线扫描,写入成本恒定,拉取成本随未读量放大。

维度 写扩散 读扩散 我们的选择
一条消息写入次数 等于群人数 一次 看群规模
拉取成本 与群规模无关 与未读量相关 写扩散小群
存储放大 人数倍 无放大 读扩散大群
未读数计算 天然存在收件箱 需单独维护 写扩散简单
群规模敏感度 高 低 读扩散抗规模
历史消息清理 按人清理复杂 按群一段段删 读扩散简单

我们的最终方案是双模式混合按群规模切换:五百人以下的群走写扩散(多数群是小群,拉取体验最好,未读数天然在收件箱上聚合),五百人以上走读扩散(写入成本恒定)。路由规则按群的成员数动态判定,群成员数变化时(升破或跌破阈值)只影响新消息的扩散模式,历史数据保持原样不迁移。拉取接口做了统一封装:客户端看到的协议一样,服务端按群模式查不同的存储结构。

阈值五百这个数字不是拍脑袋。我们按当时的写入吞吐上限反推:写扩散下单条群消息的写入行数等于成员数,单实例批量插入的行速率上限约每秒两万行;如果直接平均到发送 QPS 预算上会得出错误的结论,因为群成员数是长尾分布。正确算法是看分位数:按群成员数排序,写扩散的总写入行数控制在两万行每秒以内时,能覆盖到百分之九十九的群。结论是成员数超过五百的群只占群数量的百分之一,却贡献了写入行数的四成,切到读扩散后这部分清零。你们自己的阈值要按自己的群规模分布重新算一遍,直接照抄五百没有意义。

配置与代码

写扩散的写入路径,一条消息广播给每个成员:

func (w *FanoutWriter) Write(msg *Message, members []int64) error {
    inbox := make([]InboxRow, 0, len(members))
    for _, uid := range members {
        inbox = append(inbox, InboxRow{
            UID: uid, ConvID: msg.ConvID, MsgID: msg.MsgID, Seq: msg.Seq,
        })
    }
    return w.store.BatchInsert(inbox) // 批量插入减少往返
}

批量插入要分组:单次 SQL 的参数有上限(约六万五千个占位符),万人群一次插入八列就是八万个参数,直接报错。我们的做法是按一千行一组切片,并行度设为二,太多并行会把批量写入打成对存储的冲击。成员列表本身要读缓存(群成员变更少),否则扩散的第一步就先去查一次数据库。

读扩散的拉取路径,按已读位置扫群时间线:

func (w *TimelineReader) Fetch(uid int64, groupID string, readSeq int64, limit int) ([]Message, int64, error) {
    msgs, err := w.store.ListGroupMsgAfter(groupID, readSeq, limit)
    if err != nil {
        return nil, 0, err
    }
    if len(msgs) == 0 {
        return nil, readSeq, nil // 没有新消息,原样返回游标
    }
    return msgs, msgs[len(msgs)-1].Seq, nil // 客户端按末条 Seq 回执
}

两种模式的存储边界用一张 group_mode 表记录:群 ID 加扩散模式加生效时间。读取时先查这张表(加了本地缓存,十秒过期),决定走哪条路径。表本身变更极少(只在群规模跨阈值时写一次),缓存的命中率接近百分之百。模式的判定还参考活跃度:某些大群其实只是人数多但常年不说话(比如公司全员群),对这类群读扩散的红利最明显,写入零放大。

踩坑

第一个坑是阈值切换时的消息断层。群从四百九十九人涨到五百零一人,改为读扩散后,新消息进群时间线,老消息在收件箱。用户拉取时如果游标落在旧模式的数据里,新模式的扫描查不到后续,消息看起来断了。修复是拉取接口支持双源合并:游标在旧数据范围内时先从收件箱补齐到旧模式的最后一条,再从群时间线接续新消息。合并逻辑维护了半年,等旧群数据全部沉入归档才下线。

第二个坑是读扩散的未读数。没有收件箱就没法靠行存在性算未读,只能单独维护一个 counter:群最新 Seq 减成员已读 Seq。看起来简单,坑在于群最新 Seq 与已读 Seq 的更新顺序:先减后加可能出现负数未读。我们用 Lua 脚本保证读改写的原子性,负数兜底为零并记监控告警。

第三个坑是读扩散的拉取放大。万人活跃群一轮红包雨,每个人都在高频拉取群时间线,缓存击穿上数据库。修复是群时间线的热点窗口加 Redis 缓存(最近十条按群缓存,一秒过期),加上请求合并:同一用户对同一群的拉取在一百毫秒内只放行一次,后续请求共享结果。这两条把群拉取的 QPS 削掉了七成。

第四个坑是禁言与撤回的双模式处理。写扩散下撤回要更新每个成员收件箱里的行,万人群就是一万次更新,慢且贵;读扩散下只改群时间线一行。这反而促成了一个大群治理决定:万人群一律读扩散,撤回与禁言的管理操作成本立刻可接受。

第五个坑是群成员变更的一致性。加群的一瞬间,写扩散要给他补一段历史吗?我们的规则是不补:新成员从加入时刻的 Seq 开始看,与真实社交语义一致(你加群时看不到历史群聊本身合理)。但离群成员的收件箱要立即停止写入,否则已退群的人继续收到群消息就是隐私事故。成员变更走变更消息这一条统一通路,扩散模块订阅它,见 削峰填谷:RocketMQ 异步写入 的消费链路。

第六个坑是群时间线的缓存一致性。读扩散大群加了最近十条缓存后,新消息写入要同步失效缓存,某次失效逻辑漏掉了撤回消息的更新,用户看到的最新一条是被撤回的内容。修复是把缓存的更新与群时间线的写入放进同一个回执点:写入完成立即更新缓存(而不是删缓存等下次读重建),这样缓存的内容总是与存储一致。代价是写入路径多一次 Redis 往返,但对万人群来说这一条往返买的是正确性。对照组的监控方式见 监控告警:连接数与积压。

小结

群扩散没有银弹,只有按规模分层的混合方案:小群写扩散换拉取简单,大群读扩散换写入恒定。三个关键设计:规模阈值动态切换、双源合并兜底过渡期、未读数用原子脚本维护。给后来者一句话经验:扩散模式决定成本曲线,在群规模还是两位数时就定好路线,等万人群上线再改要还的债包括迁移、合并逻辑与半年期的双写维护。监控与积压治理见 监控告警:连接数与积压。

混合扩散真正难的是迁移过程。我们的存量几百个大群早期全是写扩散,决定迁到读扩散时不能停机,做法分三步:第一步双写,新消息同时写扩散表与群时间线,存储成本短期翻倍但写入链路有兜底;第二步灰度切换读源,按群号分桶逐批把读指到群时间线,期间对账脚本每天抽样比对两个源的返回结果,不一致就告警人工裁决;第三批成功后才停掉旧写入并清理存量扩散表。整个迁移跑了将近一个月,全程用户无感,关键在于对账脚本比切换本身更重要——没有对账你可能切过去很久都不知道某个群的某段时间消息读出来是缺的,而用户已经把尴尬的聊天截图发出去了。

延伸阅读