"消息 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. 自测
- 死信的四个触发条件分别是什么?哪个只在仲裁队列上出现?
- 只配
x-dead-letter-exchange不配 routing key,消息会去哪儿? - 为什么同一队列混用不同
expiration会导致短延迟消息被压住? x-expires与x-message-ttl删除的对象有什么不同?- 延迟插件的
x-delay为什么写在消息头而不是队列参数里? RDelayedQueue.remove在什么时点之后失效?业务侧应如何兜底?