2604 字
约 8 分钟
18
S14-X20 · 死信交换机与延迟队列:DLX 拓扑与 TTL 的真实边界

"消息 30 分钟后处理"这个需求,在 RabbitMQ 上有两条完全不同的实现路径,行为差异却常在出事后才被发现。DLX(Dead Letter Exchange)本来是失败消息的兜底通道,被拿来当延时器用;TTL 有两个作用域,选错就会遇到队头阻塞。这篇把拓扑、参数和失效条件摊开看。


1. 死信的四个触发条件

消息变成死信不是配置出来的,是被"推"出来的。触发条件固定,且每条都对应一个可观测行为。

触发条件 触发动作 可观测现象
被拒绝且不重回队列 basic.reject / basic.nack 且 requeue=false 队列 ready 数下降,死信队列上升
消息 TTL 到期 消息级 expiration 或队列 x-message-ttl 到期后从原队列消失
队列长度超限 x-max-length / x-max-length-bytes 溢出 队头最老的消息被挤出
仲裁队列投递次数超限 delivery-limit 达到上限 仅在 quorum queue 上出现
// 死信链路的声明顺序:先交换机,再队列,最后绑定
channel.exchangeDeclare("biz.exchange", "direct", true);
channel.exchangeDeclare("biz.dlx", "direct", true);

Map<String, Object> args = new HashMap<>();
args.put("x-dead-letter-exchange", "biz.dlx");
args.put("x-dead-letter-routing-key", "biz.dead");
channel.queueDeclare("biz.order.q", true, false, false, args);
channel.queueBind("biz.order.q", "biz.exchange", "order.created");
channel.queueDeclare("biz.dead.q", true, false, false, null);
channel.queueBind("biz.dead.q", "biz.dlx", "biz.dead");

两个参数要成对看:前者决定"去哪儿",后者决定"用什么路由键"。不写后者时沿用原 routing key——DLX 是 direct 且无匹配绑定时,消息会被静默丢弃。


2. 队列参数:一张表管住所有声明

声明参数是队列创建时的不可变属性,改参数只能删队列重建。这是"删了队列重启服务就好了"这类事故的根源。

参数 作用 不可变性影响
x-message-ttl 队列级存活时间(毫秒) 改值需重建队列
x-expires 队列空闲多久自动删除 与 TTL 语义完全不同,别混用
x-max-length 最大消息条数 溢出行为由 x-overflow 决定
x-max-length-bytes 最大总字节数 与条数限制同时生效,先到先触发
x-overflow drop-head(默认)/ reject-publish 后者会反向压测生产者
x-dead-letter-exchange 死信出口 未配置则死信直接丢弃

x-expires 常被误当 TTL 用:它删的是整个空队列,不是队列里的消息;只要还有消费者连着就不算空闲。


3. 两条延迟路线:TTL+DLX 与延迟插件

维度 TTL + DLX rabbitmq_delayed_message_exchange Redis ZSet / RDelayedQueue
精度 受队头扫描与消费速度影响 由插件定时器驱动,粒度较细 受后台搬运轮询间隔影响
队头阻塞 有(消息级 TTL 场景) 无 无
额外组件 无 需启用插件 需 Redis
顺序保证 同队列内 FIFO 到期顺序近似 按分数排序
运维复杂度 低 中(插件版本兼容) 中(多一个存储)
适合规模 中小流量 中高流量 已有 Redis 的架构

插件路线要注意版本:rabbitmq_delayed_message_exchange 是社区维护插件,按所用 RabbitMQ 版本核对其可用性,升级 broker 前先确认插件是否有对应版本,否则节点起不来。

# 插件启用(容器镜像里通常需要先拷贝 .ez 到 plugins 目录)
rabbitmq-plugins enable rabbitmq_delayed_message_exchange
rabbitmq-plugins list | grep delayed

启用后交换机类型变为 x-delayed-message,延迟时间写在消息头:

Map<String, Object> args = new HashMap<>();
args.put("x-delayed-type", "direct");
channel.exchangeDeclare("delay.exchange", "x-delayed-message", true, false, args);

AMQP.BasicProperties props = new AMQP.BasicProperties.Builder()
        .headers(Map.of("x-delay", 30 * 60 * 1000))   // 毫秒
        .deliveryMode(2)
        .build();
channel.basicPublish("delay.exchange", "order.timeout", props, body);

x-delay 是消息头而非队列参数,这一点是它与 TTL 路线的本质区别:延迟信息跟着消息走,所以不存在按队列顺序排队的问题。


4. 队头阻塞:消息级 TTL 的真实陷阱

维度 队列级 TTL(x-message-ttl) 消息级 TTL(expiration)
设置位置 队列参数 每条消息的属性
过期判定 队头消息到期即出队,后续连续判定 只判定队头
混合 TTL 同队列统一 允许不同值,但短 TTL 会被长 TTL 挡住
典型后果 到期时间一致 后进的短延时消息被前面长延时消息压住

这就是"设了 5 分钟延迟、消息 30 分钟才到"的机制解释:只在消息到达队头时才检查 expiration,前面那条 30 分钟的没到期,后面 5 分钟的就轮不到检查。所以同一延迟队列别混用 TTL,按档位拆队列或用插件。

// 正确做法:按延迟档位拆队列,每队列统一 TTL,全部死信到同一出口
for (int minutes : new int[]{1, 5, 30}) {
    Map<String, Object> a = new HashMap<>();
    a.put("x-message-ttl", minutes * 60 * 1000);
    a.put("x-dead-letter-exchange", "delay.dlx");
    a.put("x-dead-letter-routing-key", "delay.due");
    channel.queueDeclare("delay." + minutes + "m", true, false, false, a);
    channel.queueBind("delay." + minutes + "m", "delay.topic", "delay." + minutes + "m");
}

delay.due 队列上的消费者拿到的是"已经到期"的消息,业务侧不需要再判断时间戳——判断逻辑留在拓扑里,比留在代码里更难写错。


5. Redis 侧对照:RDelayedQueue 的工作方式

已有 Redis 的架构常直接用 Redisson 的延迟队列,省掉一套 RabbitMQ 拓扑。它的结构是"ZSet 存到期时间 + 目标阻塞队列"。

Config cfg = new Config();
cfg.useSingleServer().setAddress("redis://127.0.0.1:6379");
RedissonClient redisson = Redisson.create(cfg);

// 目标队列必须是阻塞队列,RDelayedQueue 只负责"搬运"
RBlockingQueue<String> target = redisson.getBlockingQueue("delay:target");
RDelayedQueue<String> delayed = redisson.getDelayedQueue(target);

delayed.offer("order-1001", 30, TimeUnit.MINUTES);   // 到期后出现在 target
String msg = target.take();                          // 阻塞取
关注点 行为 影响
搬运机制 后台任务周期性把到期元素从 ZSet 移到目标队列 到期时刻与实际可见时刻有毫秒级到秒级偏差
目标队列类型 必须是 RBlockingQueue 用普通 RQueue 拿不到到期消息
重复投递 多实例同时运行搬运任务 目标队列可能重复收到同一条,消费端要幂等
持久性 依赖 Redis 持久化配置 appendonly no 时重启丢任务
取消延迟 delayed.remove(msg) 只在未搬运前有效

最后一行容易忽略:remove 只对还在 ZSet 里的元素有效,搬运之后取消无效,业务仍要按"可能已执行"处理。


6. 消费端的三个必备动作

channel.basicConsume("biz.order.q", false, (tag, msg) -> {
    try {
        handle(msg);
        channel.basicAck(msg.getEnvelope().getDeliveryTag(), false);
    } catch (PermanentException e) {
        // 明确不可重试:进死信,不重回队列
        channel.basicNack(msg.getEnvelope().getDeliveryTag(), false, false);
    } catch (Exception e) {
        // 临时故障:重回队列,交给重试策略
        channel.basicNack(msg.getEnvelope().getDeliveryTag(), false, true);
    }
});
动作 语义 误用后果
basicAck 确认消费完成 忘记 ack → ready 数不动、内存涨
basicNack(requeue=true) 退回队列重投 无限重投,CPU 打满
basicNack(requeue=false) 进死信 死信队列无人消费 → 静默堆积
basicQos(prefetch) 每消费者未确认上限 不设则一次推满,内存与延迟双爆

requeue=true 的重投没有退避,失败消息立刻回到队头反复被投,这是"消费者 CPU 占满但业务没进展"的典型成因。带退避的重试要在业务层做计数,或直接交给死信补偿。


7. 排错:六种典型故障

现象 根因 定位
死信队列一直是 0 未配 x-dead-letter-routing-key 且 DLX 无匹配绑定 rabbitmqctl list_bindings 看 DLX 绑定
延迟消息早到或晚到 消息级 TTL 队头阻塞 检查同队列是否混用 expiration
队列声明报 PRECONDITION_FAILED 参数与既有队列不一致 rabbitmqctl list_queues name arguments 对比
插件启用后节点起不来 插件版本与 broker 不匹配 启动日志里找 badmatch / beam 崩溃
消息被"吃掉"无痕迹 死信路由不到任何队列 给 DLX 绑一个兜底队列
重启后延迟任务消失 Redis 未开 AOF/RDB CONFIG GET appendonly

PRECONDITION_FAILED 意味着"要声明的队列与线上已有的不是同一个",broker 拒绝覆盖。只有两条路:删队列重建(丢消息),或改代码适配旧参数。把队列参数版本化,比事后救火便宜。


8. 边界:这些需求不属于延迟队列

需求 更合适的方案 理由
精确到秒的定时任务 调度系统(cron / Quartz / xxl-job) 消息队列的到期判定不是定时器
超大延迟(天级) 数据库存到期时间 + 扫描 消息在队列里躺几天,成本与风险都高
需要取消与改期 业务表 + 调度器 队列里的消息改不了
强顺序 + 延迟 单队列单消费者 + 分区键 延迟与顺序天然冲突
百万级延迟任务 时间轮 / 分层时间堆 每任务一条消息的存储成本过高
需要"至少一次且不重复" 消费端幂等 + 去重表 任何延迟方案都只能保证至少一次

最后一条是通用前提:DLX 与延迟队列都假设"可能重复、可能延迟、可能乱序"。幂等做在消费端,拓扑怎么换都不慌。


9. 自测

  1. 死信的四个触发条件分别是什么?哪个只在仲裁队列上出现?
  2. 只配 x-dead-letter-exchange 不配 routing key,消息会去哪儿?
  3. 为什么同一队列混用不同 expiration 会导致短延迟消息被压住?
  4. x-expires 与 x-message-ttl 删除的对象有什么不同?
  5. 延迟插件的 x-delay 为什么写在消息头而不是队列参数里?
  6. RDelayedQueue.remove 在什么时点之后失效?业务侧应如何兜底?
S14-X20 · 死信交换机与延迟队列:DLX 拓扑与 TTL 的真实边界
https://www.clxhxhhr.top/posts/6029/
作者
clxstart
发布于
2024-12-31
许可协议
CC BY-NC-SA 4.0
评论
0 条
还没有评论,先写一条吧。