写Spring系列写到第29篇,今天聊一个在很多项目里都绕不开的老牌中间件:JMS。Java项目里做异步解耦、削峰填谷,或者对接ActiveMQ、老版本RabbitMQ等消息中间件时,JMS几乎是标准答案。Spring对JMS的集成其实做了两件很实用的事:把连接管理、会话创建、消息收发这些样板代码收进模板类,再把消息监听做成注解驱动,让你只需要关心业务逻辑本身。这篇我会从最原始的JMS开发方式开始,一步步带你把生产端、消费端、监听容器和事务配置全部理顺,最后把我实际踩过的一些坑也一并列出来,方便你做技术选型或者排查问题时直接参考。
1. 为什么在Spring里集成JMS
1.1 原生JMS代码的样板工程
先看一段不使用Spring时用JMS发送消息的代码,这样能更直观地理解Spring的集成到底解决了什么问题。假设我要往一个名为demo.queue的Queue发送一条文本消息:
ConnectionFactory factory = new ActiveMQConnectionFactory("tcp://localhost:61616"); Connection connection = null; Session session = null; MessageProducer producer = null; try { connection = factory.createConnection(); connection.start(); session = connection.createSession(false, Session.AUTO_ACKNOWLEDGE); Destination destination = session.createQueue("demo.queue"); producer = session.createProducer(); TextMessage message = session.createTextMessage("hello jms"); producer.send(destination, message); } catch (JMSException e) { log.error("发送失败", e); } finally { if (producer != null) { try { producer.close(); } catch (JMSException ignored) {} } if (session != null) { try { session.close(); } catch (JMSException ignored) {} } if (connection != null) { try { connection.close(); } catch (JMSException ignored) {} } }如果只是发送一条消息还好,但实际项目里可能要发几十种业务消息,每个地方都这样写,连接管理和异常处理就会变成一场灾难。而且这里还只是最简单的场景,没有算上消息类型转换、消息持久化、事务会话、并发消费这些更复杂的需求。你想想,如果一个项目里有几十个类都在重复这套初始化和收尾逻辑,维护成本是很高的。
1.2 Spring抽象出来的几个核心角色
Spring对JMS的抽象可以分成四个核心角色,它们各司其职,把原生JMS的开发流程拆解成了更易用的模块:
| 核心角色 | 作用 | 对应原生JMS概念 |
|---|---|---|
| ConnectionFactory | 统一管理物理连接资源 | Connection、ConnectionFactory |
| JmsTemplate | 简化消息发送与同步接收 | Session、MessageProducer、MessageConsumer |
| MessageConverter | 完成对象与JMS消息之间的转换 | Message类型转换 |
| MessageListenerContainer | 管理消费者生命周期和并发消费 | MessageConsumer、MessageListener |
其中JmsTemplate是最常用的入口,它封装了连接获取、Session创建、消息发送、资源关闭这一整套流程。MessageListenerContainer则是消费端的核心,Spring通过它来创建消费者、监听队列、处理消息确认和并发调度,你可以把@JmsListener注解看成Spring在这套容器之上提供的一种更简洁的声明式编程方式。
1.3 这类集成适合用在什么地方
我在实际项目中接触到的JMS集成场景,主要集中在三类:
- 系统解耦:比如订单创建后,把订单数据发布到一个队列,后续的积分、短信、审计等模块异步消费,生产端和消费端不互相依赖。
- 流量削峰:瞬时请求量大时,先把请求落到队列里,消费端根据自己的处理能力慢慢消费,避免下游数据库被压垮。
- 跨系统数据同步:很多老系统之间没有现成接口,用JMS传递结构化消息作为数据变更的事件通道。
值得一提的是,JMS不像Kafka那样自带复杂的存储和分区机制,它是一个规范,ActiveMQ、老版本RabbitMQ都有对应的JMS实现。如果你只是需要一个简单可靠的消息队列,维护成本又不愿意太高的场景,Spring + ActiveMQ这种组合在技术选型里依然很有竞争力。
2. 环境搭建与基础配置
2.1 依赖引入与内嵌Broker
做Spring框架级JMS集成,最省心的方式是我先引入spring-jms,它提供了模板、容器管理器和注解支持。如果你用Spring Boot,那事情更简单,spring-boot-starter-activemq这个依赖会把spring-jms和ActiveMQ客户端一起带进来。我这里以非Boot环境为例,贴一下Maven坐标:
<dependency> <groupId>org.springframework</groupId> <artifactId>spring-jms</artifactId> <version>5.3.20</version> </dependency> <dependency> <groupId>org.apache.activemq</groupId> <artifactId>activemq-all</artifactId> <version>5.16.5</version> </dependency>如果本地开发不想启动外部ActiveMQ进程,可以直接用ActiveMQ自带的嵌入式Broker能力。用vm://协议启动一个当前JVM内的Broker:
BrokerService broker = new BrokerService(); broker.setUseJmx(false); broker.setBrokerName("embedded"); broker.addConnector("vm://localhost"); broker.start();开发环境下用vm://localhost连接即可,这种模式的好处是零安装、启动快,集成测试也能直接跑。生产环境则改成tcp://broker-host:61616,同时按需配置failover://连接串实现高可用,ActiveMQ的failover协议会在主连接断开后自动重连备用节点,这个后面排查连接断开问题时也会提到。
2.2 连接工厂和JmsTemplate的Bean配置
Spring集成JMS的第一步是定义一个ConnectionFactory的Bean。以ActiveMQ为例:
@Configuration public class JmsConfig { @Bean public ActiveMQConnectionFactory connectionFactory() { ActiveMQConnectionFactory factory = new ActiveMQConnectionFactory(); factory.setBrokerURL("tcp://localhost:61616"); factory.setUserName("admin"); factory.setPassword("admin"); // 强制使用异步发送,可以降低发送端的阻塞等待时间 factory.setUseAsyncSend(true); return factory; } @Bean public JmsTemplate jmsTemplate(ActiveMQConnectionFactory connectionFactory) { JmsTemplate template = new JmsTemplate(connectionFactory); // 设置默认目标,之后convertAndSend可不传destination参数 template.setDefaultDestinationName("demo.queue"); // 默认使用持久化模式 template.setDeliveryMode(DeliveryMode.PERSISTENT); // 默认4秒超时,避免在失效连接上长时间等响应 template.setReceiveTimeout(4000L); return template; } }这里有一个很多人容易忽略的点:JmsTemplate默认情况下每次发送都会自己创建Connection和Session,用完就关闭。如果业务并发量大,频繁创建销毁连接的开销会很明显。生产环境建议配合PooledConnectionFactory来缓存连接和会话,或者直接使用spring-boot-starter-activemq里集成好的连接池配置。
2.3 注解驱动开关不能漏
如果你打算使用@JmsListener注解来写消费端,那光定义容器是不够的,还要开启JMS注解驱动功能。XML时代是加一行<jms:annotation-driven/>,Java配置时代则在某个配置类上添加@EnableJms:
@Configuration @EnableJms public class JmsConfig { // 上面那些Bean照写 }@EnableJms会注册一个JmsListenerAnnotationBeanPostProcessor,Spring在启动时扫描所有Bean中带有@JmsListener注解的方法,然后把方法注册到对应的监听容器里。很多刚接触的同学漏掉这个注解后,发现监听方法怎么都不执行,最后排查半天,就是因为少了这一步。
3. JMS消息生产端实操
3.1 用JmsTemplate发送文本和对象消息
先从一个最简单的Service开始,用JmsTemplate发送一条文本消息:
@Service public class OrderNotifyService { private final JmsTemplate jmsTemplate; public OrderNotifyService(JmsTemplate jmsTemplate) { this.jmsTemplate = jmsTemplate; } public void notifyCreate(String orderNo) { jmsTemplate.convertAndSend("order.notify", "order created: " + orderNo); } }convertAndSend和底层的send方法最大的区别是:convertAndSend会先经过MessageConverter把传入的对象转成Message,然后才发送。你不需要手动去创建TextMessage或者ObjectMessage。比如要发送一个订单对象:
public void notifyOrder(OrderInfo orderInfo) { jmsTemplate.convertAndSend("order.notify", orderInfo); }OrderInfo是普通的POJO,JmsTemplate内部会用SimpleMessageConverter把这个对象包装成ObjectMessage。这里要注意,使用对象消息时,OrderInfo必须实现Serializable接口,否则会抛异常。另外,发送对象消息会比发文本消息慢,如果消息体是JSON字符串,我建议你直接拼好字符串通过文本方式发送,性能更好,后续排查消息内容也更直观一些。
3.2 消息转换器决定序列化方式
关于MessageConverter,这里值得展开说一下。Spring默认的SimpleMessageConverter对String、byte[]、Serializable对象分别做不同类型转换,但它把String转成TextMessage、把对象转成ObjectMessage,在跨系统场景下会暴露一个问题——消息里带着Java序列化的二进制流,接收方如果不是Java语言,很难解析。
更通用的做法是配置一个MappingJackson2MessageConverter,把消息体统一转成JSON文本:
@Bean public MessageConverter jacksonJmsMessageConverter() { MappingJackson2MessageConverter converter = new MappingJackson2MessageConverter(); converter.setTargetType(MessageType.TEXT); converter.setTypeIdPropertyName("_type"); return converter; }配置好后,发送端和接收端只要在同一个Spring容器里,就会自动使用这个转换器,生产端发的对象,消费端方法参数能直接收成对应类型。这个方案适合绝大多数使用JSON做数据交换的微服务场景,唯一的代价是消息体里会多一个_type属性用来标识Java类型。
3.3 发送端容易踩的坑
我在项目里用JmsTemplate发消息时,遇到过几个比较典型的坑,这里集中记录一下。
第一,消息发出去但消费端重启后丢失。原因多半是DeliveryMode设置成了NON_PERSISTENT,消息只存在内存里,Broker重启或者消息未及时落地就会没掉。除非是实时性要求高、丢了也无所谓的广播类消息,否则建议统一设置成PERSISTENT。
第二,JmsTemplate发送消息时目标不存在,默认情况下会由Broker自动创建Queue,但如果Broker那边关闭了autoCreate功能,就会抛InvalidDestinationException。建议在汇编环境统一约定好Queue的命名规范,或者在Broker配置里打开自动创建。
第三,发送端如果设置了explicitQosEnabled=true,却忘了设置优先级和时间参数,会出现IllegalStateException。我遇到过一次,就是因为复制了一个老配置,多了这一行,结果每次发消息都失败,排查了很久。如果不确认参数,就别乱开这个选项。
4. JMS消息消费端实操
4.1 监听器容器到底选哪个
Spring提供了两个监听器容器类:SimpleMessageListenerContainer和DefaultMessageListenerContainer。很多人第一次接触会困惑它俩的区别,其实记住一句话就够:复杂业务场景选Default,测试和简单场景选Simple。
| 对比项 | SimpleMessageListenerContainer | DefaultMessageListenerContainer |
|---|---|---|
| 事务支持 | 不支持JTA事务绑定 | 支持Spring事务管理 |
| 动态调度 | 并发数固定 | 并发数可动态调整 |
| 连接恢复 | 需手动配置 | 自动恢复 |
| 资源消耗 | 较低 | 较高 |
DefaultMessageListenerContainer底层用的是TaskExecutor来做并发调度,它会根据配置动态增加或减少消费者线程,并且能在Broker连接闪断后自动重连恢复监听,所以大多数生产环境推荐使用它。因为@EnableJms默认注册的就是DefaultMessageListenerContainer,所以如果你直接用注解方式开发,就自动走在了正确路线上。
4.2 @JmsListener的完整玩法
消费端用注解写起来很简洁:
@Component public class OrderNotifyListener { @JmsListener(destination = "order.notify") public void onOrderNotify(String message) { System.out.println("收到订单通知:" + message); // 业务处理 } }方法入参可以直接写成业务对象的类型,前提是消息转换器配置正确。比如前面配置了MappingJackson2MessageConverter,这里就可以直接收一个OrderInfo:
@JmsListener(destination = "order.notify", containerFactory = "jmsListenerContainerFactory") public void onOrderNotify(OrderInfo orderInfo) { // 处理订单消息 }containerFactory参数可以指定使用哪个监听器容器工厂。如果不指定,Spring会用默认容器。如果你需要不同队列用不同的并发策略、确认模式,那么定义多个容器工厂,然后让注解引用就可以了。这里还支持用SpEL表达式动态绑定目标名称,比如从配置中心读取目标名:
@JmsListener(destination = "${order.notify.queue}") public void onOrderNotify(OrderInfo orderInfo) { }4.3 并发消费和消息确认
并发消费的核心参数是concurrency,既可以写固定值,也可以写区间。比如concurrency = "3-10"表示初始3个消费者,忙时最多扩展到10个。实际项目中,Queue的并发数通常要根据下游数据库或业务处理耗时来定,不是越大越好。我之前在某个电商项目里,消费端逻辑会调用下游库存接口,接口平均耗时200毫秒,并发从5调到10反而把下游接口打满了,导致消息处理失败率上升。
关于消息确认,@JmsListener默认使用容器工厂里Session配置的确认模式。如果你改成手动确认:
@JmsListener(destination = "order.notify") public void onOrderNotify(Message message, Session session) throws JMSException { // 业务处理完成后手动确认 message.acknowledge(); }这种情况下,业务抛异常时消息不会确认,Broker会重新投递。需要注意的是,手动意味着接收方要自己对消息处理的成功率负责,处理逻辑里最好做幂等性设计,否则重投过程中很容易产生重复消费。
5. 消息事务怎样管才稳
5.1 不配事务时会丢消息的现场
先看一个最常见的坑:消费端收到消息,先更新数据库,然后再发一条通知消息。如果数据库更新成功,但发消息时抛异常,整个过程没有事务保护,就会造成数据不一致。反向的情况也一样,消息先发出去了,数据库回滚了,消费端拿到了一个业务上并不该发生的通知。
再比如说,JmsTemplate发送消息默认是非事务的,send调用后消息即刻到达Broker。如果你希望一组消息要么全发成功,要么全不发,就必须开启事务会话。
5.2 让JmsTemplate支持Spring事务
Spring对JMS事务的支持核心是JmsTransactionManager。配置起来也不复杂:
@Bean public JmsTransactionManager jmsTransactionManager(ConnectionFactory connectionFactory) { return new JmsTransactionManager(connectionFactory); }然后在业务方法上加上@Transactional:
@Transactional public void sendBatch(List<OrderInfo> orders) { orders.forEach(order -> { jmsTemplate.convertAndSend("order.notify", order); }); }加了@Transactional之后,JmsTemplate里的Session会与Spring事务绑定,事务提交时消息才会真正发送,回滚时消息也就不会发出去。这里有个细节:JmsTransactionManager只有一个事务管理器时才能真正协调到同一个Session,如果你同时还有数据库事务,就必须处理多资源事务的问题,单纯标注@Transactional并不能同时跨数据库和JMS,这点下面展开说。
5.3 数据库与队列的一致性该怎么权衡
实际业务中,最常见的组合是:处理消息时写MySQL,然后往另一个队列里发通知。这种跨数据库和消息系统的事务,最直接的办法是引入分布式事务,比如JTA,但成本很高,而且ActiveMQ的JTA支持配置起来并不轻松,性能损耗也大。
现实项目中更常见的方案是本地消息表 + 定时任务补偿。基本思路是:消息先写本地业务表,同时往里写入一条待发送消息记录,同一个数据库事务保证这两步一致;然后后台任务扫描待发送记录,调用JmsTemplate发送,收到确认后标记为已发送。这套方案的优点是避免了分布式事务,缺点是引入了额外的表和补偿任务,开发量会大一些。
在做方案选型时,我的建议是:如果只是单条消息的发送,不需要和多资源一起保证一致性,用JmsTransactionManager就够了;如果牵涉到数据库和MQ跨资源一致,不要硬上分布式事务,优先考虑最终一致性设计。
6. 常见问题排查实录
6.1 高频问题速查表
这些年来我处理过不少Spring集成JMS的问题,把最高频的几个整理成了表格,遇到问题可以对号入座:
| 问题现象 | 可能原因 | 处理办法 |
|---|---|---|
| 监听方法一直不执行 | 缺少@EnableJms | 检查配置类是否开启注解驱动 |
| 能消费但消息顺序乱了 | 并发消费者大于队列分区数 | 需要保证顺序时把并发设为1,或改用分区键 |
| 消息处理失败后不断重投 | 未捕获异常且未做重试上限 | 消费方法内做try/catch,达到阈值转发到死信队列 |
| 连接频繁断开 | Broker连接串没有failover | 使用failover:tcp://host1:61616,tcp://host2:61616 |
| Broker内存暴涨 | 积压消息过多且消费慢 | 扩大消费者并发,减少消息体体积,开启消息压缩 |
| 对象消息反序列化失败 | 消费端没有对应类,或未实现Serializable | 改用JSON文本消息,或保证两端类一致 |
6.2 一个典型的“监听不生效”排查实例
有一次我在项目里接一个消息队列,配置写好后启动服务,生产端发消息,消费端那边日志里一点动静都没有。当时第一反应是看Broker,队列里的消息确实进去了,没有消费掉。接着我做了这几步排查:
第一,确认消费端有没有注册成功。看日志中是否出现Subscribed to destination: xxx一类的内容,如果没有,说明监听器根本没注册上。结果日志里没有,于是怀疑是不是忘了@EnableJms,检查配置类确实漏了这一行,加上后服务重启,问题解决。
第二,如果注册成功但仍不消费,再检查destination名称是否完全匹配。JMS目标名是大小写敏感的,生产端发到order.notify,消费端监听order.Notify就不会命中。
第三,如果名称也没问题,要看监听方法是否抛异常导致消息被回滚。DefaultMessageListenerContainer默认会把异常继续上抛,消息会重新投递,可能一直在重试循环里打日志,看起来就像“没消费”。
这套排查思路其实可以套用到大多数“监听器不干活”的场景,顺着注册、目标匹配、处理异常三边走一遍,基本都能定位。
最后再分享一个我习惯用的小技巧:在开发环境里,我会给消费方法临时加一条日志,打印消息ID和内容,单独标记一下消费入口。这样一旦出现问题,能很快分清是消息没到、转换失败,还是业务代码抛异常,省下不少排查时间。