ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

消息积压排查与应急实战:Lag诊断、扩容降级与容量监控

消息积压排查与应急实战:Lag诊断、扩容降级与容量监控

凌晨两点被电话叫起来,打开监控面板,消费者 Lag 那条线从平时的几千一路冲到七位数,业务群里已经在问"用户下单二十分钟了还没发货"。这就是消息积压最典型的一幕:它平时不声不响,一旦冒头就是事故。消息积压问题本质上是生产速率和消费速率之间的平衡被打破,表现出来的现象是队列里堆着的消息越来越多,但根因可能藏在消费端的某个同步 RPC、某个配置项、某个下游接口的抖动里,也可能藏在生产端的流量突增里。这篇文章不讲教科书定义,只讲我在实际系统里处理消息积压的完整打法:怎么在十分钟内判断是"慢"还是"停"还是"涌入",怎么算需要多少消费者才够,怎么在分区数不够的情况下应急,以及哪些坑是我真实踩过、希望你别再踩的。适合正在维护消息队列的后端开发、运维和架构同学看,刚接触消息中间件的新人也能顺着思路跟上。

1. 消息积压到底是什么:先分清三种完全不同的病

1.1 积压的本质是一道小学应用题

很多人一看到 Lag 上涨就条件反射去扩容消费者,这是最容易走弯路的地方。先把问题还原成一道应用题:队列里当前堆积了 L 条消息,生产端每秒进来 P 条,消费端每秒处理 C 条,那么消化完这批存量需要的时间是:

消化时间 = L / (C - P) 前提是 C > P

这个公式看起来简单,但它解释了为什么很多"扩容了却没用"的场景。举个例子,Lag 是 800 万,消费能力 4000 条/秒,生产速率 3000 条/秒,净消化速度只有 1000 条/秒,需要 8000 秒,也就是两个多小时。这时候你在监控上看到消费 TPS 是 4000,看起来"在正常工作",实际上永远追不上,因为每秒钟新进来的消息还在持续抵消你的努力。如果 C 恰好等于 P,消化时间就是无穷大,Lag 会稳定在一个高位永远下不来。

反过来,这个公式也告诉你应急时该往哪个方向使劲:要么把 C 拉上去(扩容、优化单条耗时、批量处理),要么把 P 压下来(生产端限流、降级非核心消息),两条路同时走效果最好。我见过最典型的一次事故,消费端拼命扩容从 8 个实例加到 40 个,但因为分区数只有 12,实际有效的消费者还是 12 个,剩下 28 个实例在空转,Lag 一点没降。所以任何动作之前,先把这个式子里的三个数搞清楚,比盲目扩容有用一百倍。

还有一个小细节:L 这个数字不能只看总量。总量 100 万但均匀分布在 64 个分区上,和总量 100 万但集中在 2 个分区上,是完全不同的两种病。后者通常意味着这两个分区对应的消费者实例出了问题,或者是消息 key 分布严重倾斜导致热点,扩容再多也没用,因为瓶颈被锁死在那两个分区上。我排查的时候第一件事永远是看分区维度的 Lag 分布,这一步能省掉后面 80% 的猜测。

1.2 三种积压:慢、停、涌入

把所有积压场景归个类,其实就三种,症状和处置方式完全不同。

慢型积压是消费端还在正常工作,只是速度跟不上。监控上表现为消费 TPS 是一条平稳的低线,Lag 缓慢但持续地往上爬,曲线斜率基本恒定。这种最常见,根因通常是单条消息处理耗时变长了,比如下游接口 RT 从 20 毫秒涨到 200 毫秒,或者某次发版加了一段没必要的查库逻辑。它的特点是可预测、可计算,你能算出多久之后会出问题,有时间从容处理。

停型积压是消费端已经不干活了,或者周期性地不干活。监控上表现为消费 TPS 断崖式归零,或者呈现出规律的锯齿状——掉零、恢复、再掉零。这种最凶险,因为它往往伴随位点提交异常,一旦处理不当会丢消息。停型积压的常见原因包括:消费者进程挂了但没被及时发现、触发 rebalance 导致消费暂停、某条"毒丸消息"反复消费失败卡住了整个消费线程、消费线程池被死锁占满、GC 停顿时间过长被踢出消费组。

涌入型积压是生产端突然放量。监控上表现为生产 TPS 出现明显尖峰,消费端其实没变慢,只是被冲垮了。典型场景是上游做数据补录、批量导入、活动开抢瞬间流量翻十倍,或者更隐蔽的一种——上游服务自身超时重试,同一条消息被重复发送了五六次,生产量凭空翻了几倍而业务方毫不知情。

区分的办法很直接,把生产 TPS 和消费 TPS 两条曲线画在同一张图上:两条线都平稳但消费线在下面,是慢型;消费线有垂直断崖,是停型;生产线出现尖峰,是涌入型。三种情况的应急手段完全不一样,慢型要优化和扩容,停型要先把消费拉活,涌入型要先在生产端踩刹车。搞错了方向,越努力越糟。

1.3 动手前必须先量出来的四个数

在敲任何命令、改任何配置之前,我要求自己先把四个数摆出来,摆在纸上或者文档里,然后再开始讨论方案。

第一个是Lag 的总量和分区分布。总量告诉你规模,分区分布告诉你瓶颈在哪。如果某个分区的 Lag 明显高于其他分区,先去看那个分区的消费者实例是什么状态。

第二个是生产 TPS,包括当前值和历史峰值。当前值决定你要多快消化,历史峰值决定你的容量设计是否合理。很多系统出问题的真正原因是从来没做过容量评估,平时 500 TPS 跑得好好的,某天来了 3000 TPS 就崩了。

第三个是消费 TPS,包括实际值和理论值。实际值从监控拿,理论值要自己算:单实例并发度 ÷ 单条平均处理耗时 × 实例数。如果理论值是 8000 而实际只有 2000,说明有 75% 的消费能力被浪费掉了,这时候该查的是为什么慢,而不是盲目加机器。

第四个是单条消息处理耗时的分布,注意是 P50 和 P99,不是平均值。平均值是最会骗人的指标,一批消息里 990 条耗时 1 毫秒、10 条耗时 2 秒,平均值才 21 毫秒,看起来很美,但那 10 条慢消息会拖垮整个批次的处理时间。我习惯把 P99 和 P50 的比值作为一个健康度指标,比值超过 10 倍就说明消费链路里有长尾,通常指向某个不稳定下游或者锁竞争。

除了这四个,还有一个容易被忽略但极其有用的数:消费线程池的活跃线程数和排队深度。如果线程池排队深度持续上涨而活跃线程数打满,说明是处理能力不足;如果活跃线程数很低但队列在涨,说明消费逻辑里在等什么东西——大概率是下游调用阻塞或者锁。

2. 消费端为什么慢:从单条耗时倒推瓶颈位置

2.1 单条耗时拆解:把 5 毫秒变成 500 毫秒的几个元凶

消费变慢从来不是抽象的,一定是某个具体操作变慢了。我的习惯是给消费逻辑做一次"耗时剖面",像做性能分析一样把每条消息的处理拆开看。拆完之后你会发现,绝大多数慢的场景都能归到下面几类。

同步调用下游是头号杀手。消费逻辑里调一次 HTTP 接口 50 毫秒,一批 100 条就是 5 秒,如果这个接口因为下游抖动变成 500 毫秒,一批就变成 50 秒,消费能力直接掉到原来的十分之一。更麻烦的是这种下降是渐进的,不会触发任何告警,等你发现 Lag 涨起来时已经过去半小时了。处理办法是把非核心的同步调用挪到异步,或者在消费链路里加超时熔断,宁可这条消息进重试也不要拖住整批。

逐条写库是第二号杀手。一条消息一次 insert,一万条消息就是一万次数据库往返,即使每次只有 2 毫秒,也是一万次网络开销。改成攒够 500 条做一次批量写入,耗时会降到原来的百分之一甚至更低。这里有个前提要注意:批量写入要么全部成功要么全部失败,一旦部分失败需要能做补偿或者重试,所以批量大小不能贪大,500 到 1000 是个比较稳的区间。

消息体过大这一条经常被忽略。单条消息 2MB,一万条就是 20GB 的网络传输和反序列化开销,很多时候瓶颈根本不在业务逻辑上,在网络 IO 和 JSON 解析上。我处理过一次,排查了很久以为是数据库慢,最后抓包发现是消息体里塞了一张图片的 base64,把消息体从 2MB 改成只传 URL、内容放对象存储,消费 TPS 直接翻了六倍。

锁竞争和串行化是第四类。如果消费逻辑里对同一个用户的处理加了分布式锁,而这个用户恰好是个高频操作的账户,所有消息就都堵在这个锁上排队。还有一种是顺序消息的处理模式,为了保证同一个 key 的消息顺序消费,同一个队列的消息只能串行处理,吞吐上限被锁死在单线程水平。这种情况要么想办法把顺序要求缩小到更细的粒度,要么接受一定程度的乱序后续用业务逻辑兜住。

日志和序列化是最后但不容忽视的一块。同步刷盘的日志框架、每次消息都打印完整的请求体和响应体,在高吞吐场景下能把 CPU 打满。我见过一个服务消费 TPS 只有 600,把 logback 的日志级别从 DEBUG 调到 INFO、把同步 appender 改成异步,TPS 直接到了 2500。

2.2 rebalance 风暴:最隐蔽也最致命的"假死"

如果说前面的问题是慢性病,rebalance 风暴就是急性心梗,而且极难排查,因为从监控上看消费端"在跑",但 Lag 就是降不下来。

机制是这样的:消费者组里每个成员需要定期向协调者证明自己还活着。除了心跳线程,还有一个更关键的参数控制着"消费者在两次拉取之间允许多久不说话"——如果超过这个时间没有发起新的拉取请求,协调者就认为这个消费者已经死了,把它踢出组,触发整个组重新分配分区。这个时间在很多实现里默认是 5 分钟。

问题出在批处理上。一次拉取 500 条消息,如果单条处理耗时从 10 毫秒涨到 700 毫秒,这一批就要处理 350 秒,超过 5 分钟,消费者被踢。被踢之后发生什么?分区被重新分配,位点可能回退到上次提交的位置,那些已经处理但还没提交的消息会被另一个消费者重新处理一遍。于是消费速度进一步变慢,再次超时,再次被踢,形成正反馈的死循环。监控上你会看到消费 TPS 呈现规律的周期性掉零,日志里反复出现组成员变更的记录,Lag 只涨不跌。

我遇到过一次非常典型的:某个消费服务单批处理时间平时是 40 秒,某天下游数据库慢查询,单批变成了 6 分钟,然后就开始反复 rebalance,Lag 从 2 万一路涨到 90 万,运维那边看监控发现"消费者数量在 8 个和 6 个之间反复跳变",才定位到是这个问题。

解决的思路有三条,可以同时用。第一,把单批拉取量调小,让每批的处理时间可控,比如从 500 降到 100;第二,把那个超时时间适当调大,给长尾消息留出余量;第三,也是最根本的,把耗时的业务处理逻辑挪到独立的线程池里,消费线程只负责拉取和分发,保证拉取节奏稳定。但第三条有个坑必须注意:一旦处理变成异步,位点提交的时机就变得很危险,如果拉取线程处理完就立刻提交位点,而业务线程还在处理,消息就丢了。正确做法是记录每条消息的处理完成状态,在真正处理完之后再按顺序推进可提交位点,这个实现不复杂但一定要做对。

2.3 位点提交与重试:越消费越多的死循环

有个场景特别反直觉:消费者明明在拼命跑,CPU 打满,日志刷得飞快,但 Lag 就是不动甚至还在涨。这种情况八成是重试逻辑出了问题。

最常见的形态是"毒丸消息"。某条消息因为数据格式错误或者业务逻辑不兼容,怎么处理都抛异常,消费失败后不提交位点,重新拉取时又拉到同一条,再失败,再重试。如果消费端是单线程按序处理,一条消息就能把整个消费链路卡死,后面几百万条全等着。我在日志里找这类问题的诀窍是看"同一条消息 ID 出现的次数",正常情况下应该只出现一次,如果某个 ID 在几分钟内出现了几十次,基本就是它了。

应对办法要分层。短期是给重试设上限,超过 N 次就跳过或者转入死信队列,绝不能无限重试。中期是在消费逻辑里做防御性校验,格式不对的消息直接记录并跳过,不要让异常穿透到消费框架。长期是建立死信队列的处理流程——死信不是垃圾桶,必须有告警、有人看、有补数手段,否则它就是一个慢慢积累的定时炸弹。

还有一种更隐蔽的"越消费越多":消费失败后消息进入重试队列,重试队列本身也是一个队列,也需要消费能力。当失败率升高时,重试消息会挤占正常消息的消费资源,形成双重积压。有一次我看到总的 Lag 是 60 万,但拆开看正常队列 20 万、重试队列 40 万,说明真正的瓶颈是失败率而不是吞吐能力,这时候该做的是查失败原因,扩容完全没用。

最后一个和位点相关的高频坑是自动提交加多线程消费。自动提交是定时提交当前已拉取到的最大位点,如果业务处理是异步的,位点可能已经提交了但消息还没处理,此时进程重启,那批消息就永久丢失了。这个问题的可怕之处在于它平时完全看不出来,只有重启或者崩溃时才暴露,而且暴露的方式是"数据对不上"而不是"报错"。我的建议是消费端一律关闭自动提交,手动在业务处理完成后按顺序推进位点,代价是几行代码,收益是数据不丢。

3. 应急止血:把 Lag 在半小时内压下去的打法

3.1 扩容之前先算分区,否则加了也白加

应急第一步永远是算账,而不是加机器。消费者的并行度上限被分区数(或者队列数)死死卡住:一个分区在同一时刻只能被同一个消费组里的一个消费者处理,消费者数量超过分区数,多出来的实例就是纯浪费。

所以扩容之前先做这个除法:现有分区数 ÷ 期望的单实例并发度。假设分区数是 12,你希望每个实例处理 1 个分区、单实例内部再开 8 个线程并行处理,那么最多需要 12 个实例。超过 12 个就没意义了。

分区数不够怎么办?有两条路。

第一条是扩分区。绝大多数消息中间件都支持增加分区,但只能增不能减,而且增加分区会打乱 key 到分区的路由关系。原来同一个订单 ID 的消息一定落在同一个分区上,扩分区之后可能落到不同分区,如果你的业务依赖分区内有序,这个操作会直接破坏顺序性。所以扩分区之前一定要确认:这个 topic 的顺序性要求是什么级别?如果有顺序要求,扩分区就不可行,只能走第二条路。

第二条路是在单实例内部做多线程消费。消费线程只负责拉取,把消息丢进线程池并行处理。这条路的代价是放弃分区内的顺序性,收益是并行度不再受分区数限制。我一般会把这作为应急手段,因为它不需要动中间件配置、不需要协调上下游、操作可逆——处理完积压再把线程数调回 1 就行。但前提是消费逻辑必须幂等,因为并行之后消息处理顺序不再有保证,同一条消息也可能因为重试被处理两次。

还有一个容易被忘掉的细节:加机器之前先看看 broker 端和网络有没有余量。我见过一次扩容之后 Lag 不降反升,最后发现是新增的消费者把 broker 的网络带宽打满了,拉取请求大量超时。所以应急扩容的幅度要控制,一次加一倍观察五分钟再加,别一口气加到十倍。

3.2 三板斧降级:批量、异步、砍分支

扩容是加法,降级是减法,两个一起用效果最好。我给团队定的应急三板斧是固定的顺序:先批量,再异步,最后砍分支。

批量是最容易见效的。原来逐条查库、逐条写库、逐条调接口,现在改成攒一批统一处理。实现上通常是在消费循环里维护一个缓冲区,攒够 N 条或者等够 T 毫秒就触发一次批量操作。参数怎么定?我的经验是把批量大小定在能让单批处理时间落在 200 毫秒到 1 秒之间,太小没效果,太大容易超时。同时要保证批量的失败处理是幂等的,因为一批失败重试时里面的消息会被重复处理。

异步是把非核心逻辑剥离出去。一条订单消息的完整处理可能包括:校验状态、更新数据库、扣减库存、发通知、写埋点、算推荐权重。真正影响用户体验的只有前三个,后面三个完全可以异步化甚至直接丢弃。应急时我会用一个开关把这些旁路逻辑的开关关掉,让消费链路只保留最短的关键路径。这个开关必须提前埋在代码里,事到临头再改代码发版就来不及了。

砍分支是更激进的降级,直接跳过某些类型的消息或者某些非核心的校验逻辑。比如大促期间临时关闭风控二次校验、关闭重复下单检测、把实时计算积分的逻辑改成离线补算。做这个决定需要业务方点头,所以要提前把降级预案和业务方对齐,写清楚"什么条件下触发、触发后影响什么、怎么恢复"。临场找业务方拍板是最耽误时间的事。

3.3 临时分流与独立消费组

有时候上面的手段都用上了还是不够,那就得考虑分流——把积压的存量消息和实时的新消息分开处理。

第一种做法是起一个临时的消费组。同一个 topic 可以被多个消费组订阅,各自维护自己的位点。原来的消费组继续处理新消息保证实时性,临时起的消费组从最早的位点开始追历史积压,它做什么?可以是完整的业务处理(前提是幂等),也可以是更轻量的"搬运"——只把消息解析出来写到数据库临时表或者对象存储,业务逻辑稍后慢慢跑。搬运的吞吐远高于业务处理,几百万条消息可能十几分钟就搬完了。

第二种做法是新建一个临时 topic 做中转。写一个转发程序,从原 topic 快速读取并写到临时 topic,转发过程不做任何业务逻辑,几乎是纯网络操作。然后用一组配置更高的消费者去消费临时 topic,慢慢消化。这个方案的好处是把"读"和"处理"彻底解耦,转发程序可以开到几十个线程,处理程序可以按业务承受能力慢慢来。

第三种是在消费端做优先级隔离。把消息按类型拆到不同队列,老消息走低优先级通道,新消息走高优先级通道,保证用户感知到的延迟不会越来越长。这个方案需要消息生产端配合,属于中期改造,应急时用不上,但对经常出现积压的业务很值得做。

分流方案有个共同的风险点:必须有办法判断"哪些消息已经被处理过"。因为临时消费组和处理组可能同时消费同一条消息,如果没有幂等保障,就会出现重复发货、重复扣款这类严重问题。所以采用任何分流方案之前,先确认幂等机制是可靠的,这是底线。

3.4 什么时候可以丢消息,怎么丢才不背锅

这个话题有点敏感,但必须讲,因为应急时总会有人提"把积压的消息清掉不就行了"。我的态度是:能丢的可以丢,但要有依据、有记录、有补偿预案。

判断标准很简单,问三个问题。第一,这条消息丢失会导致资金损失、状态错误或者不可逆的操作吗?如果是,绝对不能丢。第二,这条消息能否从其他数据源重算出来?比如埋点类、统计类、报表类消息,通常可以从数据库或者日志重算,这类可以丢。第三,消息的保留期还有多久?如果积压量已经超过了消息中间件的保留时间,那些过期消息其实已经被删了,这时候讨论丢不丢已经晚了,重点是补数据。

真的是可丢的类型,操作也要规范。不要直接重置位点跳到最新,那样丢了多少、丢了哪些完全不知道。正确做法是记录下当前位点和目标位点的范围,把这段范围内的消息 key 抽样记录下来,有条件的话整体落一份冷备,然后再跳。事后用对账任务核一遍,确认没有影响到核心数据。

我自己的原则是:宁可多花两小时补数据,也不要在没搞清楚影响面的情况下丢消息。因为丢消息的锅是甩不掉的,而积压晚两小时消化完,用户最多抱怨一下,数据错了可能要赔钱。

4. 容量与监控:让积压提前被发现而不是半夜炸

4.1 消费能力的压测与容量公式

应急是治标,容量评估是治本。我习惯用这样一个公式倒推需要的实例数:

所需并发处理单元 = 峰值生产TPS × 单条平均耗时(秒) ÷ 目标水位 所需实例数 = 所需并发处理单元 ÷ 单实例并发度

举个具体的:业务峰值 5000 TPS,单条处理耗时 20 毫秒,目标水位取 0.7(意思是平时只跑到 70% 的处理能力,留 30% 余量应对突发),单实例开 8 个消费线程。那么所需并发单元是 5000 × 0.02 ÷ 0.7 ≈ 143,所需实例数是 143 ÷ 8 ≈ 18 个。

注意这个公式里有两个地方最容易出错。第一个是"单条平均耗时"要用实测的 P95 而不是拍脑袋,因为长尾消息才是拖垮批处理的主力。第二个是目标水位不能取 1.0,取 1.0 意味着平时就跑满,一点突发就崩。

压测怎么做才有参考价值?三个要点。一是数据要真实,用生产的消息样本而不是造几条简单 JSON,消息体大小、字段复杂度都要接近真实。二是要单实例限速压,一次压 20 个实例你根本看不出单实例的上限在哪。三是要观察 P99 而不是平均值,并且记录下"耗时开始明显上升的那个 TPS 拐点",那个拐点就是你真实的容量上限,通常比理论值低 30% 到 50%。

还有一点经常被遗漏:容量评估要做全链路,不是只看消费端。消费端能处理 5000 TPS,但下游数据库扛不住 5000 次的写入,实际容量还是被下游卡住。所以压测的时候要同时盯下游的负载,找到整条链路里最弱的那个环节。

4.2 该盯哪几个指标,阈值怎么定

监控指标不在多,在能提前预警。我一般配这几类。

第一类是Lag 本身,要监控三个维度:总量、单分区最大值、增长率。只看总量不够,因为总量可能被少数几个分区拉高;只看增长率的绝对值也不够,因为大流量 topic 和小流量 topic 的合理增长量完全不同。我通常配两级告警:Lag 超过日常峰值的 3 倍触发预警(发消息),超过 10 倍或者增长率连续 3 个采集周期为正且斜率上升触发告警(打电话)。

第二类是生产 TPS 与消费 TPS 的差值。这个指标比 Lag 更灵敏,Lag 是结果,差值是趋势。差值持续为正超过五分钟,基本可以确定要出事,这时候介入还来得及。

第三类是消费耗时的 P99。这个指标能提前发现下游劣化。P99 从 20 毫秒涨到 200 毫秒,Lag 可能还没明显变化,但你已经知道自己处在悬崖边上。

第四类是消费组的稳定性,具体就是 rebalance 次数和当前消费者数量。正常情况下 rebalance 次数应该接近零,消费者数量应该稳定等于实例数。任何一个发生波动都要立刻查,因为这是停型积压的前兆。

第五类是消费线程池的活跃度和排队深度。活跃线程数长期打满说明处理能力饱和,排队深度持续上涨说明处理速度跟不上拉取速度,这两个信号比 Lag 出现得早得多。

阈值不要一次定死,我的做法是先跑一周观察基线,取日常峰值作为参考,然后按 3 倍、10 倍两档设。设完还要定期复盘——如果某档告警一个月内触发了十次全都是误报,说明阈值太敏感;如果从来没触发过,可能是太松了。指标的价值在于被信任,天天狼来了的告警等于没有告警。

4.3 消费端设计的四条铁律

长期来看,避免消息积压靠的不是应急能力,是消费端的架构设计。我把这些年的经验浓缩成四条,写进了团队的设计规范里。

第一条,消费必须幂等。这是所有其他设计的前提。因为无论是重试、rebalance 还是分流方案,都会导致消息被重复投递。实现方式有几种:用业务唯一键做去重表、用状态机限制状态流转(比如订单只能从"待支付"流转到"已支付",重复消息来的时候状态已经不匹配直接丢弃)、或者用消息 ID 做幂等。没有幂等保障的消费逻辑,任何优化手段都不敢用。

第二条,消费端必须对下游限流。这条特别反直觉,因为大家习惯认为消费越快越好。但实际上消费端的吞吐能力受下游限制,无限制地拉取和处理只会把下游打死,然后下游变慢,消费变慢,积压更严重。正确做法是在消费端配置与下游承载能力匹配的并发度,同时在下游客户端配置合理的超时和熔断。宁可让消息在队列里排队,也不要让数据库雪崩。

第三条,业务之间必须隔离。不同重要级别的消息用不同的 topic、不同的消费组、不同的实例。把核心订单消息和埋点日志消息塞进同一个 topic,结果一定是埋点洪峰把订单消费拖垮。隔离的粒度按业务域来分,不要按消息类型分得太细,否则运维成本会爆炸。

第四条,消息必须可回放。消息的保留时间要留足,至少覆盖"问题被发现到被修复"的最长时间。我经手过一个案例,消息保留时间设置的 24 小时,结果一次发版引入了 bug,第二天早上才发现,想重放的时候消息已经被删了,只能手工从业务日志里一点点捞,花了整整两天。保留时间这件事,成本很低,价值极高。

5. 排查速查表与踩坑实录

5.1 现象到根因速查表

遇到积压的时候,我最常用的流程是从现象直接对到可能的原因,先做排除法缩小范围,再深入验证。

现象最可能的原因第一步验证方法
消费 TPS 平稳但明显低于生产 TPS单条处理耗时长,容量不足看单条耗时 P99,对比历史基线
消费 TPS 断崖归零且不恢复消费者进程挂了、线程池被打满、卡在毒丸消息上看实例存活、线程栈、日志中重复出现的消息 ID
消费 TPS 周期性掉零、Lag 只涨不跌触发 rebalance 风暴看消费组日志中的成员变更记录、单批处理耗时是否超过阈值
Lag 集中在少数几个分区消息 key 倾斜或该分区消费者异常看分区维度 Lag 分布、对应实例状态
Lag 上涨但消费 TPS 正常甚至偏高重复消费,或重试队列在膨胀分别看正常队列和重试队列的 Lag
扩容后 Lag 不降反升分区数不足导致空转,或 broker 被打满对比分区数与实例数、看 broker 网络和 CPU
消费端 CPU 打满但吞吐上不去日志过多、序列化开销大、GC 频繁看 GC 日志、日志级别、消息体大小
每隔固定时间出现一次积压定时任务批量生产消息对齐定时任务执行时间
单条消息重试次数异常高数据格式兼容性问题、下游接口变更抽样看失败消息的内容和异常栈

这张表不是万能的,但它能帮你在五分钟内把可能性从十几个收敛到两三个。我的经验是,真实事故里 70% 的情况能在这张表里找到对应行,剩下的 30% 往往是多个原因叠加,比如下游变慢导致 rebalance 又导致重复消费,这时候需要按顺序一个个剥离。

5.2 三个我踩过的坑

第一个坑:扩容了 30 个实例,Lag 纹丝不动。那次事故印象很深,订单消费 Lag 涨到 300 万,第一反应是扩容,从 8 个实例加到 40 个。加完等了十分钟,Lag 曲线一点变化都没有,消费 TPS 还是原来那个数。后来才想起来看分区数——只有 6 个。40 个实例里只有 6 个在干活,剩下 34 个在空跑。教训是:任何扩容动作之前,先做那个除法。

第二个坑:下游接口从 50 毫秒劣化到 3 秒,把整个服务拖死。那次是下游的查询接口因为索引失效变慢,消费端没有任何熔断和限流,所有消费线程都阻塞在等响应上。线程池打满之后,连健康检查线程都拿不到资源,整个服务被判死,然后触发 rebalance,然后重复消费,然后更慢。这是一个典型的级联失败。修复之后我们加了三条限制:单次调用超时 500 毫秒、并发度上限按下游承载能力配置、错误率超过 30% 自动熔断并让消息进重试队列。核心思路是:宁可让消息在队列里等着,也不能把整个服务拖垮。

第三个坑:一个默认配置引发的两小时事故。消费端用的是默认的批处理参数,一次拉 500 条,平时单条 10 毫秒所以一批 5 秒,完全没问题。某天下游抖了一下,单条变成 700 毫秒,一批就是 350 秒,超过了那个"两次拉取之间最大间隔"的默认值,消费者被踢出组。被踢之后位点回退,重新消费,还是同样的慢,还是被踢,陷入死循环,Lag 从 2 万涨到 90 万。解决其实很简单:把批处理量降到 100,同时把那个超时参数从默认值调大。但这个问题的排查花了一个多小时,因为监控上看消费端"活着",只是 TPS 时高时低,完全不像故障的样子。从此以后我们的规范里加了一条:所有批处理参数必须显式配置,不允许用默认值。

这三个坑的共同点是:它们都不是"技术难题",而是"常识被忽略"。扩容前算分区、调下游前设超时、上线前显式配参数,这些都是基本功,但在事故现场的高压下,人是会忘的。所以我现在特别看重预案和演练——把该做的动作提前写下来,出事的时候照着做,比临场发挥靠谱得多。

5.3 参数与经验值参考

下面这些是我在实际项目里验证过、相对稳妥的取值区间,不同中间件和不同版本的参数名不一样,但含义是相通的,可以按这个思路去对应。

参数类别建议取值说明
单批拉取条数50 到 200保证单批处理时间控制在 1 秒内,宁可多拉几次
两次拉取最大间隔单批处理时间的 3 到 5 倍给长尾消息留余量,但不要设得过大,否则真挂了的消费者迟迟不被发现
单实例消费线程数4 到 16按下游承载能力和 CPU 核数定,IO 密集型可以偏高
批量写库条数200 到 1000太小没收益,太大容易超时和锁表
消费端下游超时300 到 800 毫秒宁可失败进重试,也不要让线程长时间挂起
单条消息最大重试次数3 到 5 次超过转入死信队列,绝不无限重试
消息保留时间3 到 7 天覆盖"发现问题到修复完成"的最长周期
目标水位0.6 到 0.7平时不要跑满,留余量应对突发

6. 关于自动扩缩容和容灾隔离的取舍

6.1 该不该上自动扩缩容

聊到这里总有人问:能不能根据 Lag 自动扩缩容,彻底解放双手?我的答案是:可以用,但只作为兜底,不能作为主力。

原因是自动扩缩容有三个绕不过去的限制。第一,扩容受分区数约束,你自动加到 100 个实例,分区只有 12 个,加多少都是白费,而分区数通常不能自动调整。第二,自动扩容的生效周期太长,从检测到 Lag 上涨、到决策、到实例启动、到分区分配完成,通常需要几分钟,而消费端的劣化往往是秒级的,等你扩完,问题可能已经演变成事故了。第三,自动扩容治标不治本,它掩盖了"为什么突然变慢"这个真问题,下次同样的原因还会再来一遍。

我的实践是:把自动扩容配置成"仅在 Lag 超过阈值且持续 15 分钟"时触发,作为人工介入的补充而不是替代。真正的主力还是告警加人工判断,因为绝大多数积压的根因不在容量上,而在某个具体的异常上——下游抖动、发版引入的 bug、数据格式变化,这些都不是加机器能解决的。

6.2 多机房、多集群场景下的积压隔离

单集群的积压相对好处理,一旦跨机房就复杂了。最常见的形态是消息在主集群生产和消费,某个机房的处理能力下降,整个消费组都受影响,因为消费者是跨机房共享同一批分区的。

应对思路是按地域拆分消费组。每个机房起一组消费者,订阅同一个 topic 但用不同的消费组名,各自处理各自负责的数据。这样某个机房出问题,至少另一部分数据还在正常流转。代价是幂等和数据一致性要做得更严,因为两个消费组可能处理到同一条消息。

另一个思路是关键业务独立部署。把最重要的那几个 topic 单独放在一个集群上,不和日志、埋点、报表类消息混在一起。这个方案成本高,但对核心业务的稳定性提升非常明显。我经手过一个系统,把订单相关的三个 topic 拆到独立集群之后,一年内没再因为其他业务的流量洪峰被影响过,这笔账算下来是划算的。

还有一点容易被忽略:跨机房的消息重试策略要单独设计。如果重试机制是"失败后立刻重投原队列",在机房级故障时会造成重试风暴,本地本来就紧张的资源被重试消息进一步挤占。更好的做法是加退避,失败后延迟一段时间再重投,并且限制单位时间内的重投速率。

返回列表