4.1 削峰填谷:RocketMQ 异步写入

场景

上线三个月后的某个周三晚八点,发送接口的 P99 从一百八十毫秒直接蹦到九百毫秒,告警群一片红。排查结果很干净:没有慢查询,没有连接打满,就是写入量本身超了存储的瞬时能力——晚高峰一小时写入量是白天的四倍,同步链路被存储的排队拖垮。用户侧的体感是发消息要等一两秒,群里一片抱怨。这就是典型的写峰值问题:平均容量够用,但峰值超过单点的同步处理能力,而 IM 的峰值又特别尖——晚间一小时承载全天三成的消息。

峰值问题有三种典型解法:扛、削、扩。扛是加机器硬顶,成本最高;扩是存储扩容,见效慢(见 存储扩容:先走读写分离);削是把峰值用队列摊平,让存储以接近均匀的速率消费。我们选了削:写入链路里在发送接口与存储之间加一层 RocketMQ,同步部分只负责校验与入队,落库由消费者按固定并发拉动。ROI 很明确:用消息队列的少量成本换掉高峰期被存储拖垮的接口。

方案

改造前的链路是:发送接口同步分配 Seq、同步落库、更新缓存、异步推送。改造后变成:发送接口校验限流后把消息写入 RocketMQ,立即返回成功;后台消费者从队列拉消息,完成 Seq 分配、落库、缓存更新与推送。一次改造把同步往返从四步压到两步。

对比项 改造前 改造后 说明
发送接口 P99 一百八十毫秒 六十毫秒 不再受存储排队影响
峰值存储压力 跟随流量尖刺 均匀速率 消费并发固定
峰值消息延迟 即时 低于两百毫秒 消费滞后可控
一致性窗口 强 队列内最终一致 客户端按需拉取补齐
故域扩散 存储抖动直接打挂接口 队列吸收抖动 但队列本身成了新依赖

引入 MQ 不是纯赚的买卖。第一个代价是消息可见性延迟:发出去的消息在好友那侧不再是严格即时,消费滞后几十毫秒到一两秒,用户几乎无感,但如果是 IM 本身的聊天场景要评估好。第二个代价是复杂度:重复消费、消费位点、积压告警、顺序消费这些概念全都要落地。第三个代价是新故障域:RocketMQ 挂了发送链路就断了,必须有降级开关。还有第四个容易漏的:Trace 与排查链路要重新设计,以前一个链路 ID 串到底,现在中间隔了队列,排查时要在消息体里带上链路 ID,消费端继续透传,否则出了问题只能对着两份日志干瞪眼。

选 RocketMQ 而不是其他队列,理由有三:顺序消息能力(会话内严格有序是 IM 的硬需求)、事务消息机制可以在未来与业务流程做更深集成、以及云上托管省运维。文档入口 cloud.tencent.com/document/product/1493。

配置与代码

生产端的代码很薄,注意发送成功只代表入队成功:

func (p *Producer) Publish(m *Message) error {
    body, err := json.Marshal(m)
    if err != nil {
        return err
    }
    msg := &primitive.Message{
        Topic: "im_msg",
        Body:  body,
    }
    msg.WithTag(m.MsgKind()) // 普通消息与系统消息分流
    _, err = p.client.SendSync(ctx, msg) // 同步刷盘,可靠性优先
    return err
}

消费端按 ConvID 哈希到固定队列,保证单会话内严格顺序:

func (c *Consumer) handle(ctx context.Context, msgs ...*primitive.MessageExt) error {
    for _, m := range msgs {
        var msg Message
        if err := json.Unmarshal(m.Body, &msg); err != nil {
            continue // 毒丸消息,记日志跳过,见踩坑三
        }
        if err := c.write.Save(&msg); err != nil {
            return err // 整批重试,依靠幂等兜底重复
        }
    }
    return nil
}

顺序消费的关键在生产端选队列的 Selector:同一 ConvID 永远进同一队列,同一队列永远由同一消费者实例拉取,这样会话内的消息顺序在整个链路里不打乱。这个性质在 消息模型:单聊、群聊与序号 里定的 Seq 语义之上又加了一层保险。RocketMQ 的实例与主题配置清单包括:消息保留时长设为三天(够消费端重试与回溯)、最大重试次数十六次、消费超时三分钟、生产端 SendSync 而不是异步发送。文档入口 cloud.tencent.com/document/product/1493。

踩坑

第一个坑是积压发现得太晚。消费者并发开得保守,某次运营 push 带来平时五倍的消息量,积压悄悄涨到十万条才开始有用户反馈消息收不到。监控补上三条:队列深度、消费 TPS、消息端到端延迟(从入队时间戳到落库时间戳的差值)。端到端延迟超过五分钟即告警,比看队列深度更接近业务体感。积压的常态化处理还有一个手段:消费端按深度自适应扩并发,深度过千时临时拉到两倍,回落后再降。

第二个坑是重复消费。消费失败整批重试时已经落库的消息会再落一次。多亏 MsgID 唯一索引,重复写入直接报冲突被跳过,但计数的 INCR 没有。后来所有消费侧操作统一包一层幂等检查(按 MsgID 查一次存在性),重复消息的开销是多一次查询,换来的是计数与缓存的绝对正确。幂等的完整设计见 消息防丢:全链路幂等设计。

第三个坑是毒丸消息。某次一条格式错误的系统消息(JSON 里少了必填字段)反序列化失败,消费端返回错误,队列按指数退避反复重试十六次,整整卡了二十分钟,期间该队列后续消息全部堵死。修复是反序列化失败的消息不返回错误,而是写入死信主题并提交位点,让队列继续前进;告警监控死信主题的计数,由人工或自动任务兜底处理。

第四个坑是降级切换的手忙脚乱。一次 RocketMQ 实例维护导致发送链路全断,当时没有一键开关,临时改配置发布花了二十分钟。补上的是配置中心的降级开关:打到同步模式时发送链路绕过队列直接落库(前置限流收紧到平时的一半防止存储被 raw 峰值打挂),打到队列模式时恢复正常。开关演练写进了每季度的常规操作,否则真出事时按钮本身就是风险。

第五个坑是消息可见性导致的交互问题。好友消息从即时变成一两秒延迟,尝鲜群的用户没感觉,但有一个高频场景炸了:用户发完消息立刻自己刷一下会话,列表里没有最新一条(消息还在队列里)。解决是发送成功后客户端本地直接插入消息做乐观渲染,同时服务端对发送者立即可见的保证(写入自己的会话列表快照)放在入队之前完成。

第六个坑是消费端的扩缩容反应慢。队列深度涨起来之后手工加消费者实例要等部署流程,眼睁睁看积压涨半小时。后来消费端做了两层自适应:实例内增加拉取协程数(配置中心动态调),实例外接 HPA 按队列深度指标扩容。注意顺序队列的扩容有个坑:队列数固定时加消费者只会让多余的实例空转,真正的扩容是升队列数,这需要提前规划好主题的队列规模,我们直接按最大规模十六队列创建,即便早期只有四个消费者也不浪费。

小结

削峰的本质是把峰值从昂贵的地方搬到便宜的地方。三个要点:生产端用同步刷盘换可靠、消费端按 ConvID 固定队列换顺序、积压监控看端到端延迟而非队列深度。另一个隐性收益是队列成了天然的缓冲层,存储维护窗口期也能短暂堆积请求,这让我们后来的几次存储迁移从容很多。最后给一个取舍提醒:小团队早期流量没到瓶颈时不要急着上 MQ,同步链路简单且排查容易,等 P99 被峰值真正打痛再切也不迟——但接口与存储之间留好抽象层,切的时候才能只换实现不动主干。下一篇 存储扩容:先走读写分离 讲存储侧如何配合扩容,灰度放量的操作见 灰度发布:按用户分桶放量。

消息顺序与消费并发是一对需要刻意平衡的矛盾。我们按 ConvID 哈希到固定队列保证同一会话内严格有序,但扩容队列数时再均衡的瞬间又把顺序打破了:再均衡把一部分队列换了消费者,正在处理的消息和刚到的消息可能被新旧两个消费者同时消费,seq 就乱了。我们的解法是在再均衡窗口内进入保序模式,这期间收到的新消息先暂存内存队列不处理,等再均衡完成、单消费者独占全部相关队列后再按序消费,代价是再均衡那十几秒消费延迟短暂升高,总比乱序好。监控上把再均衡事件单独做了一条告警,因为它只要发生就值得人工看一眼积压曲线。

配套的治理项是消费位点的自动重试上限。消息处理失败重试三次仍不成功就进死信队列并告警,绝不无限重试——无限重试等于整个队列被一条毒丸消息堵死,后面所有会话的消息全部积压,这在 IM 里是不可用级事故。死信队列的消费由人工处理,处理后按原 ConvID 手动触发重投。这套机制运行半年,毒丸消息总共出现过两次,一次是客户端发了畸形表情码导致解析空指针,都靠这套闸门兜住了影响面。

延伸阅读