
接手过一个跑了五六年没人敢动的老系统Spring Boot 1.4.7业务代码堆得跟山一样。新需求本身不复杂订单支付超时自动关闭下单后30分钟未付款系统要自动把订单置为已取消再给用户发一条提醒。这不就是典型的延时队列场景么。RabbitMQ本身不提供原生延时队列但有官方维护的rabbitmq_delayed_message_exchange插件。问题在于网上能搜到的延时队列教程十有八九是Spring Boot 2.x配新版Spring AMQP照搬到1.4上全是坑。这篇我把自己在Spring Boot 1.4上连接RabbitMQ、启用延时插件、声明交换机、收发延时消息的完整过程以及排查过的几个经典报错都写清楚给同样被老项目绑住手脚的同学一份能直接照着做的参考。1. 老项目的延时方案选型为什么我选了官方插件而不是死信队列1.1 两条技术路线TTLDLX与x-delayed-message插件RabbitMQ里没有直接叫“延时队列”的东西。业界实现延时基本就两条路。第一条是死信队列方案消息先投到一张普通队列给队列或消息设置TTL过期时间过期后消息自动进入死信交换机DLX由死信交换机再转发到真正消费的队列。这个方案资料多很多老教程都是这么写的。第二条是官方延时插件方案安装rabbitmq_delayed_message_exchange插件之后RabbitMQ多了一种x-delayed-message类型的交换机。发消息时带上一个x-delay头单位毫秒消息不会立刻路由而是等够时间后由插件内部的定时逻辑投递到绑定的队列。我最终选的是插件方案。原因很简单死信方案有个老版本RabbitMQ的经典痛点只有排到队头的消息才会被检查是否过期。如果你往同一张队列里塞了不同TTL的消息比如第1条消息设了2小时第2条设了30分钟那么第2条必须等第1条走完才能被判定过期延时精度完全失控。后来的RabbitMQ版本改进了per-message TTL的行为允许过期消息提前从队列中移除但生产环境里不少老版本3.6、3.7还在跑死信方案对多个不同延时等级混在一个队列的场景非常不友好。插件方案没有这个排序问题。消息进到x-delayed-message交换机后插件给每个消息单独计时到期了再路由到目标队列前一个消息延时多久完全不影响后一个。这里顺手做个对比对比项TTLDLX方案x-delayed-message插件额外组件死信交换机、死信队列一个插件文件延时精度老版本受队头阻塞影响每条消息独立计时运维成本队列和绑定关系多一套集群每个节点都要装插件适用版本所有版本需下载对应版本的插件包1.2 插件方案也有代价别光看好的一面选插件不是没有代价。第一插件要装在RabbitMQ的每个节点上集群环境全都要装运维多一项工作。第二如果曾经用相同名字声明过普通类型交换机再改成x-delayed-message会报406冲突要么重新起名要么手动删掉旧的。第三插件在极端高吞吐场景下性能不如纯队列方案极致但对绝大多数业务来说每秒几百上千条延时消息根本不是瓶颈。我的场景很典型订单创建后调用延时发送30分钟后消费端自动关单。一个延时交换机、一个业务队列、一个路由键搞定。比起死信方案要维护两张队列、两张交换机少一半资源排查也简单。2. 起步准备插件安装、版本对应与工程配置2.1 延时插件安装与验证步骤插件全名rabbitmq_delayed_message_exchange是RabbitMQ官方维护的社区插件。安装第一步就是版本要对上RabbitMQ 3.8.x对应插件3.8.x的releaseRabbitMQ 3.9、3.10、3.12、3.13分别对应插件3.9.x、3.10.x、3.12.x、3.13.x老一点的3.6、3.7也有对应的3.7.x插件包。版本不匹配最常见的现象是插件加载后交换机类型不生效或者RabbitMQ服务干脆起不来。先执行rabbitmqctl version确认服务端版本再去GitHub上rabbitmq-delayed-message-exchange仓库的Releases页找对应版本。具体步骤下载对应版本的.ez文件复制到RabbitMQ安装目录的plugins目录Windows在安装目录\plugins下Linux一般在/usr/lib/rabbitmq/lib/rabbitmq_server-x.x.x/plugins执行rabbitmq-plugins enable rabbitmq_delayed_message_exchange执行rabbitmq-plugins list确认列表里出现了带[E*]标记的rabbitmq_delayed_message_exchange重启RabbitMQ服务。有个小坑Windows下RabbitMQ如果注册成服务复制插件后一定要用管理员权限执行enable命令然后重启服务否则插件状态不生效管理页面里永远看不到x-delayed-message类型。验证插件是否真的能用登录RabbitMQ管理页面默认端口15672在Exchanges页新建交换机时Type下拉框里会出现x-delayed-message选项。出现这个选项说明插件没问题。2.2 Spring Boot 1.4连接RabbitMQ的依赖与配置Spring Boot 1.4连接RabbitMQ非常简单因为spring-boot-starter-amqp把连接工厂、RabbitTemplate、RabbitAdmin全都自动配置好了你只需要加依赖和写配置。pom.xml加依赖dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-amqp/artifactId /dependencySpring Boot 1.4.7.RELEASE管理的spring-rabbit版本是1.6.x对应的RabbitMQ Java客户端是3.6.x。这个客户端连接RabbitMQ 3.7、3.8都没有问题但如果服务端特别新比如3.13建议先确认一下协议兼容性老客户端连新服务端偶尔会出现认证或协议层面的异常。application.yml配置spring: rabbitmq: host: 127.0.0.1 port: 5672 username: mq_user password: mq_pass virtual-host: /order listener: acknowledge-mode: auto concurrency: 5 max-concurrency: 10 prefetch: 50三个要点virtual-host建议独立别所有业务共用一个/。给账号单独授权一个vhost生产环境权限隔离干净listener.acknowledge-mode默认是autoSpring容器自动确认。如果要做手动确认这里改成manualconcurrency和max-concurrency在1.4里对应SimpleMessageListenerContainer的并发消费者数量不是线程池线程数别理解混了。2.3 一个容易犯的错手动定义ConnectionFactory反而坏事自动配置已经创建了CachingConnectionFactory和RabbitTemplate默认情况下直接用Autowired注入就行。Spring Boot 1.4有个常见误解认为加了这个依赖还得手动创建ConnectionFactory。其实不需要RabbitAutoConfiguration会自动读取spring.rabbitmq.*配置并创建好一切除非你要定制心跳、连接超时等参数否则不要重复造轮子。我一开始就手动写了一个ConnectionFactory结果跟自动配置的Bean冲突启动时各种奇怪报错后来把自定义Bean删掉就正常了。如果你想定制连接参数、给RabbitTemplate换消息转换器正确做法是定义自己的Bean覆盖而不是另起一个跟自动配置抢CachingConnectionFactory。3. 核心声明把交换机变成x-delayed-message并绑定队列3.1 为什么必须用CustomExchange而不是TopicExchange或DirectExchange声明延时交换机最关键的坑是不能用Spring AMQP的TopicExchange、DirectExchange去声明。原因很简单这些类的declareExchange方法会把type写死成topic、direct而我们要的是x-delayed-message类型。有些教程给普通交换机传入arguments以为能改类型实际上arguments只是交换机的额外参数type还是原来那个type声明出来就是个普通交换机延时功能完全不生效。正确做法是用CustomExchange它的构造函数允许你指定类型字符串。声明的时候还要加一个x-delayed-type参数告诉插件这个延时交换机内部按哪种规则路由。插件支持direct、topic、fanout三种我做的是路由键精确匹配绑定所以x-delayed-type设direct。如果后面要按通配符绑定多个队列就设topic。顺带提一句某些版本的spring-rabbit其实提供了现成的DelayedMessageExchange类可以直接用但不同小版本的构造参数差异不小我干脆统一用CustomExchange兼容性最稳代码也最直白。3.2 三类Bean的完整声明代码Configuration EnableRabbit public class RabbitDelayConfig { public static final String DELAY_EXCHANGE delay.exchange; public static final String DELAY_QUEUE delay.queue; public static final String DELAY_ROUTING_KEY delay.routing.key; Bean public CustomExchange delayExchange() { MapString, Object args new HashMap(1); 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, false, false); } Bean public Binding delayBinding() { return BindingBuilder.bind(delayQueue()).to(delayExchange()).with(DELAY_ROUTING_KEY).noargs(); } }说明几点CustomExchange构造函数的四个参数依次是交换机名、类型、durable持久化、autoDelete无人绑定时自动删除。durable一定设true否则RabbitMQ重启后交换机就没了Queue构造参数队列名、durable、exclusive、autoDelete。生产环境队列也建议durabletrue、exclusive和autoDelete都falseEnableRabbit必须加否则RabbitListener注解不生效这些Bean定义好后Spring Boot启动时RabbitAdmin会自动把交换机、队列、绑定全部声明到RabbitMQ服务端不需要去管理页面手动创建。3.3 声明冲突的406错误怎么处理RabbitMQ的声明是幂等校验的名字相同type、durable等参数必须一致否则直接拒绝。如果你之前已经在管理页面用delay.exchange这个名字创建过一个direct类型交换机现在用上面的配置启动会看到类似这样的报错reply-code406, reply-textPRECONDITION_FAILED - inequivalent arg type for exchange delay.exchange in vhost /: received x-delayed-message but current is direct处理办法两种一是到管理页面手动删除旧交换机再重启应用让RabbitAdmin重新声明二是干脆换一个交换机名。不要想着改arguments强行覆盖声明参数不一致就是报错RabbitMQ不会给你覆盖的机会。队列同理durable或arguments不一致同样报406。4. 发送端实现给消息打上延时标记4.1 setDelay与x-delay头的关系延时交换机的工作原理是检查到达交换机的消息头里有没有x-delay以毫秒为单位到期后再路由。Spring AMQP在MessageProperties里封装了delay字段rabbitTemplate发送时会被转成x-delay头。所以发送端要做的事就是设置messageProperties的delay属性。也可以用setHeader(x-delay, 5000)达到同样效果两者等价。这里有个容易跟setExpiration混淆的地方setExpiration设置的是消息过期时间TTL走的是expiration头对延时交换机来说不生效。我一开始用错消息发出去立即就被路由了压根没延时后来抓消息头看才发现。4.2 完整发送代码Service public class OrderDelaySender { Autowired private RabbitTemplate rabbitTemplate; public void sendOrderCloseTask(String orderId, long delayMillis) { rabbitTemplate.convertAndSend( RabbitDelayConfig.DELAY_EXCHANGE, RabbitDelayConfig.DELAY_ROUTING_KEY, orderId, message - { // x-delay头单位毫秒 message.getMessageProperties().setDelay((int) delayMillis); return message; } ); } }调用方式orderDelaySender.sendOrderCloseTask(orderId, 30 * 60 * 1000L);几个细节convertAndSend四个参数分别是交换机名、路由键、消息体、MessagePostProcessor。消息体是String默认的SimpleMessageConverter会把String当成文本消息体发送消费端直接收String最省事MessagePostProcessor在Spring Boot 1.4加Java 8环境里可以直接写lambdadelay字段是int类型最长约24.8天Integer.MAX_VALUE毫秒。正常业务30分钟、1小时、1天都没问题但如果要做30天以上的延时得拆成多段比如每隔一段时间投递一次。另外消息进入延时交换机后是被插件持有的并没有立刻进入队列所以队列深度不会马上增加。在管理页面看delay.exchange能看到pending消息数那个数字就是正在等待延时的消息量。4.3 多个延时等级、多个业务怎么设计一个延时交换机可以绑定很多队列。建议按业务类型拆分队列而不是所有消息都挤一个队列订单超时关单delay.queue.order路由键order.close延时30分钟支付催付提醒delay.queue.pay.notify路由键pay.notify延时5分钟收货后自动确认delay.queue.confirm路由键order.confirm延时7天。好处是消费端互不干扰某个队列堆积不会拖累其他业务坏处是每加一个业务就多一套队列和绑定。如果只是demo或轻量场景一个队列用不同路由键绑定也行——注意同一队列下不同消息的延时是各自计时的插件保证到期消息逐个进入队列不会因为排前面的大延时堵住后面的小延时这一点比死信方案省心太多。5. 消费端RabbitListener监听延时消息5.1 最简单的自动确认消费Component public class DelayOrderConsumer { RabbitListener(queues RabbitDelayConfig.DELAY_QUEUE) public void closeTimeoutOrder(String orderId) { // 1. 查订单是否已支付 // 2. 未支付则关闭订单、释放库存 // 3. 记录操作日志 } }这是最朴素的版本消息一进监听方法Spring容器就自动确认了。适合“关单失败可以接受后续补偿机制兜底”的业务。注意RabbitListener监听的队列名要跟配置类里的常量一致字符串写错最隐蔽消息一直躺在队列里不消费管理页面能看到Ready堆积但没有任何报错。5.2 手动确认与失败重试订单关单这种操作建议用手动确认。先把acknowledge-mode改成manual然后消费方法加两个参数RabbitListener(queues RabbitDelayConfig.DELAY_QUEUE) public void closeTimeoutOrder(String orderId, Channel channel, Header(AmqpHeaders.DELIVERY_TAG) long tag) throws Exception { try { // 1. 查订单状态 // 2. 未支付则关单 channel.basicAck(tag, false); } catch (Exception e) { // 退回到队列头部等下一次重试 channel.basicNack(tag, false, true); } }这里basicNack的第三个参数requeuetrue会把消息放回队列重新投递。如果业务一直在抛异常会形成死循环疯狂重试。稳妥的做法是第一次失败basicNack(reqfalse)丢弃再靠人工或定时任务补偿或者配合Spring Retry做有限次数重试。Spring Boot 1.4里可以开spring.rabbitmq.listener.retry.enabledtrue、max-attempts3这样重试发生在进入Listener之前比自己写循环干净。有一点要特别提醒manual模式下容器不会替你确认消息。没ack的消息会一直停留在unacked状态消费线程挂掉或channel关闭时才会重新入队。所以一定要在finally里把确认逻辑写好否则消息积压在unacked里管理页面上看总数没问题实际上已经卡死了。5.3 消息转换器1.4里最容易忽略的Bean覆盖默认情况下Spring Boot 1.4的RabbitTemplate用的是SimpleMessageConverter只支持String和byte[]。如果你发送POJO默认转换器会尝试Java序列化消费端方法签名如果也是POJO两边必须用同一套序列化方式否则反序列化直接异常。实际项目里大多数情况发JSON。做法是在配置类里覆盖RabbitTemplate和监听容器工厂的MessageConverterBean public RabbitTemplate rabbitTemplate(ConnectionFactory connectionFactory) { RabbitTemplate rabbitTemplate new RabbitTemplate(connectionFactory); rabbitTemplate.setMessageConverter(new Jackson2JsonMessageConverter()); return rabbitTemplate; } Bean public SimpleRabbitListenerContainerFactory rabbitListenerContainerFactory(ConnectionFactory connectionFactory) { SimpleRabbitListenerContainerFactory factory new SimpleRabbitListenerContainerFactory(); factory.setConnectionFactory(connectionFactory); factory.setMessageConverter(new Jackson2JsonMessageConverter()); return factory; }两个必须同时设发送走RabbitTemplate接收走容器工厂少设一个就会出现“发送是JSON接收按Java反序列化”的错位。另外Jackson2JsonMessageConverter依赖jackson-databind如果项目只有spring-boot-starter-amqp这一个starter记得手动补上jackson-databind依赖否则运行时会报ClassNotFoundException。6. 从报错到稳定排查链路的完整复盘6.1 插件没生效启动直接报404第一次写完CustomExchange启动应用控制台刷出一行红字404 NOT_FOUND - no exchange delay.exchange in vhost /这个报错网上搜到的解释基本都是“交换机不存在RabbitAdmin没自动声明”但我确认RabbitAdmin是存在的。后来才发现根本原因是测试用的那台RabbitMQ实例压根没装插件x-delayed-message这个类型在服务端根本不存在声明自然失败。所以调试延时队列问题之前先做两件事rabbitmq-plugins list确认插件已在当前节点启用管理页面新建交换机时Type下拉框里能看到x-delayed-message。这两步一分钟就能排除掉一大半低级问题。6.2 重复声明的406历史遗留交换机这个在3.3里说过报错特征是PRECONDITION_FAILED、inequivalent arg。实际工作中遇到最多的场景是测试环境曾经用普通direct类型的同名交换机跑过后来代码改成延时交换机重启就炸。解决就是换名字或删掉旧交换机。这里提醒一句改代码前先到管理页面看一遍现有的exchange、queue、binding把老资源清理干净再动工能省掉很多启动期的诡异报错。6.3 “clean channel shutdown”这个报错到底是怎么回事排查期间我也遇到了那种带“clean channel shutdown”字样的日志网上搜这个关键词的还不少Shutdown Signal: channel error; protocol method: #methodchannel.close(reply-code406, reply-textPRECONDITION_FAILED ...)很多人一看到“clean channel shutdown”就以为是网络问题、连接被服务端断掉。其实不是。这行日志的意思是channel以正常协议方式关闭了而真正的错误原因在后面的reply-code和reply-text里。我见过几类常见的声明参数不一致比如队列重复声明时durable、arguments对不上交换机或队列不存在客户端往一个没声明的目标发消息延时交换机内部x-delayed-type设置与实际绑定方式不匹配消费端反序列化异常反复触发最后被容器关掉channel。排查思路很简单找到日志里完整的reply-text看是哪个资源、哪个参数不一致再去管理页面核对。别被“clean”这个词骗了它不是健康的意思只是说关闭动作是协议层面的正常关闭不是服务端kill连接。6.4 消息没有延时三个检查点如果发送端代码完全正确消息却立刻被消费按顺序排查这三个地方管理页面看delay.exchange的Type列是不是x-delayed-message。不是的话说明交换机声明错了大概率用了TopicExchange或者普通DirectExchange看消息头里有没有x-delay字段。如果看到的是expiration而不是x-delay说明你用成了setExpiration改成setDelay确认发消息时显式指定了交换机名和路由键。如果RabbitTemplate用默认交换机发送消息根本不会进delay.exchange自然也没有延时。这三点排查完延时消息基本就稳了。6.5 生产环境落地的小建议最后说几个我在生产环境总结的实操经验给延时相关账号单独建vhost权限最小化避免业务串扰也方便单独做监控开启publisher confirmspring.rabbitmq.publisher-confirmstrue给RabbitTemplate设置ConfirmCallback至少知道消息有没有被Broker接收。延时消息发送成功后要等30分钟才见分晓如果发送时丢了排查代价非常高重点监控delay.exchange上的pending消息数和delay.queue的深度。延时消息积压往往意味着插件调度出了问题或者消费端挂了这个指标比普通队列积压更隐蔽不看pending数根本发现不了一次要发大量不同延时的消息时按延时等级拆分交换机或队列避免单个插件的定时调度负载过于集中。这套东西跑下来我在老项目上的延时队列改造算是彻底落地了。如果你也是被旧版本Spring Boot绑住了手脚希望这份踩坑记录能帮你少走几步弯路。