凌晨两点被电话叫起来,打开监控面板,消费者 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 拆到独立集群之后,一年内没再因为其他业务的流量洪峰被影响过,这笔账算下来是划算的。
还有一点容易被忽略:跨机房的消息重试策略要单独设计。如果重试机制是"失败后立刻重投原队列",在机房级故障时会造成重试风暴,本地本来就紧张的资源被重试消息进一步挤占。更好的做法是加退避,失败后延迟一段时间再重投,并且限制单位时间内的重投速率。