1. 延迟消息不是可有可无的锦上添花先抛出结论RabbitMQ 本身并没有原生地提供“延迟队列”这个概念但业务里到处都是延迟消息的影子。做过电商、订单、支付、售后这类系统的人对“延迟消息”这三个字肯定不陌生。订单创建后 30 分钟未支付要自动关闭支付结果回调失败后 5 秒重试用户注册后 15 分钟发送提醒这些需求本质上都不是“立即处理”而是“过一段时间再处理”。用 RabbitMQ 做延迟消息就是把这句“过一段时间”从业务代码里抽出来交给消息组件去调度。这一章我会从两个角度把延迟消息讲透一是经典的 TTL 死信队列方案二是延迟消息插件方案。两种方案我在项目里都用过各有各的适用场景也各有各的坑。我会先讲清楚原理再给出可以直接抄的 Spring Boot 代码最后把生产环境最容易踩的问题列成排查清单。如果你是刚开始接触 RabbitMQ跟着这篇走完至少不会在选型的时候一脸懵。1.1 项目里哪些地方非用延迟消息不可延迟消息最常见的价值是把“等待”这件事从同步流程里拆出去。举几个我实际见过的场景订单超时自动关闭用户下单后不支付30 分钟后系统自动把订单状态改成已关闭释放库存。支付通知重试对接第三方支付时回调可能偶尔失败这时候消息先等 5 秒再重试而不是立刻再打一次接口。用户行为提醒用户下单后一段时间没付款发一条站内信或者短信提醒。任务延迟调度比如某个数据同步任务要在凌晨执行但不希望引入一套新的定时任务系统。你会发现这些场景有一个共同点业务不需要马上拿到结果但也不能完全不管否则就会出现“订单永远卡在待支付”“重试请求把下游打爆”这类问题。用数据库轮询也能做但延迟消息的好处是削峰、解耦、异步而且消息本身有 ACK 机制比自己在业务代码里写 Timer 要可靠得多。1.2 延迟消息到底在延迟什么先补一句背景RabbitMQ 里消息从生产者到消费者并不是直接塞进队列而是先发给交换机再由交换机根据 routing key 投递到绑定的队列。消费者只从队列里取消息。所以延迟消息想做的事情是在这条链路上找地方“扣住”消息让它晚一点进入目标消费者手里。目前主流做法有两种先说进场再说处理消息先进入一个普通队列但这个队列设置了消息过期时间消息一到时间就被当成死信转发到另一个队列真正的消费者监听那个队列。交换机暂时扣住引入一个特殊交换机生产者把延迟消息交给它它先不路由等时间到了再投递给目标队列。第一种方案完全依赖 RabbitMQ 自带的特性不需要装任何插件。第二种方案需要启用一个社区插件。下面两章我就分别拆这两个方案。1.3 两条路线怎么选先看对比表为了让你后面读代码的时候心里有数我先把两条路线放在一起对比。对比维度TTL 死信队列延迟消息插件 x-delayed-message原生支持RabbitMQ 自带能力需要单独启用插件延迟单位毫秒毫秒每条消息独立延迟可以但受队头阻塞影响可以比较灵活需要额外队列每个业务至少两套队列一套延迟交换机一套目标队列时序精度一般消息量大时容易不准相对稳定但不能当秒级定时器用运维成本低不引入额外组件插件需要在所有节点启用典型场景固定延迟级别、项目不让引入插件延迟时间灵活、按消息区分配置我的个人建议是如果公司对插件管控比较严或者你只想要固定几个延迟档位TTL 死信队列就很香如果业务里延迟时间千变万化同一个队列里的消息有的要等 10 秒有的要等 2 小时直接上插件省心得多。2. 方案一拆解TTL 死信队列的经典用法2.1 死信机制是怎么工作的RabbitMQ 里有一个概念叫 Dead Letter Exchange缩写是 DLX。它的作用非常像现实中的“转寄”一条消息在队列里如果触发了某些条件交换机不会直接把它丢弃而是按照队列上配置的“死信转发地址”重新投递到另一个队列去。触发死信的条件主要有三个消息被消费者主动拒绝并且requeuefalse队列里消息数量超过最大长度消息存活时间超过 TTL延迟消息用的就是第三种给队列设置x-message-ttl消息进去之后开始倒计时时间一到RabbitMQ 把它标记为死信然后投递到 DLXDLX 根据 routing key 找到死信队列真正的消费者从这个死信队列里拿消息。这里的链路是生产者 - 普通交换机 - 延迟队列(带 TTL DLX) - 消息过期 - 死信交换机 - 死信队列 - 消费者关键点在于消费者看到的不是“原消息”而是“过期后被转寄的同一份消息”。消息内容不变只是多经过了一次死信转发业务感知上就是“延迟了一段时间才被消费”。2.2 TTL 的两种设置方式TTL 有两种设置位置很多人会混淆。队列级 TTL在声明队列的时候给整个队列设置x-message-ttl。这个队列里的所有消息都共用同一个过期时间比较适合“统一延迟 30 分钟”这种固定场景。消息级 TTL生产者在发送每条消息的时候通过消息属性里的expiration字段单独指定过期时间。这种方式更灵活可以做到同一队列里消息 A 延迟 5 秒消息 B 延迟 5 小时。但这里有个重要注意点RabbitMQ 检查过期消息时只会看队列头部。如果队头的消息 TTL 很长排在它后面的消息哪怕实际已经到期也要等队头消息被消费或移除之后才轮到它检查。所以消息级 TTL 虽然灵活一旦你同一个队列里混着“短延迟”和“长延迟”时序就不一定准。这也是后面我会推荐插件的核心原因。2.3 代码落地Spring Boot 里的 TTL DLX 配置这个方案在 Spring Boot 里配置很直观。下面的代码是一个订单超时场景订单消息先进入order.ttl.queue30 分钟没被消费就自动跑到死信队列order.dead.queue。Configuration public class OrderDelayConfig { public static final String ORDER_TTL_EXCHANGE order.ttl.exchange; public static final String ORDER_TTL_QUEUE order.ttl.queue; public static final String ORDER_DLX_EXCHANGE order.dlx.exchange; public static final String ORDER_DEAD_QUEUE order.dead.queue; Bean public Queue orderTtlQueue() { return QueueBuilder.durable(ORDER_TTL_QUEUE) .withArgument(x-message-ttl, 30 * 60 * 1000) .withArgument(x-dead-letter-exchange, ORDER_DLX_EXCHANGE) .withArgument(x-dead-letter-routing-key, order.timeout) .build(); } Bean public TopicExchange orderTtlExchange() { return new TopicExchange(ORDER_TTL_EXCHANGE); } Bean public Binding orderTtlBinding() { return BindingBuilder.bind(orderTtlQueue()) .to(orderTtlExchange()) .with(order.create); } Bean public Queue orderDeadQueue() { return QueueBuilder.durable(ORDER_DEAD_QUEUE).build(); } Bean public TopicExchange orderDlxExchange() { return new TopicExchange(ORDER_DLX_EXCHANGE); } Bean public Binding orderDeadBinding() { return BindingBuilder.bind(orderDeadQueue()) .to(orderDlxExchange()) .with(order.timeout); } }生产者发送消息的时候并没有特别复杂的地方正常发到普通交换机就好了Service public class OrderProducer { private final RabbitTemplate rabbitTemplate; public OrderProducer(RabbitTemplate rabbitTemplate) { this.rabbitTemplate rabbitTemplate; } public void sendOrderTimeoutMessage(String orderId) { rabbitTemplate.convertAndSend( OrderDelayConfig.ORDER_TTL_EXCHANGE, order.create, orderId, message - { message.getMessageProperties().setExpiration(String.valueOf(30 * 60 * 1000)); return message; } ); } }这里的setExpiration是消息级 TTL。如果队列已经设置了统一 TTL生产者可以不设置。但要注意setExpiration接收的是字符串单位是毫秒千万别传成秒。消费者监听的是死信队列不是最初的延迟队列Component public class OrderTimeoutConsumer { RabbitListener(queues OrderDelayConfig.ORDER_DEAD_QUEUE) public void onOrderTimeout(String orderId) { // 到这里说明订单消息已经过期等价于“过了 30 分钟” System.out.println(订单超时需要关闭 orderId); } }这套设计你能看到业务上完全不需要自己维护定时器。RabbitMQ 帮你把“等待”这件事做完了消费者拿到消息的那一刻就是业务该动作的那一刻。2.4 这个方案的三个暗坑这个方案看着简单上线三个月后你才会发现它坑在哪。第一个坑是延迟级别一变就得重新声明队列。如果业务上“订单 30 分钟关闭”改成“15 分钟关闭”你不能直接在已有队列上改x-message-ttlRabbitMQ 不允许参数不一致时重复声明。所以固定延迟级别越多队列数量越多运维成本会慢慢上来。第二个坑是队头阻塞导致的延迟不准。刚才说了RabbitMQ 只看队头。假设你用一个队列做了 5 秒和 5 小时的两种延迟消息5 秒的消息可能被 5 小时的消息挡在后面实际变成 5 小时多以后才被处理。这种场景下千万不要用 TTL 死信做多级延迟要么拆队列要么换插件。第三个坑是没有补偿机制。TTL 死信本身只是“消息搬运”如果死信队列没人消费或者消费者逻辑抛异常过期消息就会一直堆积。你要有一套监控和手工补发的手段不能发完消息就撒手不管。3. 方案二拆解x-delayed-message 插件的取舍与实现3.1 插件到底做了什么TTL 死信的核心思路是“先进队列过期后再转寄”。而延迟消息插件换了一种玩法消息先不进入任何普通队列而是被一个特殊交换机扣住。这个交换机类型是x-delayed-message它内部相当于一个延迟存储区。生产者发消息时带上一个x-delay头单位是毫秒交换机先记下“这条消息 30 分钟后才放行”时间到了再按x-delayed-type指定的路由方式把消息投递给真实的队列。对比一下就能发现插件方案里的消费者不用监听死信队列它直接监听原本的业务队列就行。生产者发送的延迟消息在消费者看来只是“晚来了一些”其他没有任何区别。x-delayed-type这个参数很关键。它告诉插件时间到了之后用哪种交换机去路由。比如你原来想用 direct 就填direct想用 topic 就填topic。你可以理解为插件先当保姆照顾完消息之后还是把孩子交给原来的老师。3.2 安装与启用步骤插件本身在 RabbitMQ 发行包里一般已经集成不需要额外下载只需要在命令行启用。# 先看插件是否在列表里 rabbitmq-plugins list | grep delayed # 启用延迟消息插件 rabbitmq-plugins enable rabbitmq_delayed_message_exchange # 再确认已经启用 rabbitmq-plugins list | grep delayed启用后不需要重启 RabbitMQ 服务。但是如果你是集群环境所有节点都要执行一遍启用命令不然消息在节点之间转发的时候可能出现交换机构建失败。启用成功后在管理界面新建交换机时Type 下拉框里会出现x-delayed-message看到它就算插件生效了。3.3 配置类和生产消费代码Spring Boot 里声明这种交换机稍微特殊要用CustomExchange因为框架没有直接封装这个类型。Configuration public class DelayConfig { public static final String DELAY_EXCHANGE delay.exchange; public static final String DELAY_QUEUE delay.queue; Bean public CustomExchange delayExchange() { MapString, Object args new HashMap(); args.put(x-delayed-type, direct); return new CustomExchange( DELAY_EXCHANGE, x-delayed-message, true, false, args ); } Bean public Queue delayQueue() { return QueueBuilder.durable(DELAY_QUEUE).build(); } Bean public Binding delayBinding() { return new Binding( DELAY_QUEUE, Binding.DestinationType.QUEUE, DELAY_EXCHANGE, order.timeout, Collections.emptyMap() ); } }生产者发送消息时重点是在MessagePostProcessor里设置延迟时间Spring AMQP 提供了setDelay方法它对应的就是x-delay这个属性。public void sendDelayMessage(String orderId, long delayMillis) { rabbitTemplate.convertAndSend( DelayConfig.DELAY_EXCHANGE, order.timeout, orderId, message - { message.getMessageProperties().setDelay((int) delayMillis); return message; } ); }消费者这里的写法和普通消费者没有任何区别直接监听目标队列Component public class DelayMessageConsumer { RabbitListener(queues DelayConfig.DELAY_QUEUE) public void onMessage(String orderId) { // 到这里的消息已经是被延迟交换机放过一次“假”的消息 System.out.println(收到延迟后的消息 orderId); } }如果你想让同一个交换机支持多种业务把x-delayed-type从direct改成topic再通过不同的 routing key 绑到不同的队列。这样一套延迟交换机可以复用好几个业务不会出现 TTL 方案里套队列套到手软的情况。3.4 插件方案的适用边界插件方案确实方便但我不建议把它当成万能延迟组件。第一它是靠 Erlang 定时器来做延迟调度的。单条消息延迟时间越长或者同一时间积压的延迟消息数量越大调度压力就越大。延迟精度不是绝对的毫秒级只适合“过了大概 30 分钟再处理”这种容忍度高的场景。第二超长延迟不要硬塞。如果你要做“30 天后自动关闭某条记录”插件的调度和存储都比较吃力我更推荐把数据落到数据库写一个定时任务扫表。RabbitMQ 延迟消息适合分钟级别、小时级别的场景不是万能的定时任务替代品。第三生产环境必须考虑消息丢失。延迟插件解决的是“延迟”问题不是“绝不丢失”问题。真正重要的业务发送端要开 publisher confirm消费端要做好幂等数据库表里的状态才是最终依据消息只是辅助触发。4. 实战案例订单超时 30 分钟自动关闭4.1 需求拆解与选型我拿一个电商项目里最常见的需求订单创建后 30 分钟未支付就自动关闭。这里我用插件方案来落地因为订单超时时间项目里可能会动态调整用插件的话每条消息延迟时间独立可控运维也简单。整个链路是这样的订单服务创建订单成功后 - 发送一条延迟 30 分钟的消息 - 消息在延迟交换机里待着 - 时间到了交换机把消息路由到目标队列 - 订单超时消费者收到消息 - 查订单状态如果还是未支付就关闭。关键在于最后一步的幂等设计。因为消息在 30 分钟之后到达而这 30 分钟内用户可能已经支付了。消费者必须先从数据库里查一遍订单当前状态状态已经变了就直接忽略绝不能无脑关单。4.2 生产者订单创建后投递延迟消息生产者在订单创建成功的地方调用发送逻辑。消息体我建议只放订单号不要塞一整个订单对象。Payload 越小序列化越简单排查问题也越容易。Service public class OrderTimeoutProducer { private static final long ORDER_TIMEOUT_MS 30 * 60 * 1000L; private final RabbitTemplate rabbitTemplate; public OrderTimeoutProducer(RabbitTemplate rabbitTemplate) { this.rabbitTemplate rabbitTemplate; } public void sendOrderTimeoutMessage(String orderId) { rabbitTemplate.convertAndSend( DelayConfig.DELAY_EXCHANGE, order.timeout, orderId, message - { message.getMessageProperties().setDelay((int) ORDER_TIMEOUT_MS); return message; } ); } }这里setDelay接收的是 int 类型30 分钟换算成毫秒是 1800000完全在 int 范围内。如果你是更长时间的延迟也要注意类型溢出问题最好先把时间统一换算成毫秒再用。4.3 消费者收到消息先查库再改状态消费者是整个方案里最容易写出事故的地方。我看到过不少初学者一收到消息就去改订单状态完全不管当前状态最后用户刚支付完就被关单了。正确的做法是先查库、再判断、再修改。Component public class OrderTimeoutConsumer { private final OrderService orderService; public OrderTimeoutConsumer(OrderService orderService) { this.orderService orderService; } RabbitListener(queues DelayConfig.DELAY_QUEUE) public void onOrderTimeout(String orderId) { Order order orderService.getById(orderId); if (order null) { // 订单不存在可能是数据已经清理直接忽略 return; } if (!OrderStatus.UNPAID.equals(order.getStatus())) { // 已经支付、已经关闭、已经退款的状态都不处理 return; } orderService.closeOrder(orderId); } }你可能会问如果用户正好在消费这一刻完成了支付并发下会不会出问题答案是大概率会有极小概率的竞态。所以生产环境里关单前最好再加一个乐观锁或者版本号校验比如 SQL 里带上where status UNPAID更新影响行数为 0 就说明状态已经变化自然放弃关单。4.4 配套的补偿手段延迟消息做得再好也只能保证“大部分情况下消息会准时到”不能保证“消息永远不丢”。很多订单系统真出问题往往不是 MQ 的问题而是消费者代码抛异常、消息积压、网络分区导致消费停了。这时候订单就是没被关闭数据库里一堆待支付旧订单在那儿躺着。我建议在订单表里增加一个create_time索引写一个定时任务每隔 10 分钟扫一遍“创建时间超过 30 分钟且状态还是未支付”的订单执行补充关单。这个定时任务不是重复劳动而是兜底。延迟消息触发关单是“正常路径”扫描兜底是“异常路径”。没有异常路径的延迟消息方案是不完整的。5. 常见问题排查与避坑指南5.1 先学会看消息到底去哪了排查 RabbitMQ 延迟消息问题第一步永远是打开管理界面或者命令行看队列、看消费者、看消息数。# 查看所有队列和消息数量 rabbitmqctl list_queues name messages messages_ready messages_unacknowledged # 查看交换机 rabbitmqctl list_exchanges name type # 查看绑定关系 rabbitmqctl list_bindings source_name destination_name routing_key管理界面的 Queues 页面里重点看两个数字Ready 表示等待被消费的消息Unacked 表示已经发给消费者但还没确认的消息。如果 Ready 一直涨说明消费速度跟不上如果 Unacked 一直很高就要去看消费者业务代码是不是卡住了。延迟消息还有一个特点如果用的是 TTL 死信方案消息会先进原来的业务队列等过期后再进死信队列。所以排查时如果发现“原队列消息一直在减少但死信队列迟迟没有消息”先检查死信交换机有没有配再看死信队列有没有消费者绑定。5.2 高频问题速查表问题现象常见原因处理办法消息一直不消费队列没绑定交换机或 routing key 对不上检查 management 界面里的 binding用rabbitmqctl list_bindings确认消费者收不到延迟消息延迟交换机没有绑定目标队列确认绑定关系确认x-delayed-type是否和用的 routing key 类型匹配延迟时间完全不对TTL 方式下多条消息混在不同延迟级别队头阻塞拆分队列或改用插件方案消息偶发丢失生产者没开 confirm交换机不可持久化消费者自动 ack开启 publisher-confirm交换机/队列设置 durable消费端做好手动确认或重试插件版消息一直不路由x-delay未设置或延迟交换机类型声明失败检查生产端是否调用了setDelay确认插件在所有节点已启用队列堆积严重消费者处理太慢或消费端抛异常导致一直重投加消费者数量优化业务代码查询消费日志找到异常堆栈5.3 避免入坑的四个习惯第一个习惯延迟时间不要散落在业务代码里。我见过项目里到处写30 * 60 * 1000换一个需求就全局搜。更好的是放到配置中心或者一个常量类里统一管理。第二个习惯消息体要足够简单。body 里放一个订单号再放一个操作类型就够了。不要放几十个字段的大 JSON更不要把对象序列化后的二进制堆进去。消息越小传输越快排查时越容易在管理界面直接看清内容。第三个习惯消费端要做幂等。延迟消息最怕的是被业务重复处理。同一笔订单被关两次在库存、财务、售后上都会出连锁问题。幂等可以通过订单状态判断也可以单独建一张消息消费记录表。第四个习惯把死信队列利用起来。即使你用了插件方案业务队列也可以再绑定一个 DLX。当消费者重试多次还是失败时把消息丢进一个专门的失败队列方便人肉干预。延迟消息不代表业务处理一定成功失败消息得有一个去处而不是无限重试把 MQ 堆炸。6. 选型优先级与个人体会6.1 我推荐的选型维度如果你现在要在一个新项目里引入 RabbitMQ 延迟消息我的判断顺序是如果项目是内部系统容忍引入插件延迟时间又经常变化优先选x-delayed-message插件。代码量最少维护点最少。如果公司运维体系不允许装插件或者你只固定需要 5 秒、15 分钟、30 分钟这三种档位那就用 TTL 死信一个档位一组队列清晰可靠。如果是超过 24 小时的超长延迟别纠结 MQ 方案直接落库加定时任务扫描。RabbitMQ 不是数据库也不是 Cron。6.2 开发时的几个习惯我在实际项目里踩过几次坑之后养成了一些固定习惯分享给你。第一发送端一定要知道消息是否发成功。Spring Boot 里可以开启 publisher-confirm发送失败时拿到回调该报警报警该补发补发。不要在业务里默默发一条消息就当作成功否则延迟消息和石沉大海没什么区别。第二消费者永远不要裸奔。RabbitListener 的方法要包一层 try-catch但捕获后要根据业务决定是记录日志、重试还是丢到错误队列。最怕的是异常被吞掉消息 ACK 了结果什么都没处理排查起来无从下手。第三监控不能只看队列长度。延迟消息的时效性和普通消息不同就算队列长度是 0也可能出现“消息卡在延迟交换机里没出来”的情况。所以插件方案要额外关注延迟交换机里的 pending 数量TTL 方案要关注原队列里的 Ready 数量。最好配置一个超过阈值就告警的规则。最后再分享一个小技巧新上线延迟消息功能时先在测试环境发一条延迟 3 秒的消息再发一条延迟 5 分钟的消息观察两条消息到达消费者手里的实际时间。这个小实验成本极低却能一次性验证交换机类型、插件、绑定关系、消费者代码四个环节比上线后出问题再排查高效得多。延迟消息的核心不是“延迟”本身而是延迟之后处理得对不对、稳不稳这一点搞明白了RabbitMQ 在这一章才算真正掌握。