当前位置: 首页 > news >正文

消息“绝对送达”与“只送一次”:Kafka 在亿级 IM 系统里的顺序与幂等实战

消息“绝对送达”与“只送一次”:Kafka 在亿级 IM 系统里的顺序与幂等实战

这不是一篇停留在参数调优层面的 Kafka 文章,而是一份面向亿级 IM 系统的生产实践总结。我们关注的不是“Kafka 能不能发消息”,而是当系统进入高并发、跨机房、故障频发、流量突增的真实世界后,如何尽可能接近业务视角下的“绝对送达”与“只送一次”。


1. 先把结论说清楚:不存在无成本的“绝对送达”

在即时通讯系统里,产品、运营甚至很多工程师都会提一个看似合理的要求:

  • 消息绝不能丢
  • 消息绝不能重
  • 消息顺序绝不能乱
  • 系统还必须低延迟、高并发、可水平扩展

遗憾的是,在分布式系统里,这四件事无法靠一句“开启 Kafka exactly-once”同时免费获得。

更准确地说:

  • Kafka 能保证的是 Broker 语义层面的有序、复制、事务与幂等写入能力
  • IM 需要的是业务语义层面的送达、顺序、去重、可恢复、可追踪
  • “绝对送达”本质上不是一个中间件参数,而是一整套架构闭环

因此,真正的工程目标应该重述为:

  1. 消息不丢:服务端消息一旦返回“已接收”,必须可恢复、可追踪、可补偿。
  2. 消息不重:用户最终可见层面不能重复展示,存储层不能重复落库,推送层不能无限重放。
  3. 会话内严格有序:单聊或群聊中的消息顺序必须稳定。
  4. 系统具备故障弹性:Broker 抖动、消费者重平衡、Redis 短暂故障、机房切换后仍可恢复。

这四点,才是 IM 场景下“绝对送达”与“只送一次”的正确打开方式。


2. 一个典型线上事故:为什么单聊没事,群聊一上量就出问题

某社交产品在早期采用“接入层写库 + 直接推送”的模式:

客户端 -> Gateway -> IM Server -> MySQL -> WebSocket Push

这个架构在单聊、小流量时期工作良好,但当群聊规模扩大后,问题集中爆发:

  • 热门群消息高峰期每秒数万条,数据库连接池先被打满
  • IM Server 推送线程阻塞,导致请求超时,客户端重试
  • 业务方为了削峰引入 Kafka 后,消息被打散进多个分区,群消息出现乱序
  • 消费者处理成功但 offset 未提交,重启后消息被重复消费
  • 推送已成功但入库失败,用户看到了消息,刷新后消息却消失

最终用户侧表现为:

  • 同一条消息重复收到两次甚至多次
  • 群里消息前后颠倒
  • 撤回消息偶发失效
  • 未读数与最后一条消息内容不一致

很多团队第一次做 Kafka 化改造时,都只解决了“削峰填谷”,却没有解决 IM 的三个核心约束:

  • 会话级顺序
  • 端到端幂等
  • 写路径与推送路径的一致性

这也是 IM 和普通异步任务系统最大的不同。


3. 先厘清语义边界:Kafka 的 exactly-once 不等于业务只执行一次

这是最容易被误解的一点。

Kafka 的 exactly-once semantics(EOS)主要解决的是:

  • 生产者重试时,避免同一条消息在同一分区重复写入
  • 消费-处理-生产链路中,结合事务避免“重复产出”

并不能自动保证下面这些业务结果:

  • 你的业务代码只执行一次
  • 你的数据库只插入一条记录
  • 你的 WebSocket 只推送一次
  • 你的第三方回调只调用一次

原因很简单:Kafka 事务边界只覆盖 Kafka 自身,默认并不覆盖 MySQL、Redis、RPC、推送网关这些外部系统。

所以在 IM 里我们必须接受一个事实:

Kafka 负责尽量减少重复和丢失,业务系统负责把“至少一次”收敛成“用户看来只有一次”。

这就是幂等设计存在的根本原因。


4. IM 场景真正要保证的顺序,到底是什么顺序

很多文章一上来就谈“全局有序”,这是不现实也没必要的。

IM 里真正重要的是会话内顺序,而不是全局顺序。

4.1 顺序粒度划分

  • 单聊:一个 conversationId
  • 群聊:一个 groupId
  • 系统会话:例如机器人通知、撤回事件、已读回执,也必须归属到某个会话流

也就是说,我们要保证的是:

同一个会话中的所有事件,以同一条有序事件流被处理。

这里的“事件”不只是文本消息,还包括:

  • 文本、图片、语音、文件
  • 撤回、编辑、引用、转发
  • 已读回执、未读清零
  • 群成员加入、退出、禁言、踢出

如果这些事件不共用同一顺序通道,就会出现典型错误:

  • 消息先被撤回,后又显示出来
  • 用户已被禁言,却还能发出一条消息
  • 已读回执先于原消息到达

4.2 为什么不能把一个群拆到多个分区并行消费

答案也很直接:会破坏因果顺序

比如同一个群内连续三条消息:

  1. A:晚上 8 点上线
  2. B:收到
  3. A:改成 8 点半

如果第 1 条和第 3 条去了分区 P1,第 2 条去了分区 P2,消费端再并行处理,就可能变成:

  1. B:收到
  2. A:晚上 8 点上线
  3. A:改成 8 点半

从用户视角看,这种乱序是不可接受的。

因此生产级 IM 系统必须坚持一条原则:

一个会话,只能映射到一个 Kafka 分区;一个分区内,只能按顺序处理。

这条原则是顺序性的地基。


5. 目标架构:把“接收成功”和“送达成功”拆开

要做到高并发、可扩展、可恢复,接入层必须无状态化,消息链路必须分阶段处理。

推荐的总体架构如下:

这个架构的关键点有三个:

5.1 接入层只承诺“服务端已接收”,不承诺“对端已读”

客户端发消息时,服务端通常返回的是:

  • 消息已被接入层接受
  • 已分配 msgId
  • 已进入可靠消息链路

它不是:

  • 对方一定已经看到
  • 一定已经持久化成功
  • 一定已经多端同步完成

只有先把语义拆清楚,系统设计才不会混乱。

5.2 存储、推送、会话聚合必须解耦

很多系统的问题出在把三件事写在一个事务里:

  • 消息落库
  • 会话列表更新
  • 在线推送

这样做有两个致命问题:

  • 链路被最慢组件拖垮
  • 任一子步骤失败都会引入复杂回滚

更好的方式是通过事件驱动,把它们拆为多个独立消费者,各自幂等、各自重试、各自补偿。

5.3 “消息主事实”必须唯一

在所有下游服务中,必须有一个统一的消息主键:

  • msgId:全局唯一消息 ID
  • conversationId:会话 ID
  • clientMsgId:客户端幂等键
  • sequence:服务端会话内顺序号

后续所有排查、补偿、回放、去重,都围绕这几个字段展开。


6. 生产级消息模型设计

建议消息信封至少包含如下字段:

{ "msgId": "1912800191829002240", "clientMsgId": "ios-8f7c-173617281", "conversationId": "c2c_10001_10002", "conversationType": "SINGLE", "senderId": 10001, "payloadType": "TEXT", "payload": { "text": "晚上八点开会" }, "sequence": 9823441, "eventType": "MESSAGE_CREATED", "sendTime": 1715587200123, "traceId": "8fa0f8d7baf048de8e0ef27f3b9a8df2", "retryTimes": 0, "ext": { "deviceId": "ios-iphone15", "appVersion": "9.3.1" } }

核心字段含义如下:

字段作用是否必须
msgId服务端唯一主键,用于全链路去重必须
clientMsgId客户端重试幂等键,避免用户点两次发送必须
conversationId分区路由键、顺序边界必须
sequence会话内单调递增序号必须
eventType区分创建、撤回、编辑、回执等事件必须
traceId故障排查与观测链路强烈建议
retryTimes辅助补偿和告警建议

6.1 为什么一定要有 clientMsgId

客户端超时重试在移动端极其常见:

  • 用户网络抖动
  • 网关返回超时
  • 客户端不知道服务端是否已经成功

如果没有 clientMsgId,服务端只能把重试当成两条独立消息处理。

因此接入层应优先按 (senderId, clientMsgId) 做首层幂等,典型策略是:

  • 首次请求:生成 msgId 并写入
  • 重试请求:直接返回之前生成的 msgId 和发送状态

这样能在最前面拦住大量重复流量。

6.2 为什么一定要有 sequence

Kafka 分区内有 offset,但 offset 不能直接替代业务序号,因为:

  • offset 只在单个 topic-partition 内唯一
  • topic 重建、跨机房同步后 offset 不稳定
  • 一个会话未来可能迁移 topic 或分区策略

因此必须有独立的会话内业务序号

http://www.jsqmd.com/news/811343/

相关文章:

  • Agentic AI能效优化:计算与通信协同设计
  • Perplexity如何秒级定位IEEE顶会论文?:2024最新实测验证的7步精准检索法
  • 苹果将在培训应用中采用AI生成主播,解决传统培训规模化与个性化难题
  • 如何解决SQL数据插入死锁问题_优化索引与事务隔离级别
  • Qt WebEngine实战避坑:证书管理、代理设置与高DPI适配那些事儿
  • 收藏!小白程序员必看:如何拥抱AI,从码农到高薪AI协作者的成长指南
  • ChatGPT TikTok创意私密手册(仅开放72小时|含12个未公开的平台敏感词规避Prompt)
  • 从零解析FunFarm克隆项目:现代Web全栈开发实战指南
  • 核心 Web 指标 FCP 超过 2 秒如何针对性优化?
  • 终极指南:如何使用Reset Windows Update Tool一键修复Windows更新问题
  • castAR混合现实头显:从光学投影到空间锚定的技术解析
  • 轻量级日志聚合器Shiplog:中小团队分布式日志管理实践
  • Git仓库PR自动化管理:用gittriage实现策略即代码的生命周期管理
  • Gemini Chrome插件开发避坑清单:17个官方文档未提及的调试断点、权限继承漏洞与跨域通信失效场景
  • 无人机+点云+Civil3D:无控制点场景下的高精度土方算量实战
  • 基于Hetzner GPU云服务器与Ollama部署私有AI编程助手实战指南
  • FPGA高可靠设计核心技术:从冗余架构到EDA工具链的工程实践
  • APIO2026 题解
  • 面壁智能开源端侧多模态大模型MiniCPM-V 4.6,性能登顶同尺寸榜首,降低开发门槛
  • Nacos 1.2.1升级到2.0.3后,CVE-2021-29441漏洞还在?手把手教你正确配置auth参数
  • 别再傻傻分不清了!一文搞懂SCSI、SAS、FC、PCIe、IB这些存储协议到底怎么选
  • 题解:LG-P1020
  • 如何快速实现OBS多平台直播:obs-multi-rtmp完全配置指南
  • 普宁做招牌找哪家广告公司比较靠谱?|4个判断标准+本地案例 - 掌上普宁品牌观察
  • PUBG罗技鼠标宏终极指南:如何快速实现无后坐力压枪
  • 基于OpenClaw框架的Mattermost聊天机器人开发实战指南
  • 如何为你的项目快速接入稳定的大模型API服务
  • 2026年降AI率:防范AI代写引发学位撤销风险 - 降AI实验室
  • 软工5.13
  • 量子非局域游戏与GHZ态:原理、优化与应用