凌晨3点告警:消息积压上亿,我是如何“逃出生天”的?

还记得五年前那个让我至今心有余悸的周六凌晨,手机疯狂震动,PagerDuty 的告警声在寂静的卧室里显得格外刺耳。迷迷糊糊抓起手机一看,瞬间清醒:核心订单服务的 Kafka 消息积压(Lag)突破了 1 亿,且还在以每秒 2 万的速度飙升。

当时我的第一反应和很多刚入行的新人一样:“扩容!赶紧加机器!”于是我手忙脚乱地在 K8s 上把 Consumer 的 Pod 数从 10 个扩到了 50 个。结果呢?积压曲线纹丝不动,甚至因为甚至因为数据库连接数暴涨,差点把 DB 打挂。

那一刻我才深刻意识到,处理消息积压绝不是简单的“堆人头”或“堆机器”。这几年经历了无数次大促洗礼,踩过无数坑后,我总结出了一套从“紧急止血”到“根源治理”的组合拳。今天就不聊虚的理论,咱们复盘几个真实场景,聊聊当 Lag 红线拉满时,到底该怎么办。

扩容的陷阱:为什么加了机器没用?

很多时候,当我们看到积压,本能反应就是增加消费端节点。这招在某些场景下有效,但在 Kafka 这类基于 Partition(分区)机制的消息队列中,往往是无效的。

真实案例: 在一次“双11”预演中,我们的营销服务积压了 5000 万条消息。运维老哥很给力,一口气给我加了 20 台服务器。但我盯着监控看了一分钟,消费速率(TPS)几乎是一条直线,根本没涨。

问题出在哪? Kafka 的机制决定了,一个 Partition 在同一时间只能被同一个 Consumer Group 下的一个 Consumer 线程消费。 当时我们的 Topic 只有 20 个 Partition,而我们原来的消费者就已经有 20 个了。你扩容到 40 个、100 个,多出来的那些节点只能在旁边干瞪眼,处于 Idle 状态,完全分不到数据。

怎么破?紧急扩容“中转站”方案 既然分区数是瓶颈,那我们在不动线上核心业务逻辑的前提下,得先搞个“分流器”。

我当时的具体操作步骤是这样的:

  1. 临时扩容 Topic:新建一个临时 Topic(比如 topic_emergency_dump),把 Partition 数配置成原来的 10 倍(比如 200 个)。
  2. 改写消费逻辑(Relay):写一个极简的 Consumer 程序,它的逻辑只有一步——不处理任何业务,只负责把原来的 Topic 消息读出来,原样写入新的临时 Topic。 这一步非常快,原本 20 个节点就能扛住巨大的流量。
  3. 部署超级消费集群:在新的临时 Topic 上,部署 200 个真正的业务 Consumer 节点。

结果: 这套方案上线后,原 Topic 的积压在 15 分钟内被“搬运”到了新 Topic,而新 Topic 凭借 200 个并发的消费能力,在 1 小时内消化掉了所有积压。虽然架构复杂了一点,但在救火场景下,先把水排出去,比什么都重要。

性能的黑洞:你的代码在“摸鱼”吗?

搞定了并发度,咱们再来看看单机性能。很多时候,积压的根源不是机器不够,而是代码写得太“慢”了。

配图

真实案例: 有个做日志分析的同事找我求助,说他们的服务 CPU 使用率只有 10%,内存也很充足,但消息就是消费不过来。我看了一下代码,差点一口老血喷出来。

他的逻辑大概是这样的:

// 伪代码:千万别这么写
while (true) {
    Message msg = consumer.poll();
    if (msg != null) {
        // 每一条消息都去查一次数据库
        User user = userService.findById(msg.getUserId());
        // ... 业务逻辑
        saveResult(user);
    }
}

反思与改进: 这简直是教科书级别的性能杀手。IO(网络/磁盘)永远是最大的瓶颈。 每一条消息都发起一次 DB 查询,不管数据库多强,几万 QPS 下去也得跪,而且网络往返的耗时(RTT)会让线程大部分时间都在等待。

我给他的优化建议只改了两点,吞吐量直接提升了 20 倍:

  1. 批量消费(Batch Processing): 不要来一条处理一条。一次拉取 500 条,积攒到 List 里。
  2. 批量 IO + 本地缓存: 拿到 500 条消息后,提取出所有的 userId一次性去数据库 select * from user where id in (...)。如果数据变化不频繁,甚至可以在本地加一层 Guava Cache 或 Caffeine。

落地效果: 原本每秒处理 1000 条,改完后轻松跑到 20000 条,CPU 利用率也上去了(这才是对的,CPU 忙起来说明在干活,而不是在等 IO)。

最后的防线:别让积压拖垮整个系统

配图

有时候,积压是不可避免的。比如第三方服务挂了,或者下游系统彻底瘫痪。这时候,我们的目标不是“消除积压”,而是**“保命”**。

真实案例: 两年前,我们的一个积分服务依赖外部供应商的 API。有一天供应商挂了,响应时间从 200ms 变成了 30s 超时。我们的消费线程全部卡在等待响应上,线程池瞬间爆满。结果不仅消息积压,还导致服务 OOM(内存溢出),连带把同一个容器里的其他服务也拖死了。

经验沉淀: 从那以后,我在所有的 Consumer 端都强制加了两道锁:

  1. 强隔离与熔断: 使用 Sentinel 或 Resilience4j。一旦检测到下游接口异常比例升高,直接熔断,不再发起调用。
  2. 死信队列(DLQ)策略: 对于处理失败的消息,不要无限重试!很多框架默认会无限重试,导致一条毒丸消息(Poison Pill)堵死整个队列。 我个人的配置习惯:重试 3 次 -> 依然失败 -> 扔入死信队列 -> 发送告警 -> 人工介入。

记住一句话:在极端情况下,丢弃消息比拖垮系统要好。 只要入了死信队列,我们事后还有机会回放(Replay),但服务挂了就什么都没了。

拿来即用:我的“防积压”急救包

聊了这么多,最后给大家整理一套我个人常用的消息积压排查与治理模板,建议大家截图保存,关键时刻能省去不少思考时间。

1. 紧急排查 SOP(复制可用)

  • Step 1 看指标:积压是全量积压还是部分 Partition 积压?
    • 全量积压 -> 查下游资源(DB/Redis/接口)是否瓶颈。
    • 部分积压 -> 查是否有数据倾斜(某个 Key 数据量特别大)。
  • Step 2 看资源:消费者 CPU/内存是否打满?
    • 低 CPU 高 Latency -> 线程卡在 IO 上(查数据库、外部接口)。
    • 高 CPU -> 代码死循环或复杂计算(查 GC 日志、复杂正则)。
  • Step 3 看日志:是否有大量错误异常刷屏?
    • 有异常导致不断重试 -> 立即开启降级或调整重试策略。

2. 落地行动建议

光看不练假把式,如果你负责的系统里也有消息队列,建议这周抽出半天时间做三件事:

  1. 检查消费端配置:确认是否开启了批量消费?确认重试策略是否设置了上限(建议不超过 3-5 次)?
  2. 压测:在测试环境模拟一下,如果下游接口慢了 10 倍,你的消费端会发生什么?会堵死吗?
  3. 配置监控告警:不要等用户投诉了才知道积压。设置 Lag 阈值告警(比如积压超过 10 万或延迟超过 5 分钟),并配置电话/短信通知。

架构优化是个细活,更是个苦活。消息积压不可怕,可怕的是我们手里没有应对的方案。希望这篇复盘能帮你建立起面对告警时的底气。

你在处理消息积压时遇到过什么奇葩场景?欢迎在评论区聊聊,咱们一起排坑!