带你从零吃透JMS与ActiveMQ:一次订单同步超时引发的消息中间件学习笔记
前段时间我们这套交易系统出了个不大不小的事故:下单服务和库存服务走的是同步HTTP调用,高峰时段库存服务响应稍微慢了一点,下单接口的耗时立刻从200ms飙到3秒多,整条链路跟着一起卡。排障的时候我盯着调用链看了半天,心里只有一个念头——像这种"上游只管发、下游慢慢算"的场景,根本不该用同步等待的方式去扛。也就是从那次之后,我认真把JMS规范和ActiveMQ从头到尾捋了一遍,这篇文章就是那次学习过程的全记录。JMS是一套Java领域里定义消息中间件访问方式的标准接口,ActiveMQ则是这套接口最经典的落地实现之一。如果你也在纠结"什么时候该上消息队列""JMS和ActiveMQ到底是什么关系""第一次写生产者和消费者要踩哪些坑",这篇笔记应该能帮你省下不少时间。
1. 为什么我需要一套消息中间件:从一次订单同步超时说起
1.1 同步调用的问题不在调用本身,而在"耦合"
下单系统和库存系统之间最初就是最朴素的HTTP调用:下单服务收到请求,先扣库存,再写订单,然后返回给前端。代码写起来很直观,但问题也藏在这个"直观"里。
第一个问题是时间受制于最慢的环节。前端给我3秒超时,我得去等库存系统返回,库存系统又可能去等别的服务,整条链路的总耗时等于所有下游耗时的累加。第二个问题是突发流量的冲击无法被平滑。秒杀一开始,库存服务瞬间收到的请求量可能是平时的几十倍,HTTP线程池被打满之后,响应越来越慢,系统从"处理不过来"变成"完全不可用"往往只需要几十秒。第三个问题更隐蔽:如果库存系统需要短暂停机升级,下单服务该怎么办?同步调用模式下,唯一的答案就是一起停,或者靠网关那边做降级,但业务上其实就是断了。
这里引入JMS要解决的核心事情就是把"调用"改成"发消息"。下单服务不再关心库存服务当前在不在、处理得快不快,它只要把"商品A扣减10件"这条消息可靠地放进消息中间件,就可以立刻返回成功。库存服务按自己的节奏从中间件里取消息、慢慢处理。如果库存服务临时下线,消息也不会丢,等它恢复上线后继续消费即可。这套模型对我们当时那种"上游快、下游慢、量有波动"的场景几乎是量身定做的。
1.2 什么时候该上消息队列,什么时候不该上
我在学习过程中给自己定了一条判断准则:只有当"业务的成功不依赖下游的即时反馈"时,才适合用消息队列。典型如订单创建后要发通知短信、要更新积分、要同步物流数据,这些动作晚几秒甚至晚几分钟完成,用户和业务都能接受,那就非常适合消息化。反过来,如果用户发起支付后必须立刻知道扣款是否成功,这类强实时性的动作,如果强行拆成消息就有风险,失败反馈链路变长,用户体验会明显下降。
还有一类场景主要不是解耦,而是削峰。比如系统平时每秒只有几百笔请求,但偶尔会冲到几千,如果按峰值去扩容所有下游,成本太高。用消息队列先把请求存下来,让下游按照自己稳定的速率去消费,高峰就被"削"掉了。这个思路在我们订单场景里同样适用。
确定了要用的方向之后,真正动手之前还有一个选型问题:JMS、ActiveMQ、以及现在更常被提到的Kafka和RabbitMQ,这几样东西究竟怎么选。我当时的学习结论是:JMS是一种Java语言层面的规范,它定义了一组接口,解决的是"不同消息中间件之间的Java API不统一"的问题。ActiveMQ和另外几个支持JMS的中间件都是规范的实现方。如果团队技术栈以Java为主,且需要事务支持、需要和Spring深度集成、需要兼顾点对点队列和发布订阅主题,那么选择JMS规范的实现是比较自然的方向。ActiveMQ的优势在于结构简单、文档多、入门门槛低,适合团队已经深度绑定JMS生态的存量项目;而Kafka天生为高吞吐日志型场景设计,吞吐量极高,但其消费模型和消息删除策略并不完全符合传统企业级消息系统对"每条消息都要可靠处理"的预期。对我这次要解决的问题来说,ActiveMQ搭配JMS接口是改动最小、最容易让团队上手的方案。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. JMS核心规范与模型拆解:那些名字听上去很高级,其实很好懂
2.1 从ConnectionFactory到Destination,四个角色建立消息通道
JMS规范把访问消息中间件的方式抽象成了几个核心接口,理解它们之间的关系,整个JMS就通了。你可以把ConnectionFactory理解成"数据库连接池的工厂",它负责和ActiveMQ服务端建立底层连接。得到Connection之后,Connection代表一条物理的网络连接,但真正干活还需要创建Session。Session是JMS里最重要的"工作台",它负责创建消息、生产者和消费者,并且维护消息的事务边界。
在Session之上还有两个概念需要分清:Destination(目的地)和MessageProducer/MessageConsumer。Destination是消息要发往的"地址",它有两种类型,Queue(队列)和Topic(主题),等下细说。MessageProducer负责向Destination发送消息,MessageConsumer负责从Destination接收消息。我第一次接触这一串名词时容易搞混的点是:连接工厂产生的Connection其实是可以复用的,每个线程最好持有独立的Session,但多个线程之间共享同一个Connection也常见。Session不是线程安全的,所以在实际编码中,我会遵守"一个线程一个Session"的实践,而不是为了省事让多个线程并发使用同一个Session。
为了直观,我用一个最简单的生产者代码骨架来说明:
java复制// 1. 创建连接工厂,指定ActiveMQ服务地址
ActiveMQConnectionFactory factory = new ActiveMQConnectionFactory("tcp://127.0.0.1:61616");
// 2. 建立连接并启动
Connection connection = factory.createConnection();
connection.start();
// 3. 创建会话
// 参数一:是否启用事务,参数二:确认模式
Session session = connection.createSession(false, Session.AUTO_ACKNOWLEDGE);
// 4. 创建目的地
Destination destination = session.createQueue("ORDER.QUEUE");
// 5. 创建生产者并发送消息
MessageProducer producer = session.createProducer(destination);
TextMessage message = session.createTextMessage("扣减库存,商品A,数量10");
producer.send(message);
// 6. 释放资源
producer.close();
session.close();
connection.close();
这段代码虽然简单,但已经把JMS的完整生命周期走了一遍。值得注意的是第3步的两个参数:createSession的第一个参数代表是否开启JMS事务,开启之后必须等到session.commit()消息才会真正被投递;第二个参数是消息确认模式,后面专门讲消费端时会展开。实际项目中这些连接、会话的创建销毁不会这么简单粗暴,通常会用连接池和Spring的JMSTemplate来管理,但理解底层这些对象的关系是一切调优的基础。
2.2 点对点模型和发布订阅模型,两种语义要分清
JMS的两种消息域是我学习中最初犯糊涂的地方,因为它们的语义差异从根本上决定了代码的逻辑设计。
点对点(Point-to-Point)基于Queue实现,一条消息只会被一个消费者消费。我发一条消息进队列,多个消费者同时挂着,但这条消息只会被其中一个取走,取走之后就相当于从队列里移除。这里的关键语义是"竞争消费":消息不保证被某个特定消费者处理,但保证被处理一次。这个模型适合任务分发,比如把订单创建消息发给库存服务,多个库存服务示例之间竞争,谁闲着谁处理。
发布订阅(Publish/Subscribe)基于Topic实现,一条消息会被所有订阅了该主题的消费者各收到一份。消息发出之后,如果没有消费者在线,这条消息对后来的订阅者就不可见了(除非使用持久化订阅)。这个模型适合广播通知,比如"商品价格变更了,所有缓存服务都去刷新一遍"。我第一次实测时踩过一个经典坑:用Topic接收消息,生产者发完消息后消费者才启动连接,结果消费者一条消息都收不到。原因就是主题消息默认不持久化,消费者错过了实时窗口。要解决这个问题,就得使用持久化订阅读者身份来订阅主题。
这两个模型的选择不是随意的。我自己的经验是:默认优先选Queue,因为大多数业务场景天然就是"每条任务只处理一次"的语义,用Queue符合直觉且不容易出错;只有当明确需要"广播给所有订阅方"时才切到Topic。不要一开始图方便全都用Topic,后面消费者一多,重复处理的收尾工作会让人崩溃。
2.3 消息结构、三种常见消息类型和消息选择器
JMS消息由三部分组成:消息头(Header)、属性(Properties)和消息体(Body)。消息头包含一些系统自动填写的字段,比如JMSMessageID、JMSTimestamp、JMSDeliveryMode,这些字段可以在消费端用来做消息筛选和去重。属性部分允许你附加自定义的键值对,结合消息选择器(Message Selector)可以实现类似"只接收金额大于100元的消息"这种条件过滤。消息体则根据接口类型承载不同的数据。
我平时用得最多的是TextMessage和MapMessage。TextMessage直接承载字符串,JSON结构的业务数据几乎都能塞进去;MapMessage则是一组键值对,适合传递结构化但简单的字段。另外还有BytesMessage用于二进制数据、ObjectMessage用于序列化Java对象。ObjectMessage虽然用起来省事,我却相对谨慎,因为它需要发送方和接收方的类版本完全一致,对象结构稍微变动就会导致反序列化失败,长期维护成本高。所以我的实践原则是:除了简单学习场景,宁可自己把Java对象转成JSON放进TextMessage,也不直接使用ObjectMessage。
消息选择器写法上有一点像SQL片段,如下所示:
java复制String selector = "amount > 100 AND region = 'NORTH'";
MessageConsumer consumer = session.createConsumer(destination, selector);
用选择器可以显著减少消费端无谓的消息接收,但也要注意,选择器是在Broker端完成过滤还是消费端过滤跟具体实现有关,ActiveMQ最终是在客户端消费时过滤的,所以不要让选择条件过于复杂,否则反而增加消费端的CPU开销。我自己通常只会在必要场景(比如同一个队列,不同消费者处理不同类型消息)使用选择器,而不是为了追求"优雅"给所有消费者都挂上复杂的过滤条件。
3. ActiveMQ环境准备与第一个生产者Demo:踩着坑起步
3.1 下载、启动和后台管理界面
ActiveMQ的本地开发环境搭建非常简单,我当时的步骤是:确认本机Java环境为JDK8或JDK11,然后到官方下载页面拿当前稳定版本的压缩包,解压后进入bin目录,根据操作系统执行启动脚本。Linux下执行./activemq start,Windows下执行activemq.bat start。启动完成后,61616端口是默认的OpenWire传输端口,8161端口是内置Web控制台端口,浏览器访问http://127.0.0.1:8161/admin,默认用户名和密码都是admin(注意这是本机开发默认配置,线上必须立即修改)。
我第一次启动时遇到过端口被占用的情况,启动日志里直接报错。排查方式是在启动日志里看activemq.log,确认是哪个端口占用了,再用lsof -i:61616或者netstat -ano | findstr 61616查占用进程。清理掉占用进程后重新启动即可。另外如果修改了默认端口,记得检查conf/activemq.xml和conf/jetty.xml里的配置对应关系。
Web控制台对学习阶段特别有用,可以直接查看队列的待消费消息数(消费者数量、出队数、入队数),不用写任何代码就能直观看到消息的流转状态。我学习时习惯先手动在控制台发送一条测试消息,再去自己的消费者代码里接收,这样即使代码有问题,也能先确认服务端本身是正常的。
3.2 生产者代码和消息持久化参数
前文贴过一段最小生产者代码,实际项目中我还会设置两个重要参数:DeliveryMode和TimeToLive。DeliveryMode有两种,PERSISTENT表示消息要持久化到磁盘,就算ActiveMQ重启,消息也不会丢;NON_PERSISTENT表示消息驻留在内存中,性能好但重启即丢。TimeToLive以毫秒为单位,表示消息有效时间,超过时间的消息会被自动清除,避免消息堆积积压。
java复制producer.setDeliveryMode(DeliveryMode.PERSISTENT);
producer.setTimeToLive(60000); // 60秒后消息失效
这两个参数组合的背后是业务诉求。对于库存扣减这种绝对不能丢的消息,必须用PERSISTENT;对于日志采集这类丢了也无所谓的场景,用NON_PERSISTENT来换吞吐。我有个习惯:生产者和消费者用到的队列命名,统一约定前缀,比如业务队列以.QUEUE或者QUEUE.开头,主题以TOPIC.开头,同时把目的地的命名整理成文档写入项目WIKI,方便团队协作,防止各写各的导致调试困难。
3.3 实际项目中使用连接池与事务会话
在自己写本地Demo时直接new Connection、Session没有问题,但是放到Spring项目里,每次都新建关闭连接的开销会严重影响吞吐。我当时顺着这个痛点去查解决方案,发现ActiveMQ官方提供了一个连接池对象PooledConnectionFactory,可以复用物理连接和会话,也方便和Spring整合。在Spring Boot项目里引入activemq-pool依赖,把spring.activemq.pool.enabled=true配置打开,Spring会使用连接池来管理所有JMS操作,从而省去手动管理Connection的麻烦。
事务会话方面,JMS的事务和数据库事务类似,都在Session上操作:发送多条消息后,调用session.commit()统一提交,任一条失败则调用session.rollback()全部回滚。在Spring的@Transactional配合JmsTransactionManager的场景下,还能实现JMS事务与本地数据库事务的同步提交。但要注意,这并不等于分布式事务,跨系统的最终一致性仍然需要业务层的补偿机制来兜底。我第一次理解浅了,以为事务会话能保证消息一定能被消费成功,后来才明白事务的消息边界只在"生产端发送成功"和"消费端确认消费",至于消费后业务处理是否成功,那是消费端自己的事务范围。如果一个消费端线程处理业务失败,需要主动抛出异常或者不确认消息,触发消息重投,后续在消费者小节会细说。
4. 消费者开发与常见坑:从卡住不消费到消息重复
4.1 同步阻塞接收和异步监听器,两种消费模式怎么选
JMS消费端有两种书写方式,一种是显式的同步接收:调用consumer.receive(),当前线程会阻塞等待消息,拿到消息后自己处理;另一种是注册MessageListener,由消息中间件在消息到达时回调onMessage()方法,异步触发。
同步接收适合在一个受限的循环里拉取消息,比如批量任务处理:
java复制while (running) {
Message message = consumer.receive(1000); // 阻塞最多1秒
if (message instanceof TextMessage) {
// 处理业务
}
}
异步监听适合消息到达时间不确定、需要持续消费的场景,Spring的@JmsListener本质上就是异步监听。但异步监听有一个容易忽略的特性:默认情况下,同一个Listener容器会使用一个线程池消费,消息到达速率高的时候,多个消息会被并发处理,如果你的业务处理函数不是线程安全的,就很有可能引入并发竞态问题。我踩过的坑是异步消费时直接修改了一个全局静态Map做缓存,结果多个消息并发触发,Map的值被互相覆盖,排查了半天才发现。
所以我的实践经验是:如果对消息处理顺序有要求,那么要么在消费端自己做串行化控制,要么使用"单线程消费者"模式,通过concurrency参数把并发量限制为1。ActiveMQ的Queue模型本身不保证绝对顺序,如果你依赖严格顺序消费,需要在业务逻辑里引入序号判断,用幂等和去重来兜底。
4.2 消息确认机制:确认错了消息就会重发
消费端确认机制是我学习过程中理解最费劲的部分,因为它直接关系到消息会不会重复消费。JMS定义了几种确认模式:
Session.AUTO_ACKNOWLEDGE:消息发给消费者后自动确认,不需要业务代码干预。优点是省事,但这意味着如果业务代码在处理过程中抛异常,消息可能还没来得及确认,中间件会重新投递这条消息,进而导致重复消费。Session.CLIENT_ACKNOWLEDGE:消费者拿到消息后,需要手动调用message.acknowledge()来确认。我通常会在这个模式下延后确认,等业务逻辑成功后再确认,从而减少重复消费的概率。Session.DUPS_OK_ACKNOWLEDGE:这是一个面向吞吐的懒确认模式,中间件允许重复投递消息,以此换取更低的网络开销。适合对重复消息容忍度很高的场景。- 事务会话模式下,
commit()本身就包含了确认动作,调用commit即确认所有已消费消息。
我实际推荐的做法是:在开启事务的会话里消费,业务做成功了就commit,失败就rollback,让消息重新投递;交替使用CLIENT_ACKNOWLEDGE在业务成功后确认。无论如何都要在业务处理端保持"可重入"的设计,因为消息中间件能做到"最多一次"或者"至少一次",却很难从中间件层面做到"恰好一次"。换句话说,任何消息系统都可能因为网络抖动、服务重启等原因导致重复消息,消费端不能假设"每条消息我一定只会收到一次",而应该把"重复消费不产生副作用"作为设计底线。最简单也最通用的手段是为每条业务消息生成业务唯一ID,在消费方用数据库唯一约束或者Redis SETNX做去重。
4.3 卡住不消费、消息积压、死信队列的排查链路
一旦系统上线跑了一段时间,消费端常见的问题就浮现出来了。我记录过一条比较典型的排查链路:某天下午,后台管理页面显示订单队列里积压了十来万条消息,消费者进程还活着,却没有消费速度。
第一步我先去看消费者日志,发现完全没有消费相关日志输出。然后去看Spring的@JmsListener容器状态,确认消费者是已注册的。接着我在管理控制台看了这个队列的信息,发现消费者数量确实是0,这就奇怪了——程序明明在跑,为什么Broker认为没有消费者?
后来定位到是监听容器初始化失败导致监听器没有顺利注册到Broker,失败原因被日志吞掉了或者只在某个隐蔽的错误级别输出。具体来说,消息监听器容器在启动时需要连接到Broker,如果连接工厂配置错误,或用错了Broker地址,容器会重试很多次后放弃,但应用本身可能不会报错。那次的原因就是我在配置里漏写了一个端口号,导致监听器容器连不上Broker,消费者自然永远不存在。
还有一次是消费者能收到消息,但处理每条消息都要向外部的存储服务发起同步调用,存储服务慢,消费者处理一条消息要几十秒,积压自然逐步扩大。这种情况就不是中间件的问题,而是消费能力跟不上生产速度。解决的思路有两类:一类是调整消费者的并发线程数,提升处理速度;另一类是检查下游依赖的耗时,加缓存、做聚合、优化调用链。如果下游依赖无法提升,还可以考虑把消息分流到多个队列,由不同的消费者分组处理。
消息重试到达上限后,ActiveMQ默认会把消息转入死信队列ActiveMQ.DLQ。死信队列不是摆设,它会保留消息的原始内容,方便人工介入排查。我在实践中会专门监控这个队列,正常情况下它应该长期为空,一旦堆积就说明有不消费的死消息,需要及时处理。ActiveMQ默认的最大重投次数是6次,如果业务需要自定义,可以在activemq.xml里配置RedeliveryPolicy:
xml复制<broker xmlns="http://activemq.apache.org/schema/core">
<destinationPolicy>
<policyMap>
<policyEntries>
<policyEntry queue="ORDER.QUEUE">
<deadLetterStrategy>
<sharedDeadLetterStrategy processExpired="false" />
</deadLetterStrategy>
<redeliveryPolicy maximumRedeliveries="3" redeliveryDelay="1000" />
</policyEntry>
</policyEntries>
</policyMap>
</destinationPolicy>
</broker>
这个配置的意思是队列ORDER.QUEUE里的消息最多重投3次,重投间隔1秒。需要提醒的是,修改重投策略后要重启Broker才能生效,而且会影响所有使用该队列的消费者,改之前要评估对现有业务的影响。
5. 主动设计而非被动响应:配置调优与监控
5.1 内存策略、流控和持久化存储选型
ActiveMQ本身作为一个独立进程运行,它的内存管理分成Broker内存、单个目的地的内存限制,以及每个连接的内存限制三层。默认情况下,Broker的memoryUsage有上限,如果消息积压量超出内存限制,生产者就会进入阻塞等待状态,除非消费者把队列消费下来腾出空间。这个机制本身是一种流控保护,防止Broker因为积压被OOM打死,但副作用是生产方会被"反向压住",出现生产变慢的现象。如果生产方对延迟极敏感,需要及时调整内存策略,增加Broker的内存上限或者为单个队列设置更高的内存阈值。
持久化存储方面,ActiveMQ默认使用KahaDB,这是ActiveMQ自带的文件型存储,性能不错,部署简单。对于大多数中小规模业务,KahaDB已经足够了。数据量特别大时可以考虑JDBC等持久化方式,把消息存到底层数据库里,但JDBC存储的性能远不如KahaDB,而且配套维护成本更高,所以如果不是有强需求,我不会轻易切换。做持久化选型时还要注意一点:如果是纯内存系统,重启后队列消息全部丢失,一定要结合业务决定是否开启PERSISTENT和持久化存储。宁可多花一点存储成本,也不要让业务消息说丢就丢。
5.2 监控指标:我在生产环境里最关心的几个数据
ActiveMQ的Web控制台虽然能看概览,但真到排障和报警环节,还是得靠指标采集。我日常最关心的指标有四个:
- 当前队列深度(待消费消息数)。它直接反映积压情况,单个队列持续增长超过阈值,就要触发报警。
- 出队速度(Dequeue)和入队速度(Enqueue)的对比。如果入队持续大于出队,意味着消费能力落后,即便当前积压还不大,趋势也已经不安全。
- 消费者数量。队列有消息但消费者为0,或者消费者数量比期望值少,往往是配置问题信号。
- 死信队列大小。这个前面说过,正常情况下应该长期是零,有增长就是有故障消息。
在Spring Boot项目中,可以通过断点监控指标接口获取这些值,也可以使用JMX通过管理端口获取Broker运行状态。我当时的做法是把队列深度和死信队列数量接入现有的监控平台,配合钉钉/企微机器人做告警,这样消息一旦开始积压,负责人在问题扩大之前就有预警。
5.3 一个小型故障复盘:我如何定位一次"消费者莫名其妙退出"的问题
最后分享一次让我印象深刻的故障。某个夜里,队列的消费者进程突然退出,消息开始积压,第二天一早被报警吵醒。排查时我先看了消费者日志,没有任何Exception记录,进程就这么悄无声息地消失了。
这种"无日志退出"最先让我猜的是内存问题,检查后发现应用的堆内存设置确实偏低,然后去翻系统日志确认是OOM导致了进程被强行杀死。Java进程不会在OOM时主动打印自己"即将退出"。这个问题真正的原因不只是堆内存小,更重要的是消费端的Batch处理方式问题:每条消息都会把一个大对象放进内存缓存,积压一批后再统一写存储,堆内存就被撑爆了。修复方案有三个层面:第一是降低消息单批处理的上限,避免内存峰值过高;第二是调整JVM参数,增加堆内存并设置合理的OOM时自动导出堆转储;第三是增加进程守护,确保消费者进程退出后能自动拉起。
从这个故障中我总结出一条设计准则:只要消费者持有大量内存状态,就要考虑进程被中断时状态丢失的影响。消息中间件不是魔法,它可以帮你扛住流量,但消费端自身的健壮性和可恢复性,仍然是系统设计的重中之重。后来我又在多个项目里反复验证了这一点:凡是消息消费逻辑写得足够简单、无状态、可幂等重入,运维起来就会非常省心;凡是把大量可变状态塞给消费端的,迟早会在故障中付出代价。ActiveMQ这套体系本身学习曲线不算陡,真正吃掉时间的往往是业务侧对消息语义和重试机制的理解。把这些基础概念吃透,后面不管是继续深入其他消息中间件,还是设计分布式系统的异步通信,都会觉得顺手很多。
