1. 为什么要在 SpringBoot 里引入 JMSTemplate
1.1 从一次消息堆积事故说起
前两年我接手过一个订单履约系统,上游是交易服务,下游是仓储和风控。最初为了图快,交易服务在下单成功后直接通过 HTTP 同步调用仓储接口扣减库存,结果大促当天仓储服务响应变慢,交易线程池被拖垮,整个下单链路雪崩。事后复盘,问题根因不是仓储服务本身,而是同步调用把两个系统的可用性绑死在了一起。后来我们把扣减库存、发券、风控埋点这些非核心链路全部改成异步消息,交易服务只负责把消息投递到消息中间件,剩下的交给下游慢慢消费。这个改造里,JMSTemplate就是我们用来发消息的核心工具。
很多人一提到消息队列就想到 Kafka、RocketMQ,但在企业级 Java 生态里,JMS(Java Message Service)是一套非常经典且稳定的规范,ActiveMQ、Artemis、IBM MQ 等产品都实现了这套规范。Spring 提供的JmsTemplate就是对 JMS 规范的一层轻量封装,它帮你处理了连接获取、会话创建、生产者/消费者创建、异常转换、资源释放这些繁琐的样板代码。你只需要注入一个JmsTemplate,调一个convertAndSend,消息就出去了。
1.2 JMSTemplate 到底解决了什么问题
我先把结论摆在这儿:JmsTemplate解决的核心问题是把 JMS 原生 API 的模板化操作抽象出来,让发送和接收消息像调用一个普通方法一样简单。如果你写过原生 JMS 代码,一定对下面这套流程印象深刻:
ConnectionFactory factory = new ActiveMQConnectionFactory("tcp://localhost:61616"); Connection connection = factory.createConnection(); connection.start(); Session session = connection.createSession(false, Session.AUTO_ACKNOWLEDGE); Destination destination = session.createQueue("order.queue"); MessageProducer producer = session.createProducer(destination); TextMessage message = session.createTextMessage("hello"); producer.send(message); producer.close(); session.close(); connection.close();这段代码里真正跟业务相关的只有producer.send(message)这一行,其余全是资源管理。而用JmsTemplate,同样的逻辑只需要:
jmsTemplate.convertAndSend("order.queue", "hello");连接、会话、生产者的创建和关闭,Spring 全帮你做了。这就是模板方法模式的价值——把不变的流程固化,把变化的部分留给你。
1.3 适合哪些场景,不适合哪些场景
JmsTemplate不是银弹,我踩过坑之后总结出它的适用边界。适合的场景包括:系统间解耦(下单后异步通知多个下游)、削峰填谷(秒杀请求先入队再慢慢消费)、异步任务(发邮件、生成报表)、事件驱动(领域事件广播)。不适合的场景也很明确:超高吞吐(十万级 TPS 以上,Kafka/RocketMQ 更合适)、需要消息轨迹和事务消息(RocketMQ 更强)、需要复杂路由(RabbitMQ 的 Exchange 更灵活)。
提示:如果你的团队已经在用 Spring 生态,且消息量在万级 TPS 以内,
JmsTemplate配合 ActiveMQ 或 Artemis 是完全够用的,别为了追新而过度设计。
2. 环境搭建与核心依赖选型
2.1 依赖引入的取舍
SpringBoot 整合 JMS 的依赖其实很干净。核心就两个:spring-boot-starter-activemq(或者spring-boot-starter-artemis)和spring-boot-starter-web(如果你需要 Web 层触发发送)。我一般用 ActiveMQ 的经典版做演示,因为它的配置最直观。
<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-activemq</artifactId> </dependency> <dependency> <groupId>org.apache.activemq</groupId> <artifactId>activemq-pool</artifactId> </dependency>这里有个细节很多人忽略:一定要引入连接池依赖。JmsTemplate默认每次发送都会创建连接和会话,虽然 Spring 做了缓存,但在高并发下没有连接池仍然会成为瓶颈。引入activemq-pool后,配合spring.activemq.pool.enabled=true,性能会有明显提升。我实测过,单机发送 1 万条消息,开池比不开池耗时少了将近 40%。
2.2 配置文件的关键参数
application.yml里的配置决定了消息中间件的行为,我挑几个最关键的讲:
spring: activemq: broker-url: tcp://127.0.0.1:61616 user: admin password: admin pool: enabled: true max-connections: 50 packages: trust-all: false trusted: com.example.dto jms: template: default-destination: order.queue receive-timeout: 5000 delivery-mode: persistentbroker-url是连接地址,pool.enabled开启连接池。packages.trusted这个参数特别重要——JMS 默认不允许反序列化任意包下的对象,这是为了防止反序列化漏洞。你必须显式声明哪些包下的类可以被信任,否则消费端会抛ClassNotFoundException或者安全异常。我第一次遇到这个问题时排查了大半天,后来才明白这是 ActiveMQ 的安全机制。
delivery-mode: persistent表示消息持久化,broker 重启后消息不丢。如果追求极致吞吐且能接受丢消息,可以改成non-persistent,性能会提升不少,但生产环境我强烈建议保持持久化。
2.3 队列与主题的选择逻辑
JMS 有两种消息模型:Queue(点对点)和Topic(发布订阅)。Queue 是一条消息只能被一个消费者消费,适合任务分发;Topic 是一条消息广播给所有订阅者,适合事件通知。
我见过不少团队无脑用 Queue,结果需要广播时又加了一堆代码去模拟。选型逻辑其实很简单:如果多个下游都需要收到同一条消息,用 Topic;如果多个消费者竞争消费同一条消息,用 Queue。比如订单创建事件,仓储、风控、积分都要处理,那就用 Topic;而订单超时关闭任务,只需要一个消费者处理,用 Queue 即可。
3. 发送端实现与核心细节
3.1 注入 JmsTemplate 并发送消息
SpringBoot 自动配置会帮你创建一个JmsTemplateBean,你直接注入即可。发送文本消息最简单:
@Service public class OrderMessageProducer { @Autowired private JmsTemplate jmsTemplate; public void sendOrderCreated(OrderDTO order) { jmsTemplate.convertAndSend("order.created.queue", order); } }convertAndSend会自动把对象转换成Message。默认情况下,如果传入的是String,会转成TextMessage;如果是Serializable对象,会转成ObjectMessage;如果是Map,会转成MapMessage。这个转换逻辑由MessageConverter决定。
3.2 自定义 MessageConverter 的坑
默认的SimpleMessageConverter对Serializable对象使用 Java 原生序列化,这有两个问题:一是性能差,二是跨语言不友好。我一般会换成Jackson2JsonMessageConverter,把对象转成 JSON 文本发送。
@Configuration public class JmsConfig { @Bean public MessageConverter jacksonJmsMessageConverter() { MappingJackson2MessageConverter converter = new MappingJackson2MessageConverter(); converter.setTargetType(MessageType.TEXT); converter.setTypeIdPropertyName("_type"); return converter; } }这里有个关键点:setTypeIdPropertyName("_type")会在消息头里写入类的全限定名,消费端靠这个字段反序列化成正确的类型。如果你不设置,消费端拿到 JSON 后不知道转成什么类,就会报错。我踩过一次坑,发送端和消费端用了不同的typeIdPropertyName,结果消费端一直反序列化失败,排查了很久才发现是两边配置不一致。
3.3 发送时的消息头与优先级
有时候你需要给消息加一些元数据,比如业务追踪 ID、重试次数。JmsTemplate提供了MessagePostProcessor来在发送前修改消息:
jmsTemplate.convertAndSend("order.created.queue", order, message -> { message.setStringProperty("traceId", MDC.get("traceId")); message.setIntProperty("retryCount", 0); message.setJMSPriority(5); return message; });setJMSPriority可以设置消息优先级,0 最低,9 最高。但要注意,优先级只在 Queue 中生效,且需要 broker 支持。ActiveMQ 默认开启优先级需要配置broker的prioritizeMessages参数,否则优先级会被忽略。这个细节文档里写得不明显,我是看源码才确认的。
注意:消息头里的属性名不要用
JMS开头的保留字,否则会抛JMSException。自定义属性建议加业务前缀,比如biz_traceId。
4. 消费端实现与并发控制
4.1 使用 @JmsListener 监听消息
消费端最优雅的方式是用@JmsListener注解,Spring 会自动帮你创建消息监听容器:
@Component public class OrderMessageConsumer { @JmsListener(destination = "order.created.queue") public void handleOrderCreated(OrderDTO order) { // 处理业务逻辑 System.out.println("收到订单:" + order.getOrderId()); } }这个注解背后是DefaultMessageListenerContainer,它负责管理消费者线程、连接、会话。默认情况下,每个@JmsListener会启动一个消费者线程。如果你的消息量很大,需要提高并发度,可以通过concurrency参数控制:
@JmsListener(destination = "order.created.queue", concurrency = "5-10") public void handleOrderCreated(OrderDTO order) { ... }5-10表示最小 5 个消费者线程,最大 10 个。这个参数要根据消息量和处理耗时来调。我一般会先压测,观察消费延迟,再决定并发数。盲目调大并发不一定好,因为每个消费者都会占用一个数据库连接,并发太高反而会把数据库连接池打满。
4.2 消息确认模式的选择
JMS 有几种确认模式:AUTO_ACKNOWLEDGE、CLIENT_ACKNOWLEDGE、DUPS_OK_ACKNOWLEDGE、SESSION_TRANSACTED。Spring 的DefaultMessageListenerContainer默认使用AUTO_ACKNOWLEDGE,也就是消息监听方法正常返回后自动确认。
但这里有个陷阱:如果监听方法抛异常,消息会重新投递。默认重投递次数是 6 次,超过后消息会进入死信队列(DLQ)。我见过一个 bug,消费端因为一个空指针异常导致消息无限重投,最后把 broker 的 DLQ 撑爆了。所以消费端一定要做好异常处理,对于不可恢复的异常,要么捕获后记录日志并确认,要么配置死信队列做兜底。
@JmsListener(destination = "order.created.queue") public void handleOrderCreated(OrderDTO order) { try { // 业务逻辑 } catch (BusinessException e) { log.error("业务异常,消息丢弃", e); // 不抛出,消息会被确认 } catch (Exception e) { log.error("系统异常,消息重投", e); throw e; // 抛出,触发重投 } }4.3 事务与消息的一致性
这是最容易出问题的地方。假设消费端处理消息时要写数据库,如果数据库写入成功但消息确认失败,消息会重投,导致重复消费。解决办法有两个:幂等设计和事务消息。
幂等设计是根本。我一般会在消费端加一个去重表,用消息 ID 做唯一索引,处理前先插入去重记录,插入成功才继续处理。这样即使消息重投,第二次插入会失败,直接跳过。
@Transactional public void handleOrderCreated(OrderDTO order, String messageId) { try { dedupMapper.insert(messageId); } catch (DuplicateKeyException e) { log.warn("重复消息,忽略:{}", messageId); return; } // 业务处理 }至于事务消息,JMS 规范本身支持SESSION_TRANSACTED,但配置起来比较麻烦,而且和数据库事务是两套事务,无法做到真正的原子性。我的经验是:优先用幂等设计,事务消息只在极少数强一致场景下才考虑。
5. 常见问题排查与避坑实录
5.1 消息发送成功但消费端收不到
这个问题我遇到过好几次,排查思路可以总结成一张表:
| 排查点 | 可能原因 | 解决方法 |
|---|---|---|
| 目的地名称 | 发送端和消费端队列名不一致 | 检查两边字符串是否完全一致 |
| 消息类型 | Queue 发到了 Topic | 确认目的地类型匹配 |
| 连接地址 | 连到了不同的 broker | 检查 broker-url 配置 |
| 消费端启动 | 监听器未注册 | 检查 @JmsListener 是否生效 |
| 权限 | 用户无消费权限 | 检查 broker 的权限配置 |
最常见的是目的地名称不一致。有一次我发送端写的是order.created.queue,消费端写的是order.create.queue,少了一个d,结果消息全进了 DLQ。这种低级错误排查起来最费时间,建议把目的地名称抽成常量类,两边引用同一个常量。
5.2 消息重复消费
重复消费的根因是确认机制和业务处理不在同一个事务里。消息处理成功但确认前消费者宕机,消息就会重投。解决办法就是前面说的幂等设计。我补充一个细节:幂等键最好用消息自带的 JMSMessageID,而不是业务 ID,因为同一条业务消息可能被发送多次,业务 ID 相同但消息 ID 不同。
@JmsListener(destination = "order.created.queue") public void handle(OrderDTO order, @Header(JmsHeaders.MESSAGE_ID) String messageId) { // 用 messageId 做幂等 }5.3 消费端线程池被打满
如果消费端处理逻辑里有阻塞操作(比如调用外部 HTTP 接口),消费者线程会被长时间占用,导致消息堆积。我的做法是把阻塞操作异步化:消费端只负责把消息落库,然后交给业务线程池处理。这样消费者线程能快速释放,吞吐量会大幅提升。
@JmsListener(destination = "order.created.queue", concurrency = "10-20") public void handle(OrderDTO order) { // 只做落库,快速返回 orderMapper.insert(order); // 异步处理后续逻辑 businessExecutor.submit(() -> processOrder(order)); }提示:异步化之后要注意线程池的拒绝策略,我一般用
CallerRunsPolicy,队列满了就让消费者线程自己执行,起到背压作用,避免任务丢失。
5.4 消息体过大导致发送失败
JMS 对消息大小有限制,ActiveMQ 默认最大 100MB,但实际使用中超过几 MB 就会明显变慢。我遇到过发送一个包含大量明细的订单对象,序列化后 8MB,发送耗时超过 2 秒。解决办法是消息里只放 ID,消费端根据 ID 回查数据。这样消息体小,传输快,也避免了数据不一致。
// 不推荐 jmsTemplate.convertAndSend("order.queue", hugeOrderObject); // 推荐 jmsTemplate.convertAndSend("order.queue", order.getId());6. 性能调优与生产实践
6.1 连接池参数调优
连接池的核心参数是max-connections和maximumActiveSessionPerConnection。前者控制到 broker 的最大连接数,后者控制每个连接上最大的活跃会话数。我一般会把max-connections设为消费者并发数加发送端并发数之和,再留 20% 余量。
spring: activemq: pool: enabled: true max-connections: 50 maximum-active-session-per-connection: 100 idle-timeout: 30000 expiry-timeout: 0idle-timeout是空闲连接超时时间,expiry-timeout是连接过期时间。生产环境我建议expiry-timeout设为 0,表示永不过期,避免连接频繁重建。
6.2 批量发送与异步发送
如果一次要发很多消息,逐条发送效率很低。JmsTemplate本身不支持批量,但你可以开启异步发送:
spring: activemq: pool: enabled: true # 开启异步发送 broker-url: tcp://127.0.0.1:61616?jms.useAsyncSend=true异步发送的意思是生产者把消息写入本地缓冲区就返回,不等 broker 确认。这样吞吐量会大幅提升,但如果 broker 宕机,缓冲区里的消息会丢失。所以异步发送只适合能容忍少量丢失的场景,比如日志采集。
6.3 监控与告警
生产环境一定要监控消息队列的健康状况。我一般会关注这几个指标:队列深度(QueueSize)、消费者数量(ConsumerCount)、入队速率(EnqueueCount)、出队速率(DequeueCount)。队列深度持续增长说明消费能力不足,需要扩容消费者;消费者数量为 0 说明消费端挂了,要立即告警。
ActiveMQ 提供了 JMX 接口,可以通过 JMX 采集这些指标。SpringBoot 的 Actuator 也提供了 JMS 的健康检查端点,配置management.endpoint.health.show-details=always就能看到 broker 的连接状态。
7. 一些个人经验与后续扩展
7.1 我踩过的三个印象最深的坑
第一个坑是反序列化白名单。前面提过,packages.trusted不配置,消费端直接报错。这个问题的隐蔽性在于,发送端一切正常,只有消费端报错,很容易误以为是消费端代码问题。
第二个坑是消息优先级不生效。我设置了setJMSPriority(9),结果消息还是按 FIFO 顺序消费。后来查文档才知道,ActiveMQ 默认不开启优先级,需要在 broker 配置里加prioritizeMessages=true。
第三个坑是事务回滚导致消息重投。消费端方法上加了@Transactional,业务异常时数据库回滚,但消息确认也一起回滚了,导致消息重投。后来我把事务边界缩小到只包裹数据库操作,消息确认放在事务外。
7.2 后续可以怎么扩展
如果你已经把JmsTemplate用起来了,下一步可以考虑这几个方向:消息轨迹追踪(在消息头里透传 traceId,配合链路追踪系统)、死信队列处理(把 DLQ 里的消息捞出来人工干预或自动重试)、多 broker 容灾(配置多个 broker-url,实现故障转移)。这些扩展能让你的消息系统从"能用"变成"好用"。
最后分享一个小技巧:在开发环境把receive-timeout设小一点(比如 1000ms),这样消费端启动时能快速感知到 broker 不可用,而不是傻等 5 秒。生产环境再调回 5000ms,避免频繁超时。这个参数虽小,但对开发调试体验影响很大。