IM千人群聊该不该用消息写扩散?详细算算成本账!
本文作者拉丁解牛说技术有修订和改动。1、引言本文将为你详解 IM 群聊消息的写扩散技术原理以及写扩散过程中的服务端详细性能成本同时对比了市面上IM大厂的技术方案给出分规模架构选型与以及性能优化策略等。2、技术背景某天下午你在一个 1000 人的项目群里发了一条 7 个字的消息「今晚九点发版」。对你来说这是一次点击、一段 7 字符的字符串、一次已发送的小对勾。但服务端为这条消息做了什么按写扩散Fan-out on Write的实现思路最直观的版本是这样的仅消息副本本身就是 999 次扇出。加上路由查询、未读写入、三方推送触发一条 7 字消息在服务端产生 3000~5000 次后端操作不是夸张。这就是写扩散这个词背后的真实账单。▲ 群消息的扇出位置群服务是「1 → N」的关键3、群聊消息为什么要做扇出群消息这个功能在工程上有两条出路中小 toB 项目大多选写扩散因为 toB 场景里已读回执 / 消息撤回 / 离线补全是硬需求 — 没有每成员维度的投递记录这三个功能都立不住。4、中小规横IM项目的典型设计目标中小规模 toB IM 项目里群消息扇出这一层通常要同时满足1实时性在线成员从发送到收到 P95 100ms2可靠性离线成员上线后必须能补到这条消息3可观测每个副本的投递状态可追踪用于 SRE 排障4业务能力支撑已读回执、消息撤回、漫游、未读统计5规模单群默认 1000 人少数业务场景 3000~5000 人。这些约束串起来几乎一定走向写扩散。5、写扩散的消息副本生成时机候选 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 — 这是写扩散里的副本指纹下游所有组件历史库、离线、已读聚合都靠这个区分原消息和副本。6、扇出执行方式这是最容易踩坑的设计点。3 种执行方式的对比中小项目早期常见的姿势是异步 MQ 逐条 — 简单、容易实现、文档好查。但当群规模到 1000 人级别时MQ 写入次数会成为系统瓶颈单条 MQ 写入耗时 ~1~3ms999 条串行 ≈ 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_size50): // 50 人一批batch_msg {origin: msg,targets: [m.uid for m in batch]}publish_to_dispatcher(batch_msg) // 一次 MQ 写下游分发服务消费时再展开批次对批内每个目标做路由 投递。MQ 写次数从 999 降到 ~20吞吐能力提升一个数量级。7、副本指纹与双轨存储写扩散的一个隐蔽问题是副本要不要进历史库该怎么选1进千人群 1 条消息进历史库 999 条分表空间爆炸2不进那历史消息只存原消息未读 / 已读怎么对账主流做法是双轨▲ 写扩散的双轨存储历史库只有 1 条离线盒子可能有 N 条判断规则历史库消费者扫一眼 uuid 里有没有 |G 就知道丢弃还是入库。这是工程上极简但关键的判断。8、写扩散场景下的失败处理写扩散场景下完全成功是个奢侈品。需要承认部分失败常态设计要点如下。1失败必须可重试副本本身就是 MQ 消息失败 不 ACKbroker 自动重投。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)9、按群规模进行分档写扩散有个无法跨越的物理上限群越大扇出代价越接近不可承受。具体是1100 人群100 次副本舒服21000 人群999 次副本已经要做批量化35000 人群4999 次副本需要专门优化策略410000 人群写扩散根本走不通。工程上必须明确一条线超过 N 人的群切换策略。主流做法10、群消息扇出的时序流程把上面 5 个设计点串起来▲ 群消息扇出的完整时序11、主流大厂的大群消息投递方案不同体量的 IM 系统在这一层的设计差异极大。写扩散不是唯一解 — 当规模到某个临界点读扩散反而是更合理的选择。11.1 某钉IM钉IM最早也走写扩散但在万人群场景下扛不住演进为读扩散为主 推拉结合 多级缓存。相关资料请详读《深度解密钉钉即时消息服务DTIM的技术设计》一文中的“4.3、存储模型设计”章节核心思路1群消息只存一份不再为每个成员复制2成员读群时按订阅关系拉取3实时性靠近期消息推送 历史消息客户端按需拉取补足4多级缓存万人群成员缓存让反查群成员快到毫秒级。▲ 最终他们用智能限流和万人群成员多级缓存来支撑超级大群的运营11.2 某信IM架构在群消息存一份还是存多份的存储架构权衡上相关高级研发工程师在技术博客分享中提到采用写扩散路线详见《微信直播聊天室单房间1500万在线的消息架构演进之》一文中的“5、消息扩散方案选型读扩散”章节。即每条群消息按成员数复制成多份存储换取读路径的极致简单撤回、漫游、未读计数都只需操作单成员的消息流。代价是写放大与存储成本随群规模线性上涨。11.3 企某信的IM架构企某信和总所周知的微信是两个系统企某信的设计目标是支持万人群 已读回执。它的做法是仍然走写扩散但投入更多硬件资源 各种局部优化批量、缓存、异步来支撑万人群的扇出成本。相关资料可详读《企业微信的IM架构设计揭秘》一文中的“6、整体架构设计3消息扩散写”章节11.4 总结对比12、性能优化思考中小规模 toB IM 在群扇出这一层有一条清晰的演进路径 — 但具体怎么走要从你的产品定位、规模分布、工程团队能力倒推。下面是几个值得反复思考的点。1群规模上限你的产品定位决定了上限的硬约束。如果业务场景就是大企业、需要万人群如政府客户、大型集团内部沟通那从 day 1 就应该认真评估读扩散方案 — 等到业务跑起来再切技术债的复利会让人后悔。但如果只是一个中小企业协作工具500 人甚至 1000 人上限其实没问题参考微信群限制 500 人的判断。先决定群规模的产品上限再决定架构 — 这个顺序反过来就麻烦。2MQ 批量化观察过的几乎所有中小规模 toB IM 项目都在 MQ broker 上付过血泪学费。Broker 的瓶颈往往不是磁盘 IO而是连接数和单 partition 的串行化处理。批量化batch20~50能让 MQ 写入次数降一个数量级是收益明显的优化方向。但要注意批量化引入的复杂性 — 一批 50 个目标其中 3 个失败该怎么处理个人在中等规模 IM 项目里观察到的常见做法是批内失败拆分重投把失败的 3 个重新打包成新的小批次发回 MQ。3副本瘦身副本带不带完整 payload 是个分歧点。带 payload 简单下游消费者不需要反查原消息但浪费带宽千人群 1 条 1KB 消息 1MB 流量不带 payload 省带宽但下游消费者要做一次 KV 反查。比完全瘦身更工程化的做法是分档消息体 1KB 时带 payload超过就拆 — 副本只带 uid uuid 摘要首 100 字符做消息预览下游展开时按 uuid 反查全文。这种内嵌摘要 按需反查的混合方案既能保留消息列表的预览体验又能控制带宽峰值。4可观测体系很多中小项目的扇出层处于出问题时翻日志、平时不看的状态这是个隐患 — 当你发现群消息延迟变高时往往已经在客服工单里出现了。最值得加的三个指标分别是a单条群消息的扇出 RT P99衡量端到端时长是不是有慢扇出b副本投递失败率按时间窗口分桶衡量是否有阶段性故障c批内部分失败率如果做了批量化衡量批是不是被少数失败拖累整批。这三个指标加起来P0 故障定位时间能从翻半小时日志压缩到看一眼大盘。5大群临界点如果业务真的开始需要 5000 人以上的群参考某钉从写扩散切到读扩散的演进经验关键经验是 — 切换不是一蹴而就的。中间过渡期通常是混合模式千人以下继续写扩散保留已读 / 撤回的低成本实现千人以上的群单独走读扩散通道客户端读消息时按 uuid 范围拉取已读用 bitmap 聚合。这种按规模分流的混合策略可以让团队渐进切换不需要一刀切的大重构。关键是分流的判断点要明确在群创建时就标记类型而不是在每次消息扇出时动态判断 — 后者会让代码路径越来越复杂。写扩散不是终点是某个规模下的最优解。真正的工程能力是知道什么时候该走什么时候该停。13、参考资料[1] IM单聊和群聊中的在线状态同步应该用“推”还是“拉”[2] IM群聊消息如此复杂如何保证不丢不重[3] 移动端IM中大规模群消息的推送如何保证效率、实时性[4] 现代IM系统中聊天消息的同步和存储方案探讨[5] 关于IM即时通讯群聊消息的乱序问题讨论[6] IM群聊消息的已读回执功能该怎么实现[7] IM群聊消息究竟是存1份(即扩散读)还是存多份(即扩散写)[8] 一套高可用、易伸缩、高并发的IM群聊、单聊架构方案设计实践[10] IM群聊机制除了循环去发消息还有什么方式如何优化[11] 云信技术分享IM中的万人群聊技术方案实践总结[12] 钉钉技术分享企业级IM王者——钉钉在后端架构上的过人之处[13] IM群聊消息的已读未读功能在存储空间方面的实现思路探讨[15] 企微的IM架构设计揭秘消息模型、万人群、已读回执、消息撤回等[16] 融云IM技术分享万人群聊消息投递方案的思考和实践[18] 海量用户IM聊天室的架构设计与实践[20] 支持百万人超大群聊的Web端IM架构设计与实践[21] IM千人群聊该不该用消息写扩散详细算算成本账即时通讯技术学习- 移动端IM开发入门文章《新手入门一篇就够从零开发移动端IM》- 开源IM框架源码https://github.com/JackJiang2011/MobileIMSDK备用地址点此本文同步发布于 http://www.52im.net/thread-4915-1-1.html