在JMS和ActiveMQ这个圈子里泡久了,你会发现大多数问题不是出在“不会用”,而是出在“没理解”。我前段时间就栽在一个看似简单的消息重复消费问题上,查了两天才发现,根子不在Spring Boot代码,而在ActiveMQ的确认机制和重发策略上。这篇就把当时的学习过程、排查链路和一些可以直接抄走的经验整理成“一则”笔记,既讲清楚JMS规范里那几个容易被忽略的点,也会给出实际可用的JMX查询方法,尤其是用TopicSubscriptionViewMBean观察订阅者状态这部分,希望能帮你在遇到类似问题时少走弯路。
如果你是刚接触消息中间件,或者已经在用ActiveMQ但总被奇怪现象困扰,这篇内容会比较对胃口。我会尽量把每一步为什么这么做解释清楚,不光是甩配置和代码。
1. 先从一次“只发不收”的诡异现象讲起
那是一个周五下午,测试环境报了个消息积压的告警:生产者成功发送了一条订单状态变更消息,消费者端却一直没动静。我去看ActiveMQ的Web控制台,Queue的EnqueueCount正常增加,DequeueCount却纹丝不动,消息就这么堆在队列里。更奇怪的是,消费者服务没有任何报错,日志一片安静。
当时第一反应是“消费者没连上”,于是去翻Spring Boot的ActiveMQ配置,连接地址没问题,用户名密码没问题,监听器注解也写了。然后我开始怀疑是不是消费逻辑里try-catch把异常吞了,但排查一圈,消费者方法里完全没有异常捕获,理论上有问题应该会抛出来。
后来我在控制台上点了那条消息的Message ID,顺手查了一下JMSRedelivered标志位,发现消息的DeliveryMode是PERSISTENT,Redelivered是false,一切看起来都很正常。这时候才意识到,问题可能不在消息本身,而在于消费者会话的ACK模式。默认情况下,Spring Boot整合ActiveMQ,如果使用spring.jms.listener.acknowledge-mode不显式配置,实际走的是AUTO_ACKNOWLEDGE。AUTO_ACKNOWLEDGE的意思是,消息只要被消费者成功接收并返回,就自动确认。如果回调方法里因为某种原因抛了异常,事务回滚或确认失败,消息就会重新投递。
但现象是既不确认也不重投,直接卡住。继续深挖之后发现,真正的原因是监听器容器在启动时创建会话失败,但因为配置了重连机制,它一直在后台重试,而SpringBoot默认日志级别下,这个重试过程并不会输出到应用日志里,于是看起来就好像什么都没发生。
这个坑让我明白一件事:ActiveMQ的资料虽然多,但大多数教程只告诉你“怎么连”,很少有人讲清楚“确认、重投、回滚”这三者之间的关系。所以这篇学习笔记,我会从JMS规范里那几个核心概念讲起,再落到ActiveMQ的存储和重发机制,然后用Spring Boot整合场景做串联,最后给出一套直接能用的JMX监控查询方式。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. JMS规范里最容易绕晕的四个基础概念
2.1 Queue和Topic:不是“二选一”,是两种投递语义
很多初学者会把Queue和Topic当成“两种队列”,其实它们代表的是两种完全不同的消息投递语义。
Queue对应点对点模型:一条消息只会被一个消费者消费,哪怕有多个消费者在监听同一个队列,消息也会被负载均衡地分给其中一个。这个模型下,消息一旦被某个消费者成功确认,就会从队列中移除,其他消费者不会再看到。
Topic对应发布订阅模型:一条消息会被广播给所有订阅了该Topic的消费者。这里注意,如果消费者只是临时订阅(Non-Durable Subscription),那么它在线期间才会收到消息,离线期间发布的消息会丢。如果要用持久订阅,需要给订阅者设置一个持久的ClientId和订阅名称。
我在实际项目里见过有人把Topic当作Queue用,消费者设置了普通监听,结果服务重启期间的消息全部丢失,还以为是网络问题。这正是没区分两种模型导致的。
两种模型的选择逻辑其实很简单:
- 业务上希望一条消息只被处理一次,比如订单支付回调、积分变更,选Queue。
- 业务上希望所有关注方都能收到消息,比如商品价格变动、用户上线通知,选Topic。
需要提醒的是,Topic + 持久订阅的实现复杂度比Queue高不少,ActiveMQ要求消费者在连接时设置clientId,并且订阅名称要保证唯一。如果你只是想让多个消费者分摊消息压力,不要用Topic,直接用Queue + 多个监听实例就可以。
2.2 消息确认:AUTO_ACKNOWLEDGE和CLIENT_ACKNOWLEDGE的差别
JMS的Session有几种确认模式,最常见的是:
| 确认模式 | 行为特点 | 典型使用场景 |
|---|---|---|
| AUTO_ACKNOWLEDGE | 消息被consumer接收并成功返回后自动确认 | 默认模式,适合大多数“处理后删除”的业务 |
| CLIENT_ACKNOWLEDGE | 需要手动调用message.acknowledge()确认 |
需要批量确认、或者业务处理成功后才确认的场景 |
| DUPS_OK_ACKNOWLEDGE | 允许重复确认,吞吐高但可能重复投递 | 对消息重复不敏感的场景 |
| SESSION_TRANSACTED | 事务会话,提交事务才确认 | 需要强一致性、多消息同时确认或回滚的场景 |
AUTO_ACKNOWLEDGE最大的坑在于:它默认消费者“只要收到就算成功”。如果你在监听方法里做了外部调用,比如写数据库、调第三方接口,只有这些操作都成功后,才应该算业务成功。但AUTO_ACKNOWLEDGE模式下,ActiveMQ不会等你的业务代码执行完,它在消息从receive()返回给监听容器时就认为已确认。虽然大多数Spring Boot实现会等监听方法正常返回后才提交确认,但如果你不是用Spring的JmsListener,而是自己写原生JMS代码,很容易踩到“消息已确认但业务还没成功”的坑。
CLIENT_ACKNOWLEDGE模式则把确认控制权交给开发者,只有你显式调用acknowledge(),消息才会被标记为已消费。适合那种“先入库,再确认,确保不丢消息”的场景。但注意,acknowledge()是Session级别的操作,它会确认当前Session中所有已消费但未确认的消息,不是只确认你手里这一条。
2.3 持久化订阅:不是“Listener里加个参数”那么简单
持久化订阅(Durable Subscription)是为了解决Topic消息在消费者离线期间丢失的问题。它的原理是ActiveMQ为每个持久订阅者单独保存一份消息副本,订阅者重新连接后,ActiveMQ会把离线期间的消息重新投递给它。
配置持久订阅的关键是clientId和subscriptionName。在Spring Boot中,你需要为监听容器设置ClientId,同时用@JmsListener的subscription属性指定订阅名。这里有一个常见误区:很多文章说“设置了clientId就能持久订阅”,其实不够。ActiveMQ要求连接工厂提供clientId,而且同一个clientId只能存在一个活跃连接,否则会抛InvalidClientIDException,导致消费者根本起不来。
我见过一个生产事故,就是把两个不同服务的clientId配成了同一个值,结果两个服务反复抢占连接,消息一会儿消费一会儿不消费。排查了很长时间。
2.4 事务会话:Local Transaction的“副作用”
当你选择SESSION_TRANSACTED模式时,所有消息的发送和接收都在一个事务里。事务提交后,消息才被真正发送或确认;事务回滚,则消息会重新投递。
事务会话带来的一个容易被忽略的问题是:如果消费者在处理消息过程中抛异常并回滚,ActiveMQ会认为这条消息没有被成功消费,接着触发重投。重投次数默认是6次,超过后会进入死信队列。所以,如果你看到一条消息被消费了多次,不要急着怀疑重复发送,先确认消费者端是不是在抛异常回滚。
事务会话和普通Session还有一个大区别:事务内的消息发送是批量提交的,如果没有显式commit,消息会一直堵塞在Session缓冲区。某些情况下,代码里漏了commit(),消息就永远发不出去,且没有任何报错。这个问题调试起来极其隐蔽,因为你看到的日志都是“发送成功”,实际Broker端什么都没收到。
3. ActiveMQ的存储与重发机制:为什么“发两次”不一定是Bug
3.1 KahaDB存储与日志文件
ActiveMQ默认使用KahaDB作为持久化存储。这个存储引擎本质上是基于日志的,消息写入时先追加日志文件,然后定期做数据清理。你可能会在data目录下看到很多db-1.log、db-2.log之类的文件,这些就是KahaDB的日志文件。
KahaDB默认配置下,消息被消费确认后,不会立刻从磁盘上物理删除,而是标记为可清理状态。只有等到checkpoint机制触发,或者日志文件达到一定大小,才会执行清理。这就导致一个现象:DequeueCount已经增加了,但队列的存储占用没有立刻降下来。这不是消息没删,是存储引擎的正常机制。
如果你在生产环境发现data目录持续增长,不要急着删文件,先检查是不是有大量未确认消息积压,或者存在持久订阅者长期不在线。持久订阅者离线期间,Broker会一直为它保留消息副本,哪怕这些消息其他订阅者已经消费了,存储占用也不会下降。
3.2 重发策略与死信队列
ActiveMQ的重发策略默认是:消息消费失败后,最多重发6次,每次间隔逐次增加。超过最大重发次数后,消息会被投递到死信队列,默认死信队列后缀是ActiveMQ.DLQ。
这里的“重发”和“重复发送”是两件事。重发是Broker主动把同一条消息再次投递给消费者,消息头的JMSRedelivered标志会变为true;重复发送是生产者把两条相同内容的业务消息先后发到队列,它们的Message ID不同。排查的时候先看Message ID,如果两条消息Message ID相同,就属于重发,问题在消费者或Broker配置;如果Message ID不同,就要去查生产者为什么发了两次。
有一个很经典的场景:消费者处理消息时调用了外部接口,外部接口超时,但最终业务实际成功了。这时消费者抛出异常,ActiveMQ触发重发,消费者再次处理时,外部接口又执行了一次。如果这个接口不具备幂等性,就会产生重复数据。这个问题的根源不在ActiveMQ,而在于你的业务代码没有做幂等控制。所以我一直强调,消息消费者一定要实现幂等,这是消息系统的“基本素养”。
3.3 幂等性设计的三层兜底
消息重复消费是分布式系统绕不开的话题。最常见的幂等方案有三种:
第一层:数据库唯一索引约束。比如订单消息处理时,以订单号作为唯一键插入流水表,重复插入会抛唯一键冲突,捕获后直接忽略。
第二层:Redis分布式锁或业务主键判重。在消费者入口检查业务主键是否已经存在,存在则直接返回成功。
第三层:消息头中的JMSMessageID判重。可以把已处理的消息ID记录在Redis或数据库表中,消费前先查询。
我通常建议做前两层,因为依赖消息ID判重有个问题:不同消息可能携带同一业务数据,比如“用户修改资料”这个动作被发送了两次,Message ID不同,但业务结果应该只生效一次。只有以业务主键判重才是真正可靠的。
还有一点需要特别注意:死信队列里的消息不是“垃圾”,而是“需要人工干预的问题消息”。我见过有人直接配置deadLetterQueue不启用,让重发次数无限大,结果消息一直在循环重发,把CPU和磁盘都打满了。正确做法是保留死信队列,同时为死信队列配置独立的消费者,把其中的消息落库或对接告警系统。
4. Spring Boot整合ActiveMQ:默认配置下的三个认知误区
4.1 JmsTemplate的默认行为和“伪发送成功”
Spring Boot通过spring-boot-starter-activemq依赖可以非常方便地整合ActiveMQ。使用JmsTemplate发送消息时,默认情况下deliveryMode是持久化,timeToLive是永久。这意味着消息一旦发送成功,即使ActiveMQ宕机,消息也会在恢复后被重新投递。
但有一个隐藏坑:JmsTemplate的send()方法只是把消息提交给了本地JMS Session和连接,如果Broker不可用,默认配置下会抛出JmsException。问题在于,很多项目为了方便,给发送逻辑套了一个大try-catch,把异常吞掉,然后打一条warn日志。这就会造成“伪发送成功”——上游以为消息发出去了,实际上Broker根本没收到,或者收到了但因为连接问题落在了本地缓冲区。
我在项目中要求的规范是:发送消息时不允许吞异常,必须区分“业务成功”和“发送成功”。如果是重要消息,可以考虑发送失败后写入本地消息表,用定时任务兜底补发。
4.2 监听器并发与顺序性
Spring Boot的@JmsListener默认使用DefaultMessageListenerContainer,它有一个并发消费者数配置:concurrency属性。如果不显式设置,默认并发是1,也就是单线程串行消费。对于吞吐量要求高的场景,你可能会设置concurrency="3-10",让多个线程同时消费。
但这里有个容易被忽略的后果:并发消费会破坏消息顺序。假设生产者连续发送了“创建订单”“取消订单”两条消息,如果消费者并发为2,有可能“取消订单”被线程B先处理,“创建订单”被线程A后处理。对于依赖顺序的业务,比如状态机流转,这是灾难。
解决顺序性有三种方案:
- 将需要保证顺序的消息发送到同一个Queue,并设置消费者并发为1。
- 在消息中增加序列号,消费者收到后先缓存再按序处理。
- 通过消息分组机制,比如ActiveMQ的Message Group,可以让同一组消息被同一个消费者线程处理。
Message Group的实现是在消息头中设置JMXGroupID和JMXGroupSeq,ActiveMQ会根据GroupID把同一组的消息路由到同一个消费者。这个功能在处理“按用户维度保证顺序”的场景下很好用,但要注意它的底层依赖消息分桶,如果Broker发生主备切换,分组信息会重置,极端情况下可能出现顺序错乱。
4.3 连接池与FailoverURL的配置细节
Spring Boot默认不会为你配置连接池,它创建的是ActiveMQConnectionFactory,底层使用SingleConnectionFactory。这个工厂会复用同一个Connection,但从同一个Connection创建的Session不是线程安全的。如果发送消息的并发很高,可能导致Session被多个线程同时使用,出现javax.jms.IllegalStateException或者消息顺序混乱。
生产环境建议使用PooledConnectionFactory或ActiveMQ自带的连接池,并为连接池设置maxConnections和maximumActiveSessionPerConnection。常见配置是maxConnections=10,maximumActiveSessionPerConnection=50。
Failover URL是另一个重点。它允许客户端在Broker不可用时自动重连到其他Broker。例如:
failover:(tcp://192.168.1.10:61616,tcp://192.168.1.11:61616)?randomize=false
randomize=false表示优先连接第一个地址,这适合一主一备的经典部署。如果希望负载均衡,可以改为randomize=true。另外,Failover重连的默认参数是initialReconnectDelay=1000,maxReconnectDelay=30000,如果业务对消息发送延迟敏感,可以调低重连间隔。
需要特别说明的是,Failover的处理逻辑是“客户端本地重连”,不是“服务端高可用”。如果主Broker宕机时存在未提交事务,客户端Failover重连后,这部分事务可能需要人工介入。所以不要以为配了Failover就万事大吉,持久化存储和高可用策略依然要做。
5. JMX查询TopicSubscriptionViewMBean:用数据替代猜测
5.1 开启JMX并连接ActiveMQ
JMS和ActiveMQ的学习中,监控是很重要的一环。ActiveMQ的JMX支持默认是开启的,但需要确认配置文件。在activemq.xml中,查找broker节点的useJmx属性是否为true。如果你通过Spring Boot内嵌的ActiveMQ来跑,还需要在连接工厂中加上jmxEnabled=true。
连接JMX有两种常用方式:
- 使用JDK自带的
jconsole,输入ActiveMQ所在机器的IP和JMX端口(默认是1099,也可在配置中修改)。 - 通过代码连接,用
JMXConnector获取MBeanServerConnection。
jconsole适合人工排查,脚本化监控则需要代码实现。我在项目里更倾向于第二种,因为可以定时抓取指标,接入告警。
5.2 TopicSubscriptionViewMBean能提供哪些信息
ActiveMQ的Broker在JMX域org.apache.activemq下暴露了大量MBean。其中比较常用的是Broker下的Topic节点,以及Subscription节点。TopicSubscriptionViewMBean就是用来查看某个Topic订阅者状态的MBean。
通过这个MBean,你可以拿到以下关键信息:
ClientId:订阅者的客户端ID。SessionId:订阅者对应的会话ID。DispatchedQueueSize:等待分发给消费者的消息数量,如果这个数值持续增长,说明消费者处理速度跟不上,或者消费者已经假死。EnqueueCounter:该订阅者累计收到的消息数。DequeueCounter:该订阅者累计确认消费的消息数。PendingQueueSize:订阅者本地待处理队列大小。SubscriptionName:持久订阅的订阅名称。
最常用的排查场景是:Topic消息积压,但消费者进程正常。这时候用jconsole查看Subscription,如果PendingQueueSize很大,而DispatchedQueueSize为0,说明消息已经分发给客户端但客户端迟迟没有提交确认,问题出在消费逻辑;如果DispatchedQueueSize也很大,说明Broker正在尝试分发但客户端没有接收,可能是消费者Session阻塞或网络问题。
5.3 一段可以直接用的JMX查询代码
我写过一个小的Java工具类,用来在服务启动时自动检测Topic订阅者的积压情况。核心代码如下:
java复制import javax.management.*;
import javax.management.remote.*;
import java.util.HashMap;
import java.util.Map;
public class ActiveMQJMXMonitor {
public static void main(String[] args) throws Exception {
String jmxUrl = "service:jmx:rmi:///jndi/rmi://127.0.0.1:1099/jmxrmi";
Map<String, Object> env = new HashMap<>();
env.put(JMXConnector.CREDENTIALS, new String[]{"admin", "admin"});
JMXServiceURL url = new JMXServiceURL(jmxUrl);
JMXConnector connector = JMXConnectorFactory.connect(url, env);
MBeanServerConnection conn = connector.getMBeanServerConnection();
ObjectName query = new ObjectName("org.apache.activemq:type=Broker,brokerName=localhost,clientId=*,destinationType=Topic,destinationName=*,viewType=Subscription,*");
Set<ObjectName> names = conn.queryNames(query, null);
for (ObjectName name : names) {
String clientId = (String) conn.getAttribute(name, "ClientId");
Long enqueue = (Long) conn.getAttribute(name, "EnqueueCounter");
Long dequeue = (Long) conn.getAttribute(name, "DequeueCounter");
Long pending = (Long) conn.getAttribute(name, "PendingQueueSize");
System.out.printf("clientId=%s, enqueue=%d, dequeue=%d, pending=%d%n",
clientId, enqueue, dequeue, pending);
}
connector.close();
}
}
这段代码的连接地址和账号密码需要根据你的环境调整。需要注意的是,如果你的ActiveMQ开启了认证,但JVM的JMX配置没有正确传递凭证,会抛出SecurityException。另外,brokerName默认是localhost,如果你改过Broker名称,ObjectName也要同步修改。
5.4 一次用JMX定位Topic积压的真实过程
在我最初遇到的那次消息积压问题中,我通过JMX定位到了原因。当时查Topic下的Subscription,发现PendingQueueSize一直增加,但DispatchedQueueSize为0。进一步看消费者日志,发现监听线程池的核心线程数为1,队列容量为100,而消费者处理每条消息耗时接近10秒。消息一多,线程池就开始排队,Session的回调线程长期被占满,Broker分发消息后客户端无法及时接收。
查清楚后,我把监听容器的concurrency从1调整到5,并为DefaultMessageListenerContainer增加了maxMessagesPerTask配置,问题立刻缓解。这个案例说明,JMX不只是监控工具,更是定位问题的重要手段。与其靠经验猜测,不如直接看订阅者的内存队列和分发计数。
6. 几个能直接抄走的ActiveMQ排查经验
6.1 先看消息头,再查业务逻辑
遇到消息消费异常,第一件事不是改代码,而是去Broker控制台查看消息详情,重点看Message ID、JMSRedelivered、JMSDeliveryMode、Timestamp。这四组数据能帮你快速判断问题是重复发送、重复投递,还是持久化设置不对。
6.2 死信队列要盯,但别只盯一句“有消息”
我在项目里给死信队列配置了一个独立的监听器,收到消息后自动提取异常消息的JMSException属性,把错误原因写入日志表,并触发告警。这个做法的价值在于:死信队列不只是“消息进没进”的问题,更重要的是“为什么进”。如果只是看队列数量,你很难知道是消费者代码Bug、外部依赖超时,还是消息体本身不合法。
6.3 生产环境不要用内存消息
ActiveMQ有一种模式是memory开头的Broker URL,消息只存在内存中,不持久化。开发环境用它很方便,但生产环境千万不要用,否则Broker一重启,所有未消费的消息全部丢失。正确做法是使用tcp://协议,并确保persistenceAdapter指向KahaDB或JDBC存储。
6.4 日志里搜“JMSException”和“expired”
有些消息因为长时间未消费,达到timeToLive后会变成过期消息。ActiveMQ默认会把过期消息放到ActiveMQ.DLQ,并且日志中会出现expired字样。如果你看到某些消息突然消失,先检查是不是配了TTL。
6.5 一个反直觉的提醒:别随便调大重发次数
很多强调可靠性的团队会把重发次数调到很大,比如20次、50次。实际上这不是增强可靠性,而是放大了故障影响。一条消息在3秒内连续重发20次,不仅消耗CPU,还会让下游系统不断处理重复请求。我建议重发次数保持在默认值或稍微增加到8~10次,配合死信队列告警,让问题消息尽快暴露,而不是默默重试到地老天荒。
6.6 消息大小直接影响性能
ActiveMQ对单条消息的默认大小限制是1MB。如果业务里需要传递大报文,建议不要直接发送原始数据,而是把内容存储在数据库或对象存储中,消息体只携带业务ID和内容地址。这样既降低了Broker压力,也方便后续做内容追溯。
6.7 关于缓存连接和Session的一点体会
ActiveMQ的Session并不是线程安全的,如果你自己写JMS客户端,千万不要把同一个Session丢到多个线程里并发使用。发送消息时,最好从连接池获取Session,使用完归还,而不是每次新建连接。连接和Session的创建开销很大,尤其是连接,高频创建会导致Broker端出现大量TIME_WAIT连接,最终把端口和文件句柄耗尽。
7. 写在最后的几条心得
学习JMS和ActiveMQ没有一个固定路径,但核心是理解消息模型、确认机制和存储结构。很多人觉得ActiveMQ难用,其实是因为习惯拿“数据库思维”去套消息系统,认为“发出去就一定会在某个地方待着、一定会被消费一次”。实际情况是,消息系统是分布式的,网络抖动、进程重启、消费超时都会造成消息的重复或丢失,你的设计必须接受这些不确定性,同时通过幂等和监控把不确定性兜住。
我在这次学习过程中最大体会是:不要只依赖Web控制台看“积压数量”,那只是结果,不是原因。真正定位问题要靠JMX指标、日志和消息头的组合分析。如果你也遇到类似问题,不妨从TopicSubscriptionViewMBean开始查起,先确认消费者和Broker之间的分发明细,再往下追业务逻辑。这个思路,比盲目调整并发参数有效得多。
