最近在整理一个老订单系统的消息模块,代码里从头到尾都是ActiveMQ的痕迹:ConnectionFactory、Session、MessageProducer这些类在业务层里到处直接new,系统每次重启都会有一批老消息堆积在ActiveMQ.DLQ里。我看完代码之后的第一反应是——这块东西如果不懂JMS规范,根本没法动手改造。
坦白说,现在聊消息队列,大家第一反应往往是Kafka、RocketMQ、Pulsar这些名字,JMS这个Java平台的老牌规范反而没什么人提了。但如果你去翻传统企业的集成代码,或者用Spring Boot做老JMS接入,ActiveMQ依然是绕不开的存在。JMS(Java Message Service)本身就是Java世界关于消息通信的一套标准API,ActiveMQ则是这套规范最流行的开源实现之一。这篇学习一则,我不打算讲得又全又散,就围绕我实际写代码、踩坑、翻文档的过程,把JMS规范下的核心编程模型、ActiveMQ的落地用法,以及最容易让人想不通的几个细节梳理一遍。适合正好想入门JMS、或者虽然天天用Spring JMS但还没搞懂底层原理的Java开发者。
1. 为什么还在学JMS:消息标准与热门中间件的定位差异
1.1 消息中间件解决的本质问题
先用一个场景把消息中间件的价值说清楚。假设一个订单系统,下单接口要同时处理库存扣减、优惠券核销、积分发放、短信通知,如果全部同步调用,接口耗时可能到一两秒,碰上大促流量一冲就全堵在数据库连接上。引入消息中间件之后,下单接口只需要把"订单已创建"这个事件发到消息队列里,后面的服务各自订阅,自己慢慢处理。这就是最典型的三个价值:异步解耦、流量削峰、业务隔离。
用生活化的类比就是餐厅点餐。同步请求相当于顾客站在柜台前,后厨做完一道菜才叫下一个;消息中间件则像服务员把菜单写好放到传菜口,后厨有空就取,顾客不用干等。JMS在这中间扮演的角色,不是某个具体的餐厅,而是"传菜口该长什么样"的约定标准。
1.2 JMS是API规范,不是协议规范,更不是具体产品
这个区别是初学阶段最容易混淆的地方。JMS(JSR 914规范)定义的是Java客户端与消息服务器之间的一套接口:怎么创建连接、怎么建会话、怎么发消息、怎么收消息。它管的是"代码长得什么样",不管底层数据包怎么在网络上传输。
而AMQP这种是协议层面的标准,定了网络传输的帧格式和交互语义;Kafka的协议又是自己的一套,偏向日志与流处理场景。ActiveMQ很有意思的地方在于,它原生走OpenWire协议,同时也支持AMQP、STOMP、MQTT等协议,也就是说它既能作为JMS标准实现被Java代码调用,也能暴露给非Java的客户端使用。这也是为什么很多传统架构里ActiveMQ能当"总线"用——不同语言的服务都可以通过不同协议接进来。
1.3 JMS 1.1与2.0:API演进的大致脉络
JMS 2.0最明显的变化是引入了JMSContext,把原来的Connection和Session合并成一个对象,代码量少了一大截。但实际工作中你大概率接触的是1.1风格的老代码,尤其是SpringJmsTemplate内部封装的那套API,底层仍然是1.1的Connection/Session/Producer/Consumer结构。所以我的建议是:先吃透1.1的原始API结构,再回头看2.0或者Spring封装,理解起来会顺畅得多。
我当时学习时踩过的一个认知坑是:以为JmsTemplate就是JMS的全部,结果遇到一个不用Spring的项目,打开代码看到Session、MessageProducer这些原生类直接懵住。反过来,先把原生API玩熟,再看JmsTemplate就觉得只是个"帮你省掉样板代码的工具人"。
2. JMS编程模型里的几张"网":七个接口角色谁负责什么
2.1 从ConnectionFactory到Message的一整条链路
JMS编程模型说复杂也复杂,但核心其实是一张有方向的图:
ConnectionFactory——→Connection——→Session——→MessageProducer/MessageConsumer,旁边再挂两个关键配角:Destination(目的地)和Message(消息本体)。
每个角色各管一段:
| 角色 | 职责 | 生命周期/特性 |
|---|---|---|
| ConnectionFactory | 创建连接的工厂,客户端入口 | 一般一个应用创建一个,配置broker地址 |
| Connection | 到消息服务器的物理连接 | 重量级对象,一个应用保持一个即可 |
| Session | 会话上下文,生产/消费消息的工作单元 | 轻量但非线程安全,负责事务与确认模式 |
| Destination | 目的地:Queue或Topic | 决定消息投递模型 |
| MessageProducer | 消息生产者,往Destination发消息 | 创建后可以多次send |
| MessageConsumer | 消息消费者,从Destination收消息 | 可以同步receive,也可以注册监听器 |
| Message | 消息内容,分TextMessage等类型 | 包含属性、头字段和消息体 |
2.2 各角色代码长什么样:一段最小生产者
直接看代码最直观。下面这段是JMS 1.1原生API的最小生产者代码:
import org.apache.activemq.ActiveMQConnectionFactory; import javax.jms.*; public class SimpleProducer { public static void main(String[] args) throws JMSException { // 1. 创建连接工厂,指定broker地址 ActiveMQConnectionFactory factory = new ActiveMQConnectionFactory("tcp://localhost:61616"); // 2. 创建连接并启动 Connection connection = factory.createConnection(); connection.start(); // 3. 创建会话:非事务,自动确认模式 Session session = connection.createSession(false, Session.AUTO_ACKNOWLEDGE); // 4. 创建目的地:这里用队列 Destination destination = session.createQueue("TEST.QUEUE"); // 5. 创建生产者并发送消息 MessageProducer producer = session.createProducer(destination); TextMessage message = session.createTextMessage("Hello, JMS!"); producer.send(message); // 6. 关闭资源 producer.close(); session.close(); connection.close(); } }这段代码里值得注意的细节不少。factory.createConnection()每次都会建立一条新的TCP连接,所以生产环境里你不能频繁调用它,需要做连接池化,后面我会专门说。connection.start()是很多人容易漏的一步——如果没有调用,消费者端会收不到消息,因为JMS规范里Connection默认是"停止"状态,目的是让你先把消费者、监听器都注册好,再统一开始投递。
2.3 为什么模型要拆这么多层:一次JDBC式的类比
理解这层结构,最方便的办法是和JDBC做对比。JDBC里DriverManager负责拿连接,Connection代表数据库连接,Statement负责执行SQL。JMS是一样的思路:工厂负责创建连接,连接代表与broker的物理链路,会话则管理一次业务交互的工作单元。
比较坑的一点是Session不是线程安全的。我此前在一个并发场景里犯过错误:多个线程共享同一个Session并发调用send(),结果偶发"环境:连接被重置"的异常,查了半天才定位到问题根因。规范里明确写了Session不应被多线程并发使用,正确做法是每个线程创建自己的Session,或者对Session访问做同步控制。但Connection是可以多线程共享的,甚至MessageProducer在大多数实现下也支持并发发送,只是不同实现有差异,不能想当然。
2.4 同步接收与监听器两种消费姿势
消费者端有两种典型写法。一种是用consumer.receive()同步阻塞拿消息,适合在简单应用或者需要严格控制拉取节奏的场景:
Queue queue = session.createQueue("TEST.QUEUE"); MessageConsumer consumer = session.createConsumer(queue); Message msg = consumer.receive(1000); // 1秒超时 if (msg instanceof TextMessage) { TextMessage textMessage = (TextMessage) msg; System.out.println("收到消息: " + textMessage.getText()); }另一种是注册MessageListener,消息到达时由回调线程处理,这是生产环境里更常用的方式。但监听器模式对"消息消费成功与否"的语义要求更高,一旦回调里抛异常,消息会被认为消费失败进入重投,这里留到可靠性的部分详细说。
3. ActiveMQ上手实操:下载、启动、跑通第一条队列消息
3.1 版本选择和启动命令
ActiveMQ当前主线是5.18.x一代,如果你的JDK版本比较老,5.15.x系列也还常见。我学习时用的是5.17.x,JDK 8环境下直接解压就能跑。下载Binary包之后,进入目录,启动方式有两种:
# 后台运行 ./bin/activemq start # 前台运行,方便看日志 ./bin/activemq console第一次学习我强烈建议用console,日志直接打到终端,什么端口没绑定、licence有问题、存储目录权限不对,一眼就能看到。后台运行的话日志在data/activemq.log,问题排查靠它。
启动成功后,broker会监听61616端口,这是OpenWire协议的默认端口;Web管理控制台跑在8161,默认账号密码是admin/admin。两个端口要区分清楚:Java客户端连的是61616,浏览器管理页面访问的是8161。
3.2 启动后的健康检查
启动完不要急着写代码,先做两步检查:
# 1. 看进程是否存活 ps -ef | grep activemq # 2. 测试61616端口连通性 telnet localhost 61616如果telnet能通,说明broker主服务没问题。然后打开浏览器访问http://localhost:8161,登录控制台,在Queues标签页能看到当前所有队列的消息积压数据。这张管理台对学习非常重要:发消息之后你能看到Enqueued数上涨,消费之后Dequeued数上涨,积压数一目了然,比闷头写代码要直观得多。
3.3 第一个点对点程序:队列消息的完整流程
生产者代码上面已经有了,这里补一个消费者端完整示例。先把刚才的SimpleProducer发几条消息,然后跑下面的消费者:
import org.apache.activemq.ActiveMQConnectionFactory; import javax.jms.*; public class SimpleConsumer { public static void main(String[] args) throws JMSException { ActiveMQConnectionFactory factory = new ActiveMQConnectionFactory("tcp://localhost:61616"); Connection connection = factory.createConnection(); // 消费者端必须调用start,否则收不到消息 connection.start(); Session session = connection.createSession(false, Session.AUTO_ACKNOWLEDGE); Queue queue = session.createQueue("TEST.QUEUE"); MessageConsumer consumer = session.createConsumer(queue); Message message = consumer.receive(2000); if (message instanceof TextMessage) { System.out.println("消费到消息: " + ((TextMessage) message).getText()); } consumer.close(); session.close(); connection.close(); } }有个细节值得注意:这条TEST.QUEUE消息如果在消费者启动之前已经入队,启动消费者之后是依然能收到的。这一点和后面的Topic形成了鲜明对比,也是初学者最容易混淆的核心行为差异。队列里的消息broker会持久化保存,直到有消费者把它取走并确认。
3.4 Web控制台里几个值得盯的数据指标
控制台队列详情页有四处数据我在学习阶段经常盯,分别是:
- Number Of Pending Messages:积压消息数,消费者没接走的消息。生产环境这个数字持续上涨就意味着消费端跟不上了。
- Number Of Consumers:当前在线消费者数。如果你开了消费者却看到是0,可能消费者进程挂了或者没连上broker。
- Messages Enqueued:进入队列的历史总条数,只增不减。
- Messages Dequeued:被消费者取走的历史总条数。
用这几列数据很容易验证你的程序行为。比如发10条消息,消费者没启动之前,Pending和Enqueued一起涨;启动消费者消费之后,Dequeued跟着涨,Pending降下来。整套机制用数据一对照,比读十页文档都清晰。
4. Queue与Topic的差异:从行为对比到代码中的经典踩坑
4.1 什么时候该用Queue,什么时候该用Topic
这是消息选型最基础的问题。Queue适合任务分发:一条消息只需被一个消费者处理,多个消费者之间竞争消费、分摊负载,典型场景是订单处理、邮件发送、积分计算这些"付一次账"的业务。Topic适合事件广播:一个消息要让所有订阅者都收到,典型场景是"库存变更了"通知下游各个系统、用户注册成功广播给积分和营销系统。
用一个通俗类比:Queue像办公室里只有一个工位能处理的报销单,谁先拿到谁处理;Topic像广播体操通知,所有人都会听到,不需要抢。
4.2 一张表看清行为差异
| 对比维度 | Queue(点对点) | Topic(发布订阅) |
|---|---|---|
| 投递方式 | 每条消息只投递给一个消费者 | 每条消息广播给所有订阅者 |
| 消息离线处理 | broker持久化,消费者上线后照常消费 | 非持久订阅离线就丢,持久订阅会保留 |
| 多个消费者的关系 | 竞争消费,消息被其中一个取走 | 各自独立收到完整的消息副本 |
| 创建API | session.createQueue("X") | session.createTopic("X") |
| 典型场景 | 任务队列、削峰 | 事件广播、通知分发 |
4.3 Topic的持久订阅与clientID之争
Topic消息离线丢失问题,靠**持久订阅(Durable Subscription)**解决。持久订阅的思路是:消费者在连接上显式设置clientID,然后指定订阅名,broker就会为这个订阅者记录"上次消费到哪条消息",离线期间的消息先存着,等它回来再补投。
connection.setClientID("subscriber-001"); Session session = connection.createSession(false, Session.AUTO_ACKNOWLEDGE); Topic topic = session.createTopic("ORDER.TOPIC"); TopicSubscriber subscriber = session.createDurableSubscriber(topic, "order-sub");这段代码里有个劫是:相同的clientID在同一时间只能有一个连接。微服务多实例部署时,如果两个实例都设置同一个clientID想同时订阅同一个持久订阅,后连接的那一个会被broker踢掉或者直接报错。解决方案一般是给不同实例分配不同clientID,或者别在集群场景下用JMS持久订阅,改用自己的持久化机制。
4.4 经典踩坑演示:为什么Topic先发后收会一条都收不到
我做实验时按照队列的习惯操作,先启动了Topic生产者发送10条消息,再启动订阅者去消费,结果10条消息一条都没收到。这就是Topic非持久订阅最反直觉的地方:broker对非持久Topic消息不做缓存,消息发布时如果没有订阅者在线,消息直接就丢了。
这个行为其实有自己的理由:Topic语义是"广播事件",事件发生的时候没人关心,那就没人在意;而Queue语义是"任务存储",任务等多久都行。理解了这层语义,再回到代码端,你就知道为什么团队规范里通常要求"先建消费者、再建生产者"来保证Topic消息不丢,或者干脆用持久订阅解决离线补偿需求。
5. 消费确认、事务会话与死信消息:什么时候消息才算真正消费完
5.1 三种确认模式:从自动确认到手动确认
JMS里消息被消费者收到后,必须经过"确认"这个动作,broker才会认为消息消费成功。Session创建时可以指定确认模式,最常用的有三种:
| 确认模式 | 语义 | 适用场景 |
|---|---|---|
AUTO_ACKNOWLEDGE | 消息在receive返回前(或监听器回调返回后)自动确认 | 处理逻辑简单可靠,不需要补偿 |
CLIENT_ACKNOWLEDGE | 必须显式调用message.acknowledge()才确认 | 需要业务处理成功后再确认,防止半途失败丢消息 |
DUPS_OK_ACKNOWLEDGE | Session允许消息重复投递,可以延后确认 | 高吞吐、消费逻辑幂等 |
我刚学时对CLIENT_ACKNOWLEDGE理解不深,以为"收到消息"就等于确认了。实际上不是。如果用的是CLIENT_ACKNOWLEDGE,但代码忘了调acknowledge(),那么这条消息会在会话关闭或者超时之后被重新投递,业务上就可能出现同一个订单被处理两次的现象。反过来看,这也是消息中间件"至少一次投递"语义的一部分。
5.2 事务会话:commit和rollback的完整语义
Session创建时第一个参数设为true,就开启了事务模式。事务模式下,所有发送消息和接收消息都相当于"待提交状态",必须session.commit()才会真正生效:
Session session = connection.createSession(true, Session.SESSION_TRANSACTED); MessageProducer producer = session.createProducer(queue); // 发送两条消息 producer.send(session.createTextMessage("msg-1")); producer.send(session.createTextMessage("msg-2")); // 显式提交,此时消息才真正进入队列 session.commit();消费者端的事务语义同理:consumer.receive()拿到消息后,如果不执行session.commit()就关闭会话,broker会把消息视为未确认并重新投递。事务的粒度是Session级别的,不是每条消息一个事务,一批消息要么全部提交成功,要么全部回滚,理解这个边界很重要,别把它当成数据库单条事务来设计。
5.3 消费失败后:重投与死信队列
监听器消费消息时抛异常,消息会发生什么?大多数情况下broker会把消息重新投递,投递次数超过阈值后,这条消息就会被移到死信队列,ActiveMQ默认的死信队列名称是ActiveMQ.DLQ。
我在这地方踩过一个教训:有一个消费逻辑,内部调用下游服务失败了,代码层面catch住异常只打日志,没有继续往外抛。表面上"消息处理结束",实际上下游业务没做,消息也悄悄丢掉了。排查了很久才发现消息进了死信队列的根本原因不是重投,而是根本没触发重投机制——异常被吞掉,等于宣告消息消费成功。
正确姿势是:消费逻辑捕获异常后,能重试的重试,不能恢复的就throw RuntimeException,让broker根据重投策略处理,多次失败后进DLQ。然后定期检查DLQ,做人工补偿或者二次投递。治理DLQ我习惯用IndividualDeadLetterStrategy,按原始队列名称区分死信队列,比如ActiveMQ.DLQ.TEST.QUEUE,这样定位问题比全部挤在同一个ActiveMQ.DLQ里舒服得多。
5.4 持久化消息与broker重启后的恢复
消息发送时还可以指定投递模式:DeliveryMode.PERSISTENT或者NON_PERSISTENT。默认情况下ActiveMQ使用持久化投递,消息会先落到KahaDB存储里再返回发送成功,这意味着即使broker重启,未消费消息也会恢复。这个语义对于"消息不能丢"的业务很关键。
ActiveMQ默认的持久化引擎是KahaDB,数据目录一般在data/kahadb。学习阶段你不需要深入调参,但至少要知道:一个项目如果对消息可靠性有硬要求,发送端必须用PERSISTENT,同时确认broker存储没写满。我看到过有人为了那一点点发送性能把整个系统的消息都设成NON_PERSISTENT,结果broker一重启,队列里所有积压消息全部清空,业务补偿流程直接瘫痪。
6. 学习ActiveMQ过程中值得记录的排查笔记
6.1 连接工厂与连接池:频繁创建Connection导致的连接轰炸
前文提到过,factory.createConnection()会建立一条真实TCP连接。如果一个系统里每次发消息都新建Connection,并发一高,broker端就会出现大量连接被创建和关闭,lsof一看全是TIME_WAIT。
正确做法是应用启动时创建一个连接工厂实例,配合连接池复用底层连接。Spring生态里可以用ActiveMQPooledConnectionFactory或者JmsPoolConnectionFactory,手动管理的场景也可以用单个Connection长连接+每线程独立Session的组合。我实际测试中,从"每次新建Connection"改成"长连接复用Session",同吞吐下broker线程占用下降了肉眼可见,连接数从几百降到了个位数。
6.2 消息选择器:能过滤消息属性,不能过滤消息体
JMS支持消息选择器,用一组表达式决定消费者只接收哪些消息:
MapMessage mapMessage = session.createMapMessage(); mapMessage.setStringProperty("sector", "bank"); MessageConsumer consumer = session.createConsumer(queue, "sector = 'bank'");这里有个硬约束:选择器只能基于消息属性和消息头字段做判断,不能直接过滤TextMessage里的正文内容。所以你想按业务字段分流消息,必须在发送端把可筛选字段set成属性。还有一个实战建议:ActiveMQ是在broker端执行选择器过滤的,规则太复杂时会影响broker性能。高吞吐场景一般别把选择器当业务规则引擎用,宁可建不同队列,让生产者直接往对应队列发送。
6.3 消费日志与控制台状态:如何快速定位消费进度
排查线上消息问题时,我的固定流程是三步走:
- 先上Web控制台看Queues里的Pending、Dequeued趋势,判断是积压还是消费缓慢。
- 再看
ActiveMQ.DLQ里有没有新增消息,判断消费是否为"半途失败但未抛异常"。 - 最后拉消费端日志,根据
JMSMessageID和业务主键定位那批消息处理到哪一步。
有一次排查订单消息积压,控制台显示Dequeued在涨但Pending不降,怀疑是消费者处理太慢。进一步看日志才发现,消费逻辑里有一段远程调用的超时时间是30秒,远程服务一旦抖动,单个消费者线程就卡住,整个队列的消费能力被拖死。把超时调成3秒并加了熔断处理之后,积压很快被清掉了。这类问题不看控制台数据很难定位,因为broker本身一切正常。
6.4 学习阶段的一条建议:控制台手动发消息做实验
ActiveMQ的Web控制台里有一个非常方便的功能:可以直接在某个Queue或Topic上手动send一条消息,消息体可以写成Text类型。我学习确认机制时全靠这个功能配合消费者做实验:发一条消息,消费端处理失败,看一眼控制台消息是重新投递还是进了DLQ,整个行为模型一下子就清晰了。
这种可视化的学习方式比纯读规范文档效率高得多。消息中间件是"分布式系统里的网络排队问题",很多行为脱离运行态去看都是抽象的,一旦落到控制台数据上,就成了具体可见的事实。
回到开头那个老订单系统的消息模块,我把消费端的异常吞掉问题修掉,把连接工厂改成复用模式,再给DLQ配了按队列分离的独立死信策略之后,消息积压和重启丢失的问题基本就没了。如果你正在学JMS,我的建议是别急着上Spring Boot的JmsTemplate,先把Connection、Session、Destination这些原生角色在控制台旁边一个个验证熟。消息中间件的大部分疑难杂症,根源都在"消息到底什么时候算被消费了"这一件事上,把确认机制和事务语义吃透,你后面看什么消息队列都会通透很多。