
本文将为你详解 IM 群聊消息的写扩散技术原理以及写扩散过程中的服务端详细性能成本,同时对比了市面上IM大厂的技术方案,给出分规模架构选型与以及性能优化策略等。
某天下午,你在一个 1000 人的项目群里发了一条 7 个字的消息:「今晚九点发版」。对你来说,这是一次点击、一段 7 字符的字符串、一次"已发送"的小对勾。
但服务端为这条消息做了什么?
按写扩散(Fan-out on Write)的实现思路,最直观的版本是这样的:
仅消息副本本身就是 999 次扇出。加上路由查询、未读写入、三方推送触发,一条 7 字消息在服务端产生 3000~5000 次后端操作不是夸张。
这就是"写扩散"这个词背后的真实账单。
▲ 群消息的扇出位置(群服务是「1 → N」的关键)
群消息这个功能在工程上有两条出路:
中小 toB 项目大多选写扩散,因为 toB 场景里"已读回执 / 消息撤回 / 离线补全"是硬需求 — 没有每成员维度的"投递记录",这三个功能都立不住。
中小规模 toB IM 项目里,群消息扇出这一层通常要同时满足:
这些约束串起来,几乎一定走向写扩散。
候选 3 种:
主流选择:群服务展开。
理由是:责任边界清晰 — 群成员、群属性、群配置全在群服务,扇出本来就应该是它的活。
伪代码大致是这样:
on_group_message_arrive(msg): group = group_cache.load(msg.group_id) members = group.member_list # 1000 人 members = filter_out_sender(members, msg.from) # 999 人 for member in members: copy = clone_with_target(msg, member.uid) copy.uuid = msg.uuid + "|G" + member.short_id // 副本标识 publish_to_dispatcher(copy)
注意最后那个 uuid + "|G" + member.short_id — 这是写扩散里的"副本指纹",下游所有组件(历史库、离线、已读聚合)都靠这个区分"原消息"和"副本"。
这是最容易踩坑的设计点。3 种执行方式的对比:
中小项目早期常见的姿势是异步 MQ 逐条 — 简单、容易实现、文档好查。
但当群规模到 1000 人级别时,MQ 写入次数会成为系统瓶颈:
单条 MQ 写入耗时 ~1~3ms,999 条串行 ≈ 1~3 秒;并行能压到 100ms 内,但 broker 压力大
如果有 100 个千人群同时活跃,MQ broker 每秒要接收 ~10 万条扇出消息
改进方向:MQ 批量化。
把 999 个目标用户分成 20 批,每批 50 人装在一条 MQ 消息里:
on_group_message_arrive(msg): members = filter_out_sender(group.member_list, msg.from) for batch in chunk(members, batch_size=50): // 50 人一批 batch_msg = { "origin": msg, "targets": [m.uid for m in batch] } publish_to_dispatcher(batch_msg) // 一次 MQ 写
下游分发服务消费时再"展开"批次,对批内每个目标做路由 + 投递。MQ 写次数从 999 降到 ~20,吞吐能力提升一个数量级。
写扩散的一个隐蔽问题是:副本要不要进历史库?
该怎么选?
主流做法是双轨:

▲ 写扩散的双轨存储:历史库只有 1 条,离线盒子可能有 N 条
判断规则:

历史库消费者扫一眼 uuid 里有没有 |G 就知道丢弃还是入库。这是工程上极简但关键的判断。
写扩散场景下,"完全成功"是个奢侈品。需要承认部分失败常态,设计要点如下。
1)失败必须可重试:副本本身就是 MQ 消息,失败 = 不 ACK,broker 自动重投。
2)重试要有上限:重试 3~5 次仍失败的进死信队列,由 SRE 人工处理。
3)不让发送方感知:发送方在原消息入库时就 ACK 了("消息已发送"),扇出失败是后台事故,不回传到发送方。
4)失败可定位:每个副本带原消息 uuid + 目标 uid,死信里能精确知道"哪条消息没投到哪个人"。
伪代码:
on_dispatcher_consume(copy_msg): try: route_and_deliver(copy_msg) except RetryableError as e: if copy_msg.retry_count < 5: reject_and_requeue(copy_msg) // MQ 重投 else: send_to_dead_letter_queue(copy_msg) // 死信,SRE 处理 metrics.inc("group.fanout.dead_letter") except FatalError as e: send_to_dead_letter_queue(copy_msg)
写扩散有个无法跨越的物理上限:群越大,扇出代价越接近不可承受。
具体是:
工程上必须明确一条线:超过 N 人的群切换策略。
主流做法:

把上面 5 个设计点串起来:

▲ 群消息扇出的完整时序
中小规模 toB IM 在群扇出这一层有一条清晰的演进路径 — 但具体怎么走,要从你的产品定位、规模分布、工程团队能力倒推。下面是几个值得反复思考的点。
1)群规模上限:你的产品定位决定了上限的硬约束。如果业务场景就是大企业、需要万人群(如政府客户、大型集团内部沟通),那从 day 1 就应该认真评估读扩散方案 — 等到业务跑起来再切,技术债的复利会让人后悔。但如果只是一个中小企业协作工具,500 人甚至 1000 人上限其实没问题(参考某信群限制 500 人的判断)。先决定群规模的产品上限,再决定架构 — 这个顺序反过来就麻烦。
2)MQ 批量化:观察过的几乎所有中小规模 toB IM 项目都在 MQ broker 上付过血泪学费。Broker 的瓶颈往往不是磁盘 IO,而是连接数和单 partition 的串行化处理。批量化(batch=20~50)能让 MQ 写入次数降一个数量级,是收益明显的优化方向。但要注意批量化引入的复杂性 — 一批 50 个目标,其中 3 个失败该怎么处理?个人在中等规模 IM 项目里观察到的常见做法是"批内失败拆分重投"(把失败的 3 个重新打包成新的小批次发回 MQ)。
3)副本瘦身:副本带不带完整 payload 是个分歧点。带 payload 简单(下游消费者不需要反查原消息)但浪费带宽(千人群 1 条 1KB 消息 = 1MB 流量);不带 payload 省带宽但下游消费者要做一次 KV 反查。比"完全瘦身"更工程化的做法是分档:消息体 < 1KB 时带 payload;超过就拆 — 副本只带 uid + uuid + 摘要(首 100 字符做消息预览),下游展开时按 uuid 反查全文。这种"内嵌摘要 + 按需反查"的混合方案,既能保留消息列表的预览体验,又能控制带宽峰值。
4)可观测体系:很多中小项目的扇出层处于"出问题时翻日志、平时不看"的状态,这是个隐患 — 当你发现"群消息延迟变高"时,往往已经在客服工单里出现了。
最值得加的三个指标分别是:
这三个指标加起来,P0 故障定位时间能从"翻半小时日志"压缩到"看一眼大盘"。
5)大群临界点:如果业务真的开始需要 5000 人以上的群,参考某钉从写扩散切到读扩散的演进经验,关键经验是 — 切换不是一蹴而就的。中间过渡期通常是混合模式:千人以下继续写扩散(保留已读 / 撤回的低成本实现),千人以上的群单独走读扩散通道(客户端读消息时按 uuid 范围拉取,已读用 bitmap 聚合)。这种"按规模分流"的混合策略可以让团队渐进切换,不需要一刀切的大重构。关键是分流的判断点要明确(在群创建时就标记类型),而不是在每次消息扇出时动态判断 — 后者会让代码路径越来越复杂。
写扩散不是终点,是某个规模下的最优解。真正的工程能力,是知道什么时候该走,什么时候该停。
原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。
如有侵权,请联系 cloudcommunity@tencent.com 删除。
原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。
如有侵权,请联系 cloudcommunity@tencent.com 删除。