
先说个背景。我最近接手了一个2017年前后上线的老服务技术栈固定在Spring Boot 1.4.7.RELEASE上消息队列用的是RabbitMQ。业务上有个硬需求工单提交后30分钟内无人处理要触发升级提醒下单后15分钟未支付要自动关闭。这种“过一段时间再干活”的需求就是典型的延时消息场景。网上关于Spring Boot 2.x、3.x接RabbitMQ延时队列的教程一抓一大把但Spring Boot 1.4这个老版本加上延时队列插件的完整实践却很少。我这次把插件装好、代码调通、还顺手解决了几个挺隐蔽的坑所以专门写一篇给还在维护老项目、或者说被安排“在1.4上搞延时队列”的同行做个参考。这篇内容我会按这样的顺序来先分析为什么在2026年还要守着1.4做延时队列、插件方案和死信队列方案怎么选然后讲版本匹配和插件安装再给出Spring Boot 1.4连接RabbitMQ的完整配置和延时消息的生产消费代码最后把我实际踩过的坑——从RabbitMQ启动失败、连接时报406/404、到消息不延时和丢失——全部列出来附上排查思路。内容偏向实操直接照着抄基本能跑通。1. 先讲清楚为什么还在Spring Boot 1.4上搞延时队列1.1 这需求是真需求订单超时、工单升级都靠它延时队列解决的问题其实很朴素不是“立即让消费者收到消息”而是“在指定时间之后才让消息变得可见、可被消费”。听起来简单真正落地时方案不少不同方案之间差异极大。订单超时未支付自动关闭就是一个典型场景。用户下单后订单系统产生一条“15分钟后关闭订单”的消息这条消息进入延时队列15分钟之内它不会出现在消费者视野里一旦到期RabbitMQ自动把它投递给真正处理关闭订单的消费者业务代码把订单状态置为已取消即可。工单升级提醒同理30分钟后还没被处理消息被投递出来系统发送站内信或短信。我遇到的这个老服务之前用的方案很粗暴后台一个定时线程池每隔30秒扫一遍数据库把超时的记录捞出来处理。这在数据量小的时候没问题但一旦单量上来每次全表扫描的性能开销、还有扫描间隔带来的时间误差都是硬伤。换用RabbitMQ延时队列之后业务侧只需要发送一条带延迟时间的消息到期自动触发误差可以做到秒级甚至毫秒级数据库压力也小了很多。1.2 三条技术路线最后为什么选了插件做延时消息主流的技术路线有三条。我先对比一下再说为什么最终选择了插件方案。第一条是用RabbitMQ自带的死信队列DLX 消息TTL。思路是先声明一个普通的队列不挂消费者发送消息时给消息设置过期时间expiration队列里消息到期后会被RabbitMQ投递给绑定的死信交换机再由死信交换机路由到真正消费的队列。这种做法不需要装任何插件纯RabbitMQ自带功能。但它有个明显痛点同一队列里的消息过期时间通常是一致的如果你需要不同的延迟时间就要为每个延迟级别单独建一个队列和一套绑定代码量和运维成本随延迟级别数量线性增长。而且RabbitMQ对队列中的过期消息是“队头检查”模式如果队头消息延迟很长后面的短延迟消息会被堵住延迟精度完全取决于队头判断这是个很容易踩的坑。第二条是用Spring Boot里的Scheduled定时任务加数据库轮询。简单直接不依赖中间件但轮询间隔决定了延迟精度且并发量上来以后数据库压力大逻辑上也需要维护任务状态。第三条就是标题里的方案RabbitMQ官方延时队列插件rabbitmq_delayed_message_exchange。它通过给消息加一个“x-delay”头指定延迟毫秒数消息进入x-delayed-message类型的交换机后不会被立即路由而是等到延迟时间到了才投递给绑定的队列。对比死信队列方案最爽的一点是每条消息可以单独指定延迟时间不需要为不同延迟建多个队列对比数据库轮询精度高且不占业务库的查询压力。我最终选了插件方案还有一个现实原因这个老项目的部署环境里RabbitMQ是独立安装的运维同事可以很方便地添加插件文件不需要改业务代码也不需要动一下代码就重新发布整个Spring Boot服务。插件方案对业务代码的侵入也最小生产者和消费者几乎不用改原有逻辑只增加一个延时交换机即可。提示延时插件方案并非完全没有缺点。rabbitmq_delayed_message_exchange插件在RabbitMQ内部会把未到期消息暂存起来占用一定的内存和磁盘资源。延迟消息量很大、延迟时间动不动好几小时甚至几天的时候建议评估一下资源占用不要无脑上插件。2. 版本匹配和插件安装这里最容易翻车2.1 Spring Boot 1.4、Spring AMQP、RabbitMQ、插件之间的版本关系先把版本关系捋清楚这是整个项目里最大的坑。Spring Boot 1.4.x引入spring-boot-starter-amqp时管理的是Spring AMQP 1.6.x系列。Spring AMQP是Spring对RabbitMQ客户端的高级封装RabbitTemplate、RabbitListener、声明队列交换机绑定全靠它。Spring AMQP 1.6这个版本我没有做任何升级保持Spring Boot父工程指定的默认版本就够用因为它已经支持CustomExchange足够声明x-delayed-message类型交换机。RabbitMQ服务端的版本则需要和延时插件版本匹配。我这样说吧插件名称是rabbitmq_delayed_message_exchange它是一个独立的.ez文件需要手动下载并放入RabbitMQ的plugins目录。插件的版本号要和RabbitMQ服务端的大版本一致比如RabbitMQ服务端是3.8.x就找3.8.x的插件release服务端是3.7.x就找3.7.x版本。如果版本对不上启用插件时大概率报错或者虽然能启用但运行期行为异常。做一个速查表方便你对着查RabbitMQ服务端插件版本方向常用Erlang版本3.6.x3.6.x老版本插件Erlang 18/193.7.x3.7.x插件Erlang 20/213.8.x3.8.x插件Erlang 21及以上后续新版本优先官方release页面给出的对应版本Erlang保持匹配表格只是给出匹配原则别把版本号当唯一真理。另外Erlang版本和RabbitMQ的匹配关系也很容易踩坑RabbitMQ官方文档给了一个Elixir/Erlang兼容矩阵装服务端之前最好查一下你手头的Erlang环境是否被支持否则RabbitMQ服务可能反复启动失败。2.2 装插件Windows/Linux双系统实录插件安装本身不复杂关键是把文件放对位置并且用对启用命令。我两台环境都装过分别说。Linux环境里找到RabbitMQ的安装目录比如/usr/lib/rabbitmq/lib/rabbitmq_server-3.8.x/plugins/。把下载好的rabbitmq_delayed_message_exchange-xxx.ez文件拷贝到这个目录下然后执行rabbitmq-plugins enable rabbitmq_delayed_message_exchange执行完看到类似“The following plugins have been configured: rabbitmq_delayed_message_exchange”就算成功了。如果没有权限会报错提示检查目录可写性用root权限执行或者调整目录属主即可。Windows环境里RabbitMQ通常安装在C:\Program Files\RabbitMQ Server\rabbitmq_server-3.8.x\plugins目录。把插件文件放进去之后用管理员身份打开命令行执行rabbitmq-plugins.bat enable rabbitmq_delayed_message_exchange这里有一个常见坑Windows下RabbitMQ服务是作为Windows服务运行的插件目录如果有权限限制服务进程读不到插件文件你会看到服务起来了但插件没生效。解决办法是确认插件文件确实在plugins目录下并且启动RabbitMQ服务的Windows用户对该目录有读取权限。实在不行先在命令行里用rabbitmq-plugins.bat list查看插件是否被识别再确认服务是否重启。我自己的经验是不管Windows还是Linux装完插件一定手动重启一次RabbitMQ服务或者执行rabbitmq-diagnostics的命令刷新插件状态别太相信“enable成功就是最终状态”。2.3 装完怎么确认插件真的生效了启用插件后用下面命令查看插件列表rabbitmq-plugins list输出中应包含rabbitmq_delayed_message_exchange另外一个更直观的验证方式是打开RabbitMQ的Web管理界面默认端口15672进入“Exchanges”页面点击“Add a new exchange”在“Type”下拉框里如果出现了x-delayed-message类型就说明插件已经生效。这一步是很多教程没提到的但却是最快确认插件可用的方式。注意如果管理界面里看不到x-delayed-message即使rabbitmq-plugins list显示已启用也建议重启RabbitMQ服务再刷新页面。我遇到过一次插件列表显示正常但管理界面一直不出现类型选项的情况重启后解决原因是RabbitMQ的Web管理组件没有重新加载插件类型注册表。3. Spring Boot 1.4连接RabbitMQ的基础配置3.1 Maven依赖和yml配置老版本别乱升级Spring Boot 1.4里连接RabbitMQ首先要在pom.xml中加入依赖parent groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-parent/artifactId version1.4.7.RELEASE/version relativePath/ /parent dependencies dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-amqp/artifactId /dependency /dependencies这里必须说一句别手贱去升级spring-boot-starter-amqp的版本让它跟着1.4.7.RELEASE父工程走就行。Spring Boot 1.4自带的Spring AMQP 1.6.x对RabbitTemplate、RabbitListener、Binding这些核心API的支持已经足够强行升级到Spring AMQP 2.x反而容易引发API不兼容比如ConnectionFactory的创建方式、消息转换器的默认行为都会变。然后是application.yml。Spring Boot 1.4的RabbitMQ自动配置读取的是spring.rabbitmq前缀spring: rabbitmq: host: 127.0.0.1 port: 5672 username: guest password: guest virtual-host: / publisher-confirms: truepublisher-confirms这个配置项对应的是RabbitTemplate的confirm功能。1.4版本里是true/false开关到了Spring Boot 2.x才改成publisher-confirm-type: correlated。还有publisherReturns对应returnCallback可以用于消息投递失败的回调。我在生产环境里会把这两个都打开因为延时消息一旦发送失败且没有回调业务上很难感知后面排查消息丢失时就会非常被动。3.2 配置类里的几个细节连接工厂、RabbitTemplateSpring Boot 1.4的自动配置会自动创建一个CachingConnectionFactory和一个RabbitTemplate但有些细节需要手动调整。我建议显式定义配置类至少把RabbitTemplate的mandatory开关和消息转换器确认好。Configuration EnableRabbit public class RabbitConfig { Bean public RabbitTemplate rabbitTemplate(ConnectionFactory connectionFactory) { RabbitTemplate template new RabbitTemplate(connectionFactory); template.setMandatory(true); return template; } }setMandatory(true)的作用是如果消息无法路由到任何队列RabbitMQ会把消息返回给生产者触发ReturnCallback。这在你排查“消息去哪里了”的时候非常有用。1.4的RabbitTemplate默认强制消息只能路由成功不设置mandatory的话路由失败的消息会被静默丢弃。消息转换器也建议看一下。默认情况下convertAndSend发送一个String对象走的是SimpleMessageConverter消息体是字节数组消费者收到的也是String没问题。但如果要发送JSON对象建议统一配置Jackson2JsonMessageConverterBean public Jackson2JsonMessageConverter jackson2JsonMessageConverter() { return new Jackson2JsonMessageConverter(); } Bean public RabbitTemplate rabbitTemplate(ConnectionFactory connectionFactory) { RabbitTemplate template new RabbitTemplate(connectionFactory); template.setMandatory(true); template.setMessageConverter(jackson2JsonMessageConverter()); return template; }这里有一个细节生产端和消费端的消息转换器要一致否则消费者反序列化时容易报类型错误、或者拿到一堆乱码字节。老项目里如果生产端是另一个服务两边都要同步确认Converter配置。4. 让延时插件真正跑起来交换机、生产者、消费者4.1 用CustomExchange声明x-delayed-message交换机核心来了。延时插件的工作机制是消息投递到一个类型为x-delayed-message的交换机这个交换机不会立刻把消息路由到队列而是把消息暂时保存起来等延迟时间到了才执行路由逻辑把消息投给绑定的队列。因此声明交换机的时候type必须写x-delayed-message并且要额外设置一个参数x-delayed-type指定“延迟结束之后按什么类型路由”。这个参数是插件规定的用来模拟direct、topic、fanout等路由行为。我把延时交换机设成direct类型配合固定的routingKey简单直接。Configuration public class DelayRabbitConfig { public static final String DELAY_EXCHANGE delay.exchange; public static final String DELAY_QUEUE delay.queue; public static final String DELAY_ROUTING_KEY delay.key; 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 new Queue(DELAY_QUEUE, true); } Bean public Binding delayBinding() { return new Binding(DELAY_QUEUE, Binding.DestinationType.QUEUE, DELAY_EXCHANGE, DELAY_ROUTING_KEY, null); } }CustomExchange是Spring AMQP里用来声明非标准类型交换机的类RabbitMQ内置direct、topic、fanout等类型各有专门的类但x-delayed-message是插件扩展类型只有CustomExchange能表达。声明参数里构造方法依次是交换机名称、类型、durable、autoDelete、arguments。durable必须为true表示交换机重启后还在arguments里的x-delayed-type必须保留。第三个参数args为null也是允许的对应绑定不需要额外参数。这里有个非常容易踩的坑如果之前你已经用同一个交换机名声明过一个普通direct类型交换机再启动项目声明x-delayed-message交换机RabbitMQ会直接拒绝报406 PRECONDITION_FAILED。解决办法是到管理界面手动删除旧交换机或者换个新的交换机名称。队列和交换机一样同名不同类型/参数冲突也会报406。4.2 生产者发送延时消息的完整写法生产者的核心逻辑很简单发送消息到延时交换机消息带上x-delay头值是延迟的毫秒数。Spring AMQP中MessageProperties负责承载消息头。我建议直接用setHeader(x-delay, 延迟毫秒数)这是插件约定的原生写法不依赖Spring AMQP对delay属性是否有特殊处理最保险。Component public class DelayMessageProducer { Autowired private RabbitTemplate rabbitTemplate; public void sendDelayMessage(String message, long delayMillis) { MessagePostProcessor messagePostProcessor new MessagePostProcessor() { Override public Message postProcessMessage(Message message) throws AmqpException { message.getMessageProperties().setHeader(x-delay, (int) delayMillis); return message; } }; rabbitTemplate.convertAndSend( DelayRabbitConfig.DELAY_EXCHANGE, DelayRabbitConfig.DELAY_ROUTING_KEY, message, messagePostProcessor ); } }调用时delayMessageProducer.sendDelayMessage(订单未支付请关闭, 15 * 60 * 1000L);这里我有几个值得说的点。第一x-delay头的单位是毫秒而且插件要求它是一个整数。60000表示1分钟15分钟就是900000不要写成秒这是最常见的低级错误。第二int的位数上限决定了延迟最长时间大约在24.8天左右Integer.MAX_VALUE毫秒如果业务上要延迟一个月甚至更久这个方案就不合适了建议换数据库调度。第三MessagePostProcessor里不要顺手调用message.getMessageProperties().setDelay(...)再同时setHeader(x-delay, ...)两个机制混用容易造成头被覆盖或者类型不一致选一种就行。还有一点经验之谈发送延时消息之后最好记一条业务日志带上消息ID、交换机、routingKey、延迟时间。一旦后续消息丢失或者延迟不准你至少有线索可以查。RabbitTemplate的convertAndSend方法如果你不传Message对象、传的是String对象默认生成的messageId是null排查时很痛苦可以在MessagePostProcessor里给messageProperties.setMessageId(UUID.randomUUID().toString())。4.3 消费者侧需要注意的队列绑定与ACK行为消费者端不需要感知x-delay头。消息到期后由延时交换机自动投喂到队列消费者只是一条普通队列的消费者。一个最简单的RabbitListener写法如下Component public class DelayMessageConsumer { RabbitListener(queues DelayRabbitConfig.DELAY_QUEUE) public void process(String message) { // 到期之后这里才会被触发 System.out.println(收到延时消息: message); } }但真实生产环境里我建议使用手动ACK并且在业务处理成功后才确认消息。Spring Boot 1.4里可以手动定义SimpleRabbitListenerContainerFactory设置AcknowledgeMode.MANUALBean public SimpleRabbitListenerContainerFactory rabbitListenerContainerFactory(ConnectionFactory connectionFactory) { SimpleRabbitListenerContainerFactory factory new SimpleRabbitListenerContainerFactory(); factory.setConnectionFactory(connectionFactory); factory.setAcknowledgeMode(AcknowledgeMode.MANUAL); return factory; }消费者方法接收Channel和Message参数自己手动确认Component public class DelayMessageConsumer { RabbitListener(queues DelayRabbitConfig.DELAY_QUEUE) public void process(Message message, Channel channel) throws Exception { try { String body new String(message.getBody(), UTF-8); // 在这里处理业务逻辑 channel.basicAck(message.getMessageProperties().getDeliveryTag(), false); } catch (Exception e) { channel.basicNack(message.getMessageProperties().getDeliveryTag(), false, true); } } }使用手动ACK重启服务时未确认的消息会重新投递不会因为服务恰好宕机在消息到期节点就丢掉业务。延时消息通常是“业务需要确保最终执行”的比如关闭订单、升级工单丢一条的代价都不小。所以在Spring Boot 1.4里手动ACK这条别省。另一个注意点是消费者的并发度。延时消息到期后往往会有一个“集中触发”的尖峰因为很多业务都习惯把延迟时间设成整点、整分钟。如果队列积压了很多同时到期的消息但消费者并发只有1消费速度会跟不上。可以在容器工厂里设置factory.setConcurrentConsumers(5)和factory.setMaxConcurrentConsumers(15)给消费者留出弹性扩展空间。5. 实操中遇到的坑从启动失败到消息不延时5.1 RabbitMQ服务启动失败的几类原因先说服务启动失败这类问题卡住的话后面全白搭。第一类端口冲突。RabbitMQ默认5672如果本机已经有一个进程占用了5672服务起不来。Windows下特别常见排查命令是netstat -ano | findstr 5672找出占用进程PID再通过任务管理器结束或者换端口。也可以修改RabbitMQ配置文件里的TCP监听端口但改动配置会让其他依赖方也一起变能不动就别动。第二类Erlang版本和RabbitMQ不匹配。我见过一台服务器上RabbitMQ 3.8.x配了很低版本的Erlang启动时直接崩日志里一大段Erlang异常栈。排查办法不是看进程有没有起来而是执行rabbitmq-diagnostics status看版本信息确认Erlang release是否在RabbitMQ支持范围内。第三类主机名问题。RabbitMQ服务依赖系统主机名解析某些场景下主机名解析失败会导致EPMD启动失败。Linux下可以通过/etc/hosts把主机名映射到127.0.0.1临时解决。第四类Windows服务权限不足。以Windows服务方式安装RabbitMQ后插件目录、数据目录如果权限配置不对服务启动报错。打开Windows事件查看器看到RabbitMQ相关错误时优先看是不是“Access Denied”。5.2 连接RabbitMQ时报NOT_FOUND/406/ACCESS_REFUSED启动Spring Boot服务时你可能会遇到这样一串报错cause: clean channel shutdown; protocol method: #methodchannel.close(reply-code404, reply-textNOT_FOUND - no exchange delay.exchange in vhost /, class-id60, method-id40)404 NOT_FOUND通常是交换机或队列没有被声明成功。最常见的场景是你在生产端代码里发送消息到delay.exchange但应用启动时并没有执行配置类里的delayExchange()方法也就是配置类没被Spring扫描到。检查一下启动类的包扫描路径确认RabbitConfig所在的包在扫描范围内。另外还有一种404交换机的名字或者vhost和后端不匹配。如果配置里virtual-host写的是/消息实际也是在默认vhost /下对不上会报错。RabbitMQ对vhost敏感生产环境的vhost经常是独立创建的比如/shop配置要跟实际一致。406 PRECONDITION_FAILED是另一个高频报错。前面提到过同名交换机类型不同会报406同名队列参数不同也会报406。比如之前队列声明时是durablefalse现在改成durabletrueRabbitMQ拒绝修改已存在队列直接406。排错思路是去管理界面看这个名称的队列/交换机是否已经存在存在就删除掉再重新启动项目。还有ACCESS_REFUSED默认情况下guest用户只能通过localhost访问RabbitMQ如果Spring Boot应用和RabbitMQ不在同一台机器上用guest连一定失败。这时候要创建一个专用账号并赋权rabbitmqctl add_user mquser mqpass rabbitmqctl set_permissions -p / mquser .* .* .*然后在application.yml里把username/password改成新账号问题立刻消失。这是远程连接RabbitMQ时最容易踩的安全限制别在默认guest上死磕。5.3 消息不延时、丢失、堆压怎么办延迟时间没生效消息发下去立刻被消费掉了。出现这种问题我通常按下面顺序排查先看消息头。到管理界面的“Queues”页面选中目标队列查看消息的Headers。如果看到x-delay不在里面说明生产端MessagePostProcessor没有生效。一个比较隐蔽的问题是如果你传的是String消息但RabbitTemplate被配置了Jackson2JsonMessageConverterconvertAndSend重载方法可能没有按预期应用PostProcessor。检查方法是在MessagePostProcessor里加个日志把message.getMessageProperties().getHeaders()打印出来。再看路由键和交换机类型。延时交换机必须确实被声明成x-delayed-message类型routingKey要能匹配to指定的key。如果你不小心把消息发到了另一个同名的普通交换机消息自然延时不了。还有一种情况x-delay头被设置成了字符串类型。插件要求x-delay是整数有些JSON配置里写成了900000类型不严格匹配时会投递异常。用MessageProperties.setHeader时务必传Integer我前面的代码里特意强转成int就是为了避开这个坑。消息丢失问题首先要查mandatory有没有开、ReturnCallback有没有写。打开mandatory之后路由失败的消息会触发ReturnCallback你可以打日志。代码可以这样注册callbackrabbitTemplate.setReturnCallback(new RabbitTemplate.ReturnCallback() { Override public void returnedMessage(Message message, int replyCode, String replyText, String exchange, String routingKey) { System.out.println(消息路由失败: replyCode replyText); } });消息堆积问题通常是消费者处理太慢。除了调高并发度之外还要看消费者里有没有做耗时的远程调用。如果是调外部接口导致耗时建议把外部调用改成异步或者增加重试队列避免把RabbitMQ的消费线程拖死。提示如果消息延迟时间特别长比如几小时甚至几天插件的调度误差会被拉大。rabbitmq_delayed_message_exchange的实现在内部使用定时器扫描延迟消息越多、延迟时间越长实际触发时间可能比设定时间晚几十秒甚至更久。对时间精度要求很高秒级的场景建议把延迟控制在分钟到小时级别内超长延迟改用数据库调度。这个特性限制在官方文档里有说明但很多教程不会提醒。6. 最后说点维护老项目的心得Spring Boot 1.4很老但存量系统不会因为新版本出现就自动消失。我这次做完延时队列后有个体会老版本并不等于不能做新功能关键在于把版本边界摸清楚不强行升级也不排斥新方案。RabbitMQ延时插件从RabbitMQ 3.6.x时代就有了Spring AMQP 1.6的CustomExchange能稳定支撑甚至不需要改Spring Boot版本这点可能很多人没意识到。在我实操的过程中最大的效率来源反而是先把问题定义清楚我需要的是“每条消息独立延迟”不是“队列级别延迟”。就这一个判断直接决定了我放弃死信队列方案。如果当时只想着“用RabbitMQ实现延迟”大概率会在TTLDLX的各种坑里打转绕一圈又回来。最后分享两个我自己的习惯。第一个测试延时消息时永远从最短时间开始验证比如先发一条延迟5秒的消息确认能消费后再逐步拉长到1分钟、30分钟。你要是直接发一条30分钟的延时消息等它触发时可能已经忘了自己测的是哪一条。第二个延时队列上线后一定要加监控至少监控队列消息堆积数和消息年龄。RabbitMQ管理界面能看到“message count”和“unacked”消息堆积持续上涨就是消费端出问题的信号不能等用户反馈才发现。如果你也在维护一个老版本Spring Boot项目正为延时消息头疼按照这篇文章把插件装上、把交换机声明成x-delayed-message、发送时带上x-delay头基本上就能跑通。剩下的坑无非是版本、权限、类型冲突这几类对照第五节逐一排查就行。