zhaojie.tech/me
消息与分布式事务 2026-10-11T00:00:00.000Z

f02-ttl-dlx-delay-retry


title: TTL + 死信交换机:让失败消息自动延迟重试,listener 零阻塞 date: 2026-10-14 tags: [RabbitMQ, 消息队列, 架构]

TTL + 死信交换机:让失败消息自动延迟重试,listener 零阻塞

门店的小票打印机偶尔离线。最早的实现很”自然”:listener 消费失败,Thread.sleep(1000) 睡一秒再重试。结果某天三台打印机同时掉线,listener 容器的并发线程全部在 sleep,打印队列瞬间堆积——正常的打印请求也被堵在后面打不出来,收银台排起队。

根子在于:重试的等待时间,不应该由工作线程自己付。

方案推演:四种延迟重试的姿势

把”等一会儿再试”拆开看,候选有四种:

  1. Thread.sleep / requeue=true:要么占着 listener 线程干等,要么把消息塞回队首立刻重投——毒消息会以 CPU 打满的方式死循环,正常消息被堵死。最先排除。
  2. 重试表 + DB 轮询:失败落表,定时任务扫描重投。通用,但为了一个重试引入一张表加一个轮询器,还多了一层轮询延迟。
  3. rabbitmq-delayed-message-exchange 插件:消息级任意延迟,最灵活。但延迟消息存在 Erlang 节点的 Mnesia 表里,量大后有性能与可靠性上限,还得每台 broker 装插件。
  4. TTL + 死信交换机(DLX):零插件,纯 AMQP 原生能力。代价是延迟档位要预先定好。

我的场景只需要 1s/2s/4s 的固定梯度退避(打印这种近场任务,超过三档还没成功就该进死信人工看了),档位固定正好把方案 4 唯一的缺点抵消掉。选 4。

实现:三个”没有消费者”的延迟队列

拓扑长这样:主队列 queue.print.render 消费失败后,按重试档位投进 delay.s1/s2/s4 三个队列;这三个队列一个消费者都不挂,消息在里面躺着等 TTL 到期,到期后被死信交换机自动路由回主队列:

@Bean
public Queue printRenderDelayS1() {
    return QueueBuilder.durable("print.render.delay.s1")
            .withArgument("x-message-ttl", 1000)          // 躺 1 秒
            .withArgument("x-dead-letter-exchange", "print.render")
            .withArgument("x-dead-letter-routing-key", "print.render.task")
            .build();   // 到期 → 死信 → 回主队列,形成 1s→2s→4s 梯度
}

s2/s4 同构,TTL 分别是 2000/4000ms,梯度由此形成:第一次失败等 1 秒,第二次等 2 秒,第三次等 4 秒。主队列自身的声明同样只多两个参数——x-dead-letter-exchange 与 x-dead-letter-routing-key,指向死信队列 queue.print.render.dlq,重试超限的消息 basicReject(不 requeue)后落进去,留给人工处理。

消费端全局 AcknowledgeMode.MANUAL,顺序很关键:可重试失败——先把带重试计数的消息 publish 到下一档延迟队列、再对原消息 basicAck(不能反过来:主队列配了 DLX,直接 basicReject(requeue=false) 的可重试消息会抢先落进 DLQ,与延迟副本形成两份)。只有重试超限才 basicReject(requeue=false),经 DLX 进死信队列留给人工——listener 线程从头到尾不做任何等待,投完立刻去消费下一条。

这里必须较真一下 ack 语义,因为它和”假死”直接相关:消息一旦投给消费者就进入 unacked 状态,如果处理失败后既不 ack 也不 reject(比如异常往上抛被吞了),unacked 会一路堆到 prefetch 上限,broker 认为你”吃不下了”就停止投递——容器看起来活着,实际上一条消息都进不来。所以纪律是:ack/reject 必须覆盖 try 的所有出口,正常路径 ack;可重试路径先把消息 publish 进下一档延迟队列、再 ack 原消息(reject 会抢在延迟副本之前落 DLQ,形成两份);重试超限才 reject,经 DLX 落 DLQ 等人工。三个延迟队列就是给第二条出口准备的”候车室”。

坑:TTL 的队头阻塞

这里有个必须提前知道的坑,也是面试常问的陷阱:RabbitMQ 的过期消息只从队头检查。如果给单条消息设置 TTL,一条 4 秒的消息排在队头,后面跟着一条 1 秒的——那 1 秒的必须等前面的过期出队后才轮得到,实际延迟变成 4 秒+。

所以绝不在同一队列里混不同 TTL 的消息。我的做法是队列级固定 TTL、按档位分队列:想等 1 秒就去 s1 排队,想等 4 秒去 s4,同队列内的 TTL 完全一致,队头阻塞无从谈起。代价就是延迟档位是死的——但如上所述,这个场景要的恰恰是可预期的固定梯度。

另外一个小坑:Spring 的容器工厂如果自己 new 了一个 SimpleRabbitListenerContainerFactory,必须显式 setConnectionFactory——yml 里 spring.rabbitmq.listener.* 只作用于自动配置的默认工厂,自定义工厂拿不到,连接直接起不来。

收尾一句话

重试的等待应该由队列承担,而不是由线程承担:TTL 是消息的睡眠,不是线程的睡眠。

← 全部文章 赵杰 · me.zhaojie.tech