两年前接手一套订单中台对接项目时,碰到的第一个需求就很别扭:老系统的回执消息跑在一套 ActiveMQ 上,新接入的物流平台只肯通过他们自己的 broker 投递轨迹数据。两套 broker 地址不同、账号不同、连队列命名风格都不一样,可中间层只有一个 Spring 应用。换句话说,这个spring 项目必须同时连接两个 activemq 服务器,还要保证消息不串门、启动不互相拖累、一个挂掉另一个照常跑。这类需求在系统整合、并购后的双中台并存、灰度迁移过渡期非常常见,但 Spring 官方文档几乎都是围绕"一个应用一个 ConnectionFactory"写的,真到要连两套时,坑全在细节里。下面这套东西适合已经能跑通单 broker、正准备上双 broker 的开发者,也适合被"消息发错地方""只连上一个"折磨过的同行对照排查。
1. 先把场景说清楚:什么情况下要并联两套 ActiveMQ
1.1 三种最常见的真实动因
第一种是系统整合期的双活并存。两家业务合并,各自的中台都有一套自己维护多年的 broker,谁也不敢先动手迁。这种情况下两套 broker 不是临时状态,可能共存半年以上,中间层必须稳定支撑双向收发。这类场景的特点是消息量大、可靠性要求高,方案要按长期方案设计,不能凑合。
第二种是跨组织对接的隔离要求。外部合作方只允许通过他们指定的 broker 通道投递数据,安全和运维边界卡得很死,你既不能让他连你的,也不能把你的地址给他。此时项目里就要同时维护"内部 bus"和"外部 bus"两条链路,两条链路的 SLA、账号权限、消息格式都不一样。
第三种是灰度迁移。老 broker 还在承载存量流量,新 broker 已经开始接入增量流量,Spring 应用作为消费端和发送端要同时向两端收发,靠业务开关逐步切流。这种场景对"两套连接互不干扰"要求最高,因为你要随时能关掉其中一条链路做回滚。
我在实际项目里遇到的是第一种和第三种叠加:存量走 A broker,新渠道走 B broker,未来还要把 A 的量逐步迁到 B。所以最终的配置不是简单加一个 bean,而是把"连接、模板、监听容器"三层都做了按 broker 隔离的设计。
1.2 为什么不用单 broker 多队列或网络桥接凑合
有人会问:能不能让两套 broker 做网络桥接(network of brokers),或者让一侧转发到一个公共 broker 上,Spring 只连一个?技术上可行,但要看代价。
网络桥接解决的是 broker 之间的消息转发,前提是两套 broker 版本兼容、网络可达、双方运维都愿意开通道。现实中跨组织的那套 broker 往往根本不给你开双向通道的权限,这条路直接堵死。而且桥接之后消息的持久化语义、死信策略、监控归属都会变得模糊,出了问题定位要跨两个团队,排查成本极高。
至于"统一到一个公共 broker 转发",那等于在中间加了一跳,多一跳就多一次失败可能、多一份维护成本,而且把两侧的强耦合转移到了一个新的单点上。迁移动机本来就是解耦,结果造出一个更脆弱的中心节点,方向上就错了。
真正务实的做法是在应用层做隔离:两套独立的 ConnectionFactory、两套独立的 JmsTemplate、两套独立的监听容器工厂,用命名空间和路由门面把边界画清楚。这种做法的好处是每一侧都能独立配置参数、独立降级、独立监控,出问题时影响面是可控的。
1.3 两套连接并存的三种技术路线
实际落地时有三条路线可选,我按推荐程度排序。
第一条是全手工装配,禁用自动配置。所有 ConnectionFactory、JmsTemplate、监听容器工厂都自己声明,明确每个 bean 的 name 和用途。控制力最强,也最适合双 broker 场景。代价是要多写几十行配置,但这点代码量换来的是完全可预测的装配行为。
第二条是保留自动配置,另加一套手工 bean。用spring.activemq.*配置主连接,手工声明第二套。看起来省事,但自动配置会创建默认的 JmsTemplate 和jmsListenerContainerFactory,它们需要注入一个 ConnectionFactory,一旦容器里出现两个 ConnectionFactory 类型的 bean,注入就变成歧义,要么靠@Primary压制,要么就报NoUniqueBeanDefinitionException。这条路能走通,但要非常清楚自动配置的边界在哪里。
第三条是用 JMS 规范之外的抽象层,比如自己封装一个MessageBus接口,下面挂两个实现。这层封装在小项目里是过度设计,但在长期维护的系统里很有价值,因为它把"消息发到哪里"从配置问题变成了代码里的显式选择,读代码的人不需要翻 XML 就知道某个业务走的是哪条链路。
我最终选的是第一条路线加第三层的轻封装:底层纯手工装配,上层加一个按业务标签路由的发送门面。原因很直接——配置层的正确性可以靠 review 保证,而"发错 broker"这种问题靠 review 是防不住的,必须在代码层面让选择变显式。
2. Spring 的 JMS 装配模型:为什么默认只让你连一个
2.1 自动配置那条隐形的单例链
Spring Boot 的 JMS 自动配置做的事情比大多数人想象的要多。引入spring-boot-starter-activemq之后,它会在满足条件的情况下创建三个关键 bean:一个ConnectionFactory(用spring.activemq.broker-url的值构建,没配就用默认的本地地址)、一个JmsTemplate(绑定那个唯一的 ConnectionFactory)、以及一个名为jmsListenerContainerFactory的监听容器工厂。
这三个 bean 的创建条件里都有@ConditionalOnMissingBean一类的判断,但判断的粒度不同。ConnectionFactory的判断是"容器里没有 ConnectionFactory 类型的 bean",JmsTemplate的判断是"没有 JmsTemplate 类型的 bean",监听容器工厂的判断则是按 name 匹配jmsListenerContainerFactory。这就带来一个很隐蔽的后果:如果你手工声明了两个 ConnectionFactory,自动配置的 ConnectionFactory 确实不会创建了,但自动配置的JmsTemplate和监听容器工厂仍然会创建,而它们需要注入一个 ConnectionFactory,此时容器里有两个候选,注入直接失败。
我在第一次搭这个项目时就是被这个卡住的:配置写完了,启动报NoUniqueBeanDefinitionException: expected single matching bean but found 2。当时第一反应是"我明明没让它自动配置",实际上是自动配置的那部分在背后还活着。
稳妥的处理方式是显式排掉
ActiveMQAutoConfiguration和JmsAutoConfiguration,尤其在双 broker 场景下,把所有装配权收回到自己手里,比事后靠@Primary兜底要清晰得多。
2.2 ConnectionFactory、JmsTemplate、容器工厂三者的绑定关系
理解这三个东西的关系,双 broker 的配置就成功了一半。ConnectionFactory是物理连接的生产者,它持有 broker 地址、账号、连接池参数;JmsTemplate是发送端的门面,构造时必须绑定一个 ConnectionFactory;监听容器工厂是消费端的配置模板,创建MessageListenerContainer时也必须绑定一个 ConnectionFactory。
关键在于:这条绑定链是单向的,而且只在创建那一刻发生。JmsTemplate一旦绑定就固定了,不会在运行时切换。监听容器也是一样。所以"同时连两个 broker"在 Spring 里的本质,不是让一个 bean 同时连两个地址,而是让两条独立的 bean 链各自连各自的地址。
这也解释了为什么@JmsListener有一个containerFactory属性。它的默认值是字符串jmsListenerContainerFactory,也就是说如果你不写,所有监听器都会去找那个名字的 bean,自然就全跑到主 broker 上去了。要让某些监听器走第二套 broker,唯一的办法就是在注解上显式指定另一个工厂 bean 的名字。
2.3 我的隔离策略:按 name 分层、按用途分模板
配置多了以后,最容易出的问题是"名字乱了"。两个connectionFactory、两个jmsTemplate,一眼看过去分不清谁是谁。我的做法是给每套配置固定一套后缀:主链路统一用Primary后缀,第二套用Secondary后缀,bean 名字里都带上这个后缀。
除了后缀,还会按用途拆模板。同一个 broker 上,普通业务发送和事务型发送、同步请求应答,对 TTL、持久化、超时的要求差别很大。如果所有业务共用一个 JmsTemplate,必然要在每次发送时用send()的 lambda 版本来临时设置参数,代码会变得很啰嗦。我的习惯是每个 broker 配一个"基础模板",再按业务类型配一到两个"专用模板",都指向同一个 ConnectionFactory。这样发送端写起来就是一行orderTemplate.convertAndSend(queue, payload),参数已经沉淀在配置里了。
这里有个细节值得单独说:多个 JmsTemplate 共用一个 ConnectionFactory 是安全的,因为 JmsTemplate 本身无状态,真正的并发控制由 ConnectionFactory 内部的连接和 session 池承担。但反过来说,如果你在 ConnectionFactory 外面又套了一层缓存,那这个缓存是共享的,session 缓存大小要按所有模板的总并发来估,不能只按单个模板算。
3. 从零落地:XML 与 Java Config 两套完整配置
3.1 依赖与版本基线
先把版本对齐,这步做错后面全是玄学问题。Spring 5.3.x 系列配 ActiveMQ 5.16/5.17 是最稳的组合;ActiveMQ 5.18 起基线要求 JDK 11,如果你的项目还在 JDK 8 上,不要碰 5.18。Spring Boot 2.x 的依赖管理已经帮你把 activemq-client 的版本锁好了,一般不需要手工指定版本号,除非你有意升级。
依赖方面,除了 starter,如果打算用 ActiveMQ 自带的连接池(而不是 Spring 的 CachingConnectionFactory),还需要额外引入连接池模块:
<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-activemq</artifactId> </dependency> <dependency> <groupId>org.apache.activemq</groupId> <artifactId>activemq-pool</artifactId> </dependency>这两个连接池方案不要混用。Spring 的CachingConnectionFactory缓存的是 session 和 producer/consumer,ActiveMQ 的PooledConnectionFactory池化的是物理连接和 session。两个套在一起,会出现连接数翻倍、session 泄漏、关闭时挂住不退出这类问题。选一个就好,我更倾向CachingConnectionFactory,因为它和 Spring 的 JMS 抽象贴合更紧,异常转换和 session 复用行为更可预测。
3.2 第一套连接:手工管理 ConnectionFactory
先写 XML 版本,很多老项目还在用。第一套连接对应的 bean 全部带Primary后缀:
<!-- 物理连接工厂 --> <bean id="amqConnectionFactoryPrimary" class="org.apache.activemq.ActiveMQConnectionFactory"> <property name="brokerURL" value="failover:(tcp://10.0.0.11:61616,tcp://10.0.0.12:61616)?randomize=false&maxReconnectAttempts=-1&initialReconnectDelay=200&maxReconnectDelay=30000"/> <property name="userName" value="${mq.primary.user}"/> <property name="password" value="${mq.primary.password}"/> <property name="useAsyncSend" value="true"/> <property name="sendTimeout" value="5000"/> <property name="closeTimeout" value="3000"/> <property name="alwaysSessionAsync" value="false"/> </bean> <!-- Spring 缓存包装 --> <bean id="connectionFactoryPrimary" class="org.springframework.jms.connection.CachingConnectionFactory"> <property name="targetConnectionFactory" ref="amqConnectionFactoryPrimary"/> <property name="sessionCacheSize" value="20"/> </bean> <!-- 发送模板 --> <bean id="jmsTemplatePrimary" class="org.springframework.jms.core.JmsTemplate"> <property name="connectionFactory" ref="connectionFactoryPrimary"/> <property name="explicitQosEnabled" value="true"/> <property name="deliveryPersistent" value="true"/> <property name="timeToLive" value="300000"/> </bean>几个参数值得逐个解释。failover前缀是为了在单 broker 地址变化或短暂重启时能自动重连,randomize=false保证优先连第一个地址,避免集群环境下随机连到从节点导致消息消费延迟。maxReconnectAttempts=-1表示无限重试,对于长期运行的中间层是合理的——断了就一直试,总比直接抛异常让线程死掉好。useAsyncSend=true让发送不阻塞等待 broker 确认,吞吐能提升明显,代价是极端情况下丢消息的窗口变大;如果业务对可靠性要求高,这条要改成 false 或者配合sendTimeout一起用。
alwaysSessionAsync=false这个参数很多人不知道,它的作用是当只有一个 session 时,让发送走同步路径而不是丢到单独的异步线程池里,能省一次线程切换。对高频小消息场景有实际收益。
sessionCacheSize是 Spring 缓存的关键参数,默认值是 1,这意味着默认情况下所有发送线程抢同一个 session。并发一高就成了瓶颈,表现出来是发送 RT 突然拉长。我一般按"并发发送线程数 × 1.5 + 监听容器数"来估,20 是个适合中等负载的起点。
3.3 第二套连接:命名为王
第二套配置的结构完全一样,只是后缀换成Secondary,地址和账号换成另一个 broker 的:
<bean id="amqConnectionFactorySecondary" class="org.apache.activemq.ActiveMQConnectionFactory"> <property name="brokerURL" value="tcp://10.1.0.21:61616"/> <property name="userName" value="${mq.secondary.user}"/> <property name="password" value="${mq.secondary.password}"/> <property name="useAsyncSend" value="true"/> <property name="sendTimeout" value="5000"/> </bean> <bean id="connectionFactorySecondary" class="org.springframework.jms.connection.CachingConnectionFactory"> <property name="targetConnectionFactory" ref="amqConnectionFactorySecondary"/> <property name="sessionCacheSize" value="10"/> </bean> <bean id="jmsTemplateSecondary" class="org.springframework.jms.core.JmsTemplate"> <property name="connectionFactory" ref="connectionFactorySecondary"/> <property name="explicitQosEnabled" value="true"/> <property name="deliveryPersistent" value="true"/> <property name="timeToLive" value="600000"/> </bean>注意这里我没有给第二套加failover。原因不是懒,而是第二套 broker 由外部团队维护,他们不提供多节点地址,加 failover 也没有备用地址可切,反而会多一层无意义的包装。如果排除掉这种外部约束,只要 broker 有主备,两套都该用 failover。
两套配置的sessionCacheSize我给了不同的值,因为两个链路的流量本来就不对等。主链路承载存量全量业务,第二套只承载新渠道的增量。参数不搞"一刀切",是双 broker 配置里最容易被忽略但收益最直接的一点。
3.4 拆分 JmsTemplate 与统一发送门面
配置写完了,接下来是让业务代码能"明确地"发到指定 broker。直接@Autowired两个 JmsTemplate 按名字注入是可以的,但读代码的人容易看漏@Qualifier,一不小心就注入了主模板。我的做法是加一层薄门面:
@Component public class MqPublisher { private final Map<BrokerTag, JmsTemplate> templates = new EnumMap<>(BrokerTag.class); public MqPublisher(@Qualifier("jmsTemplatePrimary") JmsTemplate primary, @Qualifier("jmsTemplateSecondary") JmsTemplate secondary) { templates.put(BrokerTag.PRIMARY, primary); templates.put(BrokerTag.SECONDARY, secondary); } public void send(BrokerTag tag, String queue, Object payload) { JmsTemplate template = templates.get(tag); if (template == null) { throw new IllegalArgumentException("unknown broker tag: " + tag); } template.convertAndSend(queue, payload); } }BrokerTag是个枚举,只有PRIMARY和SECONDARY两个值。业务代码调用时第一个参数就是链路标识,任何人看代码都知道这条消息走的是哪一侧。比起在两个 JmsTemplate 字段之间来回选,这种写法把"路由决策"提到了接口签名里,出错概率低得多。
门面里我还会加一个兜底逻辑:如果目标链路当前处于降级状态(比如运维在配置中心把这个 broker 标记为不可用),发送直接抛业务异常或者落到本地待发表,而不是傻等 5 秒超时。这个逻辑不复杂,但能让故障时的用户体验从"页面转圈"变成"明确报错"。
3.5 监听容器工厂与 @JmsListener 的路由
消费端才是真正容易翻车的地方。两套监听容器工厂必须都建起来,名字和 broker 一一对应:
<bean id="jmsListenerContainerFactoryPrimary" class="org.springframework.jms.config.DefaultJmsListenerContainerFactory"> <property name="connectionFactory" ref="connectionFactoryPrimary"/> <property name="concurrency" value="4-12"/> <property name="sessionTransacted" value="false"/> <property name="autoStartup" value="true"/> <property name="shutdownTimeout" value="15000"/> </bean> <bean id="jmsListenerContainerFactorySecondary" class="org.springframework.jms.config.DefaultJmsListenerContainerFactory"> <property name="connectionFactory" ref="connectionFactorySecondary"/> <property name="concurrency" value="2-6"/> <property name="sessionTransacted" value="false"/> <property name="autoStartup" value="true"/> <property name="shutdownTimeout" value="15000"/> </bean>消费者的写法必须显式指定工厂,否则默认全走主 broker:
@JmsListener(destination = "PRIMARY.ORDER.RECEIPT", containerFactory = "jmsListenerContainerFactoryPrimary", concurrency = "4-12") public void onPrimaryReceipt(String payload) { // 主链路回执处理 } @JmsListener(destination = "SECONDARY.WAYBILL.TRACK", containerFactory = "jmsListenerContainerFactorySecondary", concurrency = "2-6") public void onSecondaryTrack(String payload) { // 外部链路轨迹处理 }注意我把队列名也加了链路前缀。这不是洁癖,是防错。两套 broker 上如果都叫ORDER.RECEIPT,配置改错一行、注解少写一个containerFactory,消息就悄悄跑到另一侧去了,而且表面上一切正常,可能几天后对账才发现。加了前缀之后,至少日志里一眼能看出走的是哪条链路。
defaultDestination不建议在两套模板上都设。一旦模板带了默认目的地,某个调用忘了传队列名,消息就会静默发到默认地址,这在双 broker 场景下是灾难。宁可让它在启动时就报错,也不要让它悄悄发错地方。
3.6 参数怎么算:连接池、并发、prefetch
参数不能拍脑袋,得有算法。先算并发:假设某条链路目标吞吐 200 条/秒,单条消息平均处理耗时 50 毫秒,那么单线程的理论处理能力是 20 条/秒,需要 10 个线程才能扛住。考虑到峰值和抖动,配8-16的并发范围比较合理。这个计算方式比"看别人配多少就抄多少"靠谱得多,因为别人的消息处理耗时和你的完全不一样。
再算 prefetch。ActiveMQ 的消费者默认会预取 1000 条消息到本地缓存,这个默认值在单消费者场景下能极大提升吞吐,但在多消费者、消息处理慢的场景下会造成严重的分配不均——一个消费者手里攥着 1000 条还没处理完,另一个消费者闲着没事干。消息处理耗时超过 100 毫秒、消费者数量超过 3 个的场景,我一般把 queue 的 prefetch 压到 10 到 50 之间。设置方式有两种,全局的:
<property name="prefetchPolicy"> <bean class="org.apache.activemq.ActiveMQPrefetchPolicy"> <property name="queuePrefetch" value="20"/> <property name="topicPrefetch" value="100"/> </bean> </property>或者按队列粒度在目的地名里带参数。全局设置简单,按队列设置灵活,看你的消息类型差异有多大。
session 缓存的算法在前面提过,这里补一个容易漏的项:监听容器本身也占用 session。每个容器实例至少占 1 个 cached session,如果并发上限是 12,那这部分就要留出 12 个的余量。sessionCacheSize配小了不会报错,只会让发送线程排队等 session,表现是偶发的 RT 尖刺,非常难查。
4. 排坑实录:从"只连上一个"到"消息串门"
4.1 几个高频故障现场
启动即报 bean 冲突。这是最常见的第一个坑,前面提过,自动配置还活着导致的。处理方式是排除自动配置类,或者在两个 ConnectionFactory 里给其中一个加@Primary。我推荐排除,因为@Primary只是解决了注入歧义,自动配置创建的jmsListenerContainerFactory仍然挂在主连接上,后面会带来更难查的行为不一致。
只连上了一个 broker。表现是日志里只有一条连接建立的记录,另一条链路的消息发出去无响应但不报错。原因通常是两个 bean 的名字不同但类型相同,某个地方用的是按类型注入,随机选中了一个。排查方式很简单,启动后打一条日志把两个 ConnectionFactory 的 broker 地址都打出来,或者直接看 broker 端的连接列表,连了几个一目了然。
消息发到了对面。这类问题最阴险,因为系统不报错。根因往往是某处代码用了@Autowired JmsTemplate按类型注入,或者监听器漏了containerFactory。我的防错手段是:所有 JmsTemplate 字段的注入一律带@Qualifier,所有监听器一律显式写containerFactory,并且在 CI 里加一个静态检查规则,禁止出现不带限定符的 JmsTemplate 注入。
启动被慢链路拖住。第二套 broker 在外部网络,某天网络抖动,应用启动卡在建立连接上,主链路的服务也跟着起不来。原因是监听容器默认autoStartup=true,容器启动时会尝试建立连接,连接失败会按recoveryInterval反复重试。解决办法是给外部链路配recoveryInterval(比如 5 秒)并把autoStartup设成 false,在应用完全启动后再手动 start。这样主链路先跑起来,外部链路什么时候通什么时候接上。
4.2 问题速查表
| 现象 | 可能原因 | 排查动作 |
|---|---|---|
启动报NoUniqueBeanDefinitionException | 自动配置仍在生效,容器里有两个 ConnectionFactory | 检查是否排除了自动配置类,或补@Primary |
| 只有一条连接建立日志 | 某个 bean 被另一个同类型 bean 覆盖 | 打印两个 ConnectionFactory 的 broker 地址对比 |
| 消息发出无异常但对面收不到 | 监听器漏写containerFactory,消息在另一侧堆积 | 查看两个 broker 控制台的队列积压情况 |
| 发送偶发长 RT | sessionCacheSize过小,发送线程抢 session | 调大缓存,观察 RT 分布是否收敛 |
| 消息在多个消费者间分配不均 | prefetch 过大 | 把 queuePrefetch 压到 10~50 |
| 应用关闭时卡住不退出 | 监听容器等待消息处理完成,shutdownTimeout过长 | 调整超时值,或改造成先停容器再停应用 |
| 一个 broker 挂了导致启动失败 | 监听容器autoStartup=true且无重试间隔 | 设autoStartup=false,配recoveryInterval |
4.3 我踩过的几个细节坑
第一个坑是连接池方案混用。当时为了让第二套连接"性能好一点",给它单独套了 ActiveMQ 的PooledConnectionFactory,外面又包了一层CachingConnectionFactory。结果运行一天后连接数缓慢上涨,重启才恢复。原因是两层池对"归还"的语义理解不一致,某些异常路径下 session 没被正确释放。改成单层缓存后问题消失。这个教训是:一个 ConnectionFactory 只套一层缓存,不要叠。
第二个坑是消息转换器的配置不一致。主链路用的是SimpleMessageConverter,第二套链路用的是 JSON 转换器,两边对同一个业务对象序列化出来的字节完全不同。外部系统偶尔能收到消息但解析失败,日志里全是反序列化异常。解决办法是把转换器也按链路独立配置,并且明确规定:跨 broker 的消息体一律用 JSON 文本,不用对象序列化。这样即使配置写错,消息体本身也是可读的,排查时用眼睛就能看出来。
第三个坑是事务边界。有一段业务逻辑需要先落库、再发消息,我一开始想着"两个 broker 都配上sessionTransacted=true就能保证一致性"。实际测试发现,sessionTransacted只在单个 ConnectionFactory 内保证本地事务,跨 broker 根本没有原子性可言,一个成功一个失败是常态。最终的方案是本地消息表加定时补偿,这部分的必要性在下一节展开说。
第四个坑是队列名大小写。ActiveMQ 的队列名是大小写敏感的,但有些场景下 broker 端的命名习惯是全大写。我在配置里写的是小写,broker 端建的是大写,结果是产生了两个不同的队列,消息发出去后在控制台看不到积压,因为看到的是另一个队列。这类问题没有技术含量,纯粹是命名规范不统一,但它能让你查一整天。现在的做法是队列名统一由配置中心的常量管理,代码里不出现字符串字面量。
5. 上生产前还得补的三件事
5.1 无法跨 broker 的原子性
这是双 broker 架构最重要的认知,必须写在方案文档的第一页:两套 ConnectionFactory 之间没有分布式事务。sessionTransacted管的是单个 broker 内的发送与会话,一个业务操作如果同时要往两侧发消息,任意一侧失败都不会回滚另一侧。
现实中的处理方式有三种。第一种是本地消息表,把要发出去的消息先落库,由一个独立的投递线程去发,失败了重试,这是最常用也最稳的方案。第二种是业务对账加补偿,允许短时间的不一致,靠定时任务扫描修正。第三种是业务上保证幂等,接受至少一次投递,下游自己去重。
我选的是本地消息表加幂等消费。原因很朴素:这个系统的下游本来就要求幂等,既然幂等已经做了,就没必要再追求跨 broker 的强一致,那样只会把架构复杂度抬上一个台阶而收益有限。判断取舍的时候,想清楚"不一致持续几秒会不会造成实际损失",比纠结用哪种分布式事务框架要实际得多。
5.2 优雅停机与启动顺序
双 broker 场景的停机比单 broker 复杂,因为两条链路的停止节奏可能不一样。默认情况下容器停止时会等待正在处理的消息完成,但如果某条消息处理特别慢,整个应用就会卡在关闭过程中,最后被强制 kill,导致消息重复消费。
我的配置是给两个容器工厂都设一个合理的shutdownTimeout,取业务最长消息处理时间的两倍左右,比如 15 秒。同时把应用的关闭流程改成:先停监听容器(container.stop()),再等一段时间让在途消息处理完,最后关闭连接。这套顺序写在SmartLifecycle的实现里,比依赖框架的默认顺序可控得多。
启动顺序上,如果某条链路是"可以晚点连上"的,就把它排在后面启动,用phase值控制。主链路 phase 小一点先启动,外部链路 phase 大一点后启动,这样即使外部链路初始化慢,也不影响主链路开始对外服务。
5.3 监控与容量
两套 broker 意味着两套监控指标,别只盯着一侧看。我关注的核心指标有四个:每条链路的连接数、发送队列的积压深度、消费速率、以及消息从入队到消费完成的延迟。前两个在 broker 端的控制台能看,后两个需要在应用里埋点。
积压深度是最直观的健康度指标。积压持续上涨说明消费能力不足,要么加并发,要么查是不是消费端卡在了某个下游调用上。延迟指标能反映更深的问题,比如积压不涨但延迟变长,往往是单条消息处理慢,这时候加并发没用,得优化处理逻辑。
容量规划上有个经验值可以参考:单套 broker 连接数控制在 50 以内,单队列消费者数控制在 20 以内,超过这个量级不是不能跑,而是问题定位会变得非常困难。真到了这个量级,该考虑的是拆分队列而不是继续堆消费者。
最后分享一个我在这个项目里固定下来的小习惯
每套 broker 配置的类注释里,我都会写清楚这条链路的归属、负责人、SLA 和变更流程。听起来像流程废话,但它救过我一次:某次外部链路切换地址,我按注释找到了对接人确认了切换窗口,避免了在大促前夜改配置。双 broker 的复杂不是因为技术难,而是因为牵扯的边界多,人一多,信息就散,配置注释这种最土的办法反而最有效。真要说这套东西的核心,其实就一句话:把"连哪一套"从隐式的默认行为变成显式的代码和命名决策,剩下的都是参数调优。