小Cの已经记不起来的博客

一次线上 消息队列 故障的复盘记录

一开始我以为是消费端挂了

上周四下午三点半左右,订单服务那边开始报“状态不更新”,客服群里也有人问为什么下单成功之后页面一直转圈。监控先是消费延迟上涨,接着 RabbitMQ 管理台里几个核心队列深度开始往上窜。

第一反应还是那个最常见的误判:消费者是不是挂了?或者线程池被占满了?于是先看应用日志,没看到明显异常,只有一些慢查询提示。再去看消费者线程,也都还在,没有 OOM,也没有大量报错。当时我还想,既然消费者还活着,那就先观察一会儿,说不定高峰过去就好了。

结果看了一眼 RabbitMQ 控制台,才发现事情比我想的麻烦。order.notifyorder.retry 两个队列堆了不少消息,而且不是普通堆积,是消息像滚雪球一样一直增加。有些消息反复出现在 retry 队列里,间隔几秒又回到原队列,像是被弹来弹去。

真正看到队列数据的时候有点懵

先执行了一串命令看队列状态:

rabbitmqctl list_queues name messages messages_ready messages_unacknowledged consumers memory

输出大致是这样:

name              messages    ready    unacked    consumers    memory
order.notify      186432      179210   7222       12           812MB
order.retry       94211       91200    3011       0            398MB
order.dlq         12          12       0          0            12MB

这个结果有点不对劲。order.retry 队列有将近九万条消息,但是消费者数量是 0。当时心里咯噔一下,因为重试队列没有消费者其实不一定有问题,它可能只是靠 TTL 把消息过期后通过死信路由回原队列。但如果 TTL 很短,原队列消费又慢,就会形成一个循环:原队列消费失败,消息进 retry,retry 过期,消息回原队列,原队列又失败,再进 retry。

这玩意儿真的会让人头皮发麻。你以为是在重试,实际上是在无限循环。

然后又看了一眼当前连接和通道:

rabbitmqctl list_channels consumer_count prefetch_count messages_unconfirmed messages_unacknowledged

有几个消费端通道 prefetch_count 很大,达到 5000。当时发版前有人觉得“吞吐高一点应该更好”,于是把预取数调大了。现在看起来,这反而让少量慢消费者占满了大量未确认消息,管理台里 unacked 一堆,真实处理能力却上不来。

翻配置和代码,发现一个很小的坑

继续翻代码和配置,发现重试逻辑确实是我之前写的那一套:消费者抛异常后,不直接进 DLQ,而是发布到 order.retryorder.retry 配置 TTL,到期后通过死信路由回 order.notify

RabbitMQ 那边大概是这个思路:

queues:
  - name: order.retry
    arguments:
      x-message-ttl: 5000
      x-dead-letter-exchange: order.direct
      x-dead-letter-routing-key: order.notify

  - name: order.notify
    arguments:
      x-dead-letter-exchange: order.dlq
      x-dead-letter-routing-key: order.dlq

代码里失败时把消息重新发到 retry:

try {
    consume(orderEvent);
} catch (Exception e) {
    rabbitTemplate.convertAndSend("order.retry", event);
    channel.basicNack(deliveryTag, false, false);
}

看起来好像很合理,对吧?失败就重试,重试就等几秒,等一会儿再回主队列消费。但问题在于,消息里没有计数。也就是说,只要它每次回到 order.notify 之后还是失败,就会再次进 retry,再回主队列,再失败,再来一次。没有“第几次失败”,也没有“最多重试几轮”。

而当天线上数据库慢查询持续了一段时间,导致消费逻辑超时。于是大量消息开始这个循环。更巧的是,重试队列 TTL 之前被从 60000 改成过 5000,理由是“希望订单状态更快刷新”。这个改动单看没什么,但配合上无限重试,就变成了把消息以每五秒一次的频率往主队列里打。

我当时看到这段配置的时候真的有点无语。为什么没有加重试次数呢?可能是发布节奏太赶,也可能是我以为重试队列天生安全。现在回头看,这个假设挺危险的。消息队列里很多事故都不是“完全不懂的人搞出来的”,而是“懂一点配置,但没想完整”的时候搞出来的。

临时处理:先把火灭了

处理第一步不敢乱动,先停掉部分消费者,避免更多 unacked 消息被压在线程里。然后降低预取:

rabbitmqctl list_consumers

确认哪些通道占着大量未确认消息之后,把对应服务实例滚动重启。重启后先把 prefetch_count 改小:

spring.rabbitmq.listener.simple.prefetch=50
spring.rabbitmq.listener.simple.concurrent-consumers=4
spring.rabbitmq.listener.simple.max-concurrent-consumers=12

这里没有继续追求“高并发”,先把系统稳住再说。之前那个 prefetch=5000 的改动,本质上是把一个队列变成了少数线程的缓冲区。缓冲区太大,一旦下游数据库变慢,故障会被放大。

然后把 order.retry 的 TTL 临时改大,减少消息回流频率。这个改动没有解决根因,但至少能让循环慢下来,给后续清理和修复争取时间:

rabbitmqctl list_policies

因为队列参数不能直接改,所以用了 policy 覆盖:

rabbitmqctl set_policy retry-ttl "^order\\.retry$" '{"message-ttl":60000}' --apply-to queues

注意,这只能覆盖部分运行时行为。为了减少风险,后来又临时把主队列消费者扩容,并且把非核心通知类消费者先停掉,让核心订单状态消费优先。处理过程中最难受的不是不知道问题在哪,而是明知道要动线上配置,却必须非常小心,怕一重启把更多 unacked 消息重新入队。

改配置:重试不能没有刹车

火稍微压住之后,开始真正补逻辑。最关键的是给重试消息加次数,超过上限就进 DLQ,不再回到主队列。

RabbitMQ 侧保留 retry 队列,但消费者不再无脑把失败消息扔回去:

try {
    consume(event);
    channel.basicAck(deliveryTag, false);
} catch (Exception e) {
    int retryCount = getRetryCount(message);
    if (retryCount >= maxRetry) {
        sendToDlq(event, retryCount);
    } else {
        sendToRetry(event, retryCount + 1);
    }
    channel.basicNack(deliveryTag, false, false);
}

重试次数放在消息 header 里:

private int getRetryCount(Message message) {
    Long value = message.getMessageProperties().getLong("x-retry-count");
    return value == null ? 0 : value.intValue();
}

发送 retry 的时候带上次数和原因:

rabbitTemplate.convertAndSend("order.retry", event, m -> {
    m.getMessageProperties().setHeader("x-retry-count", nextCount);
    m.getMessageProperties().setHeader("x-retry-reason", e.getClass().getSimpleName());
    return m;
});

超过次数之后进死信队列:

queues:
  - name: order.dlq

死信队列不自动消费,先由人工或巡检任务处理。这里我故意没有继续用死信路由回 retry,否则很容易又绕一圈回去。重试的终点必须是明确的,要么成功,要么进入人工干预区。

后来还把主队列的死信策略也收紧了:

queues:
  - name: order.notify
    arguments:
      x-dead-letter-exchange: order.dlq
      x-dead-letter-routing-key: order.dlq

这样即使消息在主队列异常丢弃,也能落到 order.dlq,而不是被 retry 悄悄捞回去。

复盘出来的几条教训

这次事故表面上看是消息堆积,根子上其实有三件事没做好:重试没有上限、预取数没有约束、死信链路没有画清楚。

先说重试。以前我一直觉得“重试队列”是消息队列里的标准玩法,但忽略了一个点:重试不是默认安全的。只要失败条件还在,重试就会把故障放大。数据库慢一点,应用线程卡一点,消息本来只是延迟,重试风暴一来就变成雪崩。所以重试一定要有次数,有退避,有上限,有最终落点。

再说 prefetch_count。这个东西真的很微妙,太小会影响吞吐,太大又容易把未确认消息堆在客户端本地。线上稳定期可以稍微高一点,但只要下游有数据库、第三方接口这种慢变量,就应该谨慎。prefetch=5000 在压测里可能看起来很爽,因为压测下游通常很快;一到线上,数据库抖动一下,几百个消息就卡住了。

还有配置变更。x-message-ttl60000 改成 5000,这个改动看起来只是让重试更快一点,但实际改变了系统的时间常数。这种变更如果没有压测、没有回滚方案、没有监控看板,很容易出问题。后来我们补了一个规则:任何队列 TTL、重试、死信路由的变更,都要附带一张图,画出消息完整流向,从发布到成功、失败、retry、DLQ、人工恢复。没图不审。

监控也补了一些,比如队列深度增长斜率、retry 队列重复率、消息 retry-count 最大值、DLQ 增长、消费者 unacked 数量。以前只盯 messages_ready,这次发现 unacked 和 retry 队列才是更容易先变形的地方。

还有一点是业务幂等。这次有些消息被重复消费过,好在订单状态更新做了幂等判断,否则问题会更难看。消息队列故障里,很多时候不怕消息多,怕的是重复消息把状态改坏。所以重试必须配合幂等,不然你就是在给系统反复投掷同一个包裹,祈祷消费者别被砸晕。

最后,我其实挺庆幸这次没有直接去“清空队列”或者“删消费者”。当时手确实痒,看到堆积那么多,很想一把梭哈。但线上消息队列很多时候不能靠狠,只能靠慢。先把循环切断,先把下游恢复,再把死信和积压分清楚。真正稳定的处理,往往不是一行神操作,而是一堆看着很笨的确认步骤。

评论

还没有评论。

发表评论

提交后评论将经过自动审核,审核通过后公开展示。

未在播放