ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

SpringBoot整合JMSTemplate实战:从消息堆积事故到性能调优

SpringBoot整合JMSTemplate实战:从消息堆积事故到性能调优

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: persistent

broker-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: 0

idle-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,避免频繁超时。这个参数虽小,但对开发调试体验影响很大。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进