1. 一个消费服务对接两套消息集群,到底难在哪
手里维护过消息中间件的人多半遇到过这种局面:公司早期用一套消息集群支撑订单业务,后来另一个事业部或者一条新业务线又搭了第二套集群,两边的生产者各写各的,谁也不愿意改。等到你要做一个统一的消费者服务去把两边消息都收下来做聚合处理时,问题就来了——一个消费服务,怎么同时消费两个消息集群的消息?
这个需求听起来像是"多连一个地址"那么简单,实际动手就会发现坑不少。消费组怎么命名、两套客户端的线程池会不会互相抢资源、位点提交会不会串、其中一套集群抖动会不会把另一套的消费也拖垮,这些都是真金白银踩出来的问题。我这次要聊的,就是围绕"一个consumer服务同时消费两个消息集群"这个场景,把方案选型、代码落地、参数调优和排障经验完整拆一遍。
先明确一下适用人群:如果你只是单集群单消费组,那这篇对你帮助有限;但只要你手上有多套消息集群需要统一消费、或者正在做消息中间件迁移过渡期双读、又或者要给多个业务方的topic做聚合消费,那这篇基本可以照着抄。文中涉及的消息中间件以RocketMQ为主,但思路对同类消息系统同样成立。
需要提前说明的是,下面所有集群名、topic名、消费组名都是虚构代称,参数值也是基于常见生产实践给出的参考区间,你落地时一定要按自己集群的实际规格调整,不要照搬数字。
2. 方案选型:三种思路的取舍逻辑
在动手写代码之前,先把方案想清楚,否则后面返工的成本远大于前期多花的那半小时。一个消费服务消费两套集群,本质上可以归为三类做法,各有各的适用边界。
2.1 方案一:单进程内起两套消费者实例
这是最直观的做法。在同一个Spring Boot应用里,声明两个DefaultMQPushConsumer(或者两套RocketMQTemplate消费端配置),分别指向集群A和集群B的NameServer地址,各自订阅各自的topic和消费组,互不干扰。
它的核心优势是部署简单、资源可控。一个JVM进程,两套消费者共享同一个线程池容器(注意是容器共享,线程池本身要隔离,后面细说),运维只需要管一个服务。对于消费逻辑有聚合需求——比如要把A集群的订单消息和B集群的物流消息做关联计算——这种同进程方案天然占优,因为数据都在一个堆里,不用跨进程通信。
代价也很明显:故障域没有隔离。集群A的NameServer挂了,虽然理论上不影响集群B的消费者,但如果代码里共用了某些阻塞资源(比如同一个业务线程池、同一个数据库连接池),A的消费堆积就可能把B也拖下水。另外两套客户端SDK版本如果差异大,同进程共存时类冲突的概率会上升。
2.2 方案二:拆成两个独立服务,各自消费
把两个集群的消费拆成两个独立的微服务,各自部署、各自扩缩容,中间通过数据库或者另一层消息做结果汇聚。这是隔离性最好的方案,一套集群出问题绝不波及另一套,扩缩容也完全独立。
但它引入了新的复杂度:如果业务上需要两边数据做join,你就得引入额外的汇聚层,要么落库再查,要么再发一条消息到第三个地方,链路变长、延迟变高、一致性也更难保证。而且两个服务的运维成本是翻倍的,对于中小团队来说未必划算。
2.3 方案三:用消息桥接做中转
还有一种思路是搞一个桥接服务,把集群B的消息转发到集群A,最终只让业务消费者面对集群A。这适合迁移过渡期的场景——比如要从旧集群迁到新集群,双读一段时间后切流。
桥接的坏处是多了一跳,延迟增加、消息可能重复、桥接服务本身成了单点和瓶颈。除非是明确的迁移场景,否则我不推荐把它作为长期方案。
2.4 我的选型结论
综合下来,方案一(单进程双消费者)是大多数聚合消费场景的最优解,前提是你把资源隔离做到位。方案二适合两个集群的消费逻辑完全独立、且团队有足够运维能力的情况。方案三只在迁移期用。
下面所有的实操,都基于方案一展开。选它的核心理由就一句话:聚合消费的业务价值,往往大于隔离性带来的运维便利,而隔离性的问题可以通过工程手段在单进程内解决。
3. 核心细节:双消费者共存的几个关键点
方案定了,接下来是把细节抠清楚。单进程跑两套消费者,最容易翻车的地方集中在四个点上:消费组命名、线程池隔离、位点管理、以及客户端实例的生命周期。这四点任何一个没处理好,都会在生产环境给你颜色看。
3.1 消费组命名必须全局唯一且可辨识
RocketMQ里消费组(ConsumerGroup)是位点管理的核心维度,同一个消费组在同一集群内,多个实例会做负载均衡。当你一个服务里跑两套消费者时,两个消费组名绝对不能相同,哪怕它们连的是不同集群——虽然跨集群理论上不冲突,但一旦哪天运维把两个集群的配置搞混,同名消费组会导致位点互相覆盖,这是灾难级的。
我的命名习惯是带上集群标识和业务标识,比如cg_order_aggregator_clusterA和cg_logistics_aggregator_clusterB。这样在控制台一眼就能看出这个消费组属于哪个集群、干什么用的。别图省事用consumer_group_1这种名字,出问题时你连它是谁都不知道。
注意:消费组名一旦上线就不要随意改。改名等于换了一个消费组,位点会从头开始(或者按你配置的消费策略),历史消息可能被重复消费一遍。
3.2 线程池隔离是重中之重
这是最容易被忽视、也最容易出事的一点。RocketMQ的PushConsumer内部有自己的消费线程池,默认是20个线程(consumeThreadMin和consumeThreadMax)。如果你两套消费者都用默认值,那么一个JVM里就有40个消费线程在跑。
问题不在于线程数量,而在于如果消费逻辑里还用了业务线程池或者共享的阻塞资源,两套消费者就会互相争抢。举个真实场景:集群A的消息消费慢,把共享的业务线程池占满了,集群B的消息虽然拉下来了,但提交到业务线程池后一直排队,表现出来就是B也"消费不动"了。
解决办法是给每套消费者配独立的业务线程池,并且给消费线程数设置合理的上限。参考配置如下:
| 配置项 | 集群A消费者 | 集群B消费者 | 说明 |
|---|---|---|---|
| consumeThreadMin | 10 | 10 | 最小消费线程数 |
| consumeThreadMax | 20 | 20 | 最大消费线程数 |
| 业务线程池核心数 | 8 | 8 | 独立线程池,互不共享 |
| 业务线程池队列 | 200 | 200 | 有界队列,防止OOM |
| pullBatchSize | 32 | 32 | 单次拉取条数 |
业务线程池一定要用有界队列,并且配置合理的拒绝策略。我见过用无界队列的,消息一堆积,队列无限增长,最后直接OOM把整个服务干挂。拒绝策略建议用CallerRunsPolicy,让拉取线程自己执行,形成天然的背压,比直接丢弃消息安全得多。
3.3 位点提交策略要分开考虑
两套集群的消息重要程度往往不一样。集群A可能是核心交易消息,一条都不能丢;集群B可能是日志类消息,丢几条无所谓。这种情况下,两套消费者的位点提交策略应该区别对待。
对于核心消息,用同步提交(ConsumeConcurrentlyStatus.CONSUME_SUCCESS返回前确保位点已提交),牺牲一点吞吐换可靠性。对于非核心消息,可以用异步提交或者批量提交,提升吞吐。RocketMQ的并发消费模式下,位点提交是框架自动做的,你能控制的是消费返回状态和重试策略。
重试次数也要分开配。核心消息可以重试16次,非核心消息重试3次就够了,避免无效重试占用资源。这些参数在DefaultMQPushConsumer上通过setMaxReconsumeTimes设置。
3.4 客户端实例的生命周期管理
两套消费者实例的启动和关闭顺序也有讲究。启动时建议先启动核心集群的消费者,再启动非核心的,这样核心消息能更早开始消费。关闭时反过来,先关非核心的,再关核心的,并且要确保shutdown()被正确调用,否则位点可能来不及提交。
在Spring Boot里,可以用@PreDestroy或者实现DisposableBean接口来保证优雅关闭。别小看这一步,服务发布时如果消费者没优雅关闭,轻则重复消费一批消息,重则位点回退导致大面积重放。
4. 实操落地:从配置到代码的完整实现
理论说完了,上代码。下面这套实现基于Spring Boot + RocketMQ客户端,两套消费者通过配置类分别创建,业务逻辑通过独立的处理器分发。我会把关键代码和配置都贴出来,你可以直接参考改造。
4.1 依赖与配置准备
首先确认依赖。如果你用的是rocketmq-spring-boot-starter,它默认只支持单集群配置,双集群需要手动创建消费者实例,不能完全依赖starter的自动装配。我的做法是保留starter的依赖,但不用它的自动消费,手动new消费者。
<dependency> <groupId>org.apache.rocketmq</groupId> <artifactId>rocketmq-client</artifactId> <version>4.9.4</version> </dependency>版本选择上,两套集群的broker版本如果不同,客户端版本要就低不就高,选能兼容两边的最低版本。比如A集群是4.9,B集群是4.5,那客户端用4.5.x更稳妥,避免高版本客户端对低版本broker的某些特性不兼容。
配置文件里把两套集群的地址分开写:
mq: cluster-a: name-server: 10.0.0.1:9876;10.0.0.2:9876 consumer-group: cg_order_aggregator_clusterA topic: TOPIC_ORDER_PAY cluster-b: name-server: 10.1.0.1:9876;10.1.0.2:9876 consumer-group: cg_logistics_aggregator_clusterB topic: TOPIC_LOGISTICS_STATUSNameServer地址用分号分隔多个,这是RocketMQ的标准写法,客户端会自动做故障切换。
4.2 双消费者配置类的实现
核心是创建两个独立的消费者Bean,每个都有自己的线程池和监听器。下面这段代码是我实际项目里简化出来的:
@Configuration public class DualConsumerConfig { @Value("${mq.cluster-a.name-server}") private String clusterANameServer; @Value("${mq.cluster-a.consumer-group}") private String clusterAGroup; @Value("${mq.cluster-a.topic}") private String clusterATopic; @Value("${mq.cluster-b.name-server}") private String clusterBNameServer; @Value("${mq.cluster-b.consumer-group}") private String clusterBGroup; @Value("${mq.cluster-b.topic}") private String clusterBTopic; @Bean(initMethod = "start", destroyMethod = "shutdown") public DefaultMQPushConsumer clusterAConsumer() throws MQClientException { DefaultMQPushConsumer consumer = new DefaultMQPushConsumer(clusterAGroup); consumer.setNamesrvAddr(clusterANameServer); consumer.setConsumeThreadMin(10); consumer.setConsumeThreadMax(20); consumer.setConsumeMessageBatchMaxSize(16); consumer.setMaxReconsumeTimes(16); consumer.subscribe(clusterATopic, "*"); consumer.registerMessageListener(new ClusterAListener()); return consumer; } @Bean(initMethod = "start", destroyMethod = "shutdown") public DefaultMQPushConsumer clusterBConsumer() throws MQClientException { DefaultMQPushConsumer consumer = new DefaultMQPushConsumer(clusterBGroup); consumer.setNamesrvAddr(clusterBNameServer); consumer.setConsumeThreadMin(10); consumer.setConsumeThreadMax(20); consumer.setConsumeMessageBatchMaxSize(16); consumer.setMaxReconsumeTimes(3); consumer.subscribe(clusterBTopic, "*"); consumer.registerMessageListener(new ClusterBListener()); return consumer; } }这里有几个细节值得展开说。initMethod = "start"和destroyMethod = "shutdown"保证了Spring容器启动时消费者自动启动、关闭时自动优雅关闭,省去了手动管理的麻烦。两个消费者的setMaxReconsumeTimes故意设成不同值,A集群16次、B集群3次,对应前面说的核心与非核心消息的区别对待。
4.3 消费监听器与业务线程池隔离
监听器里最关键的是不要直接在consumeMessage方法里做重业务,而是提交到独立线程池。RocketMQ的消费线程是宝贵的拉取资源,被业务阻塞住会导致拉取停滞。
public class ClusterAListener implements MessageListenerConcurrently { private final ExecutorService bizPool = new ThreadPoolExecutor( 8, 16, 60L, TimeUnit.SECONDS, new ArrayBlockingQueue<>(200), new ThreadFactoryBuilder().setNameFormat("clusterA-biz-%d").build(), new ThreadPoolExecutor.CallerRunsPolicy() ); @Override public ConsumeConcurrentlyStatus consumeMessage( List<MessageExt> msgs, ConsumeConcurrentlyContext context) { try { bizPool.submit(() -> processMessages(msgs)); return ConsumeConcurrentlyStatus.CONSUME_SUCCESS; } catch (RejectedExecutionException e) { // 队列满且拒绝策略兜底失败,返回重试 return ConsumeConcurrentlyStatus.RECONSUME_LATER; } } private void processMessages(List<MessageExt> msgs) { // 实际业务处理 } }注意这里有个容易踩的坑:如果你把消息提交到业务线程池后立即返回CONSUME_SUCCESS,那么位点会马上提交,但业务可能还没处理完。一旦服务在这中间挂了,消息就丢了。所以这种"提交即返回成功"的模式只适合允许少量丢失的场景。
对于核心消息,正确做法是在监听器里同步等待业务处理完成再返回状态,或者用更精细的位点管理。我一般对核心消息用同步处理,对非核心消息才用异步提交。这个取舍一定要根据业务容忍度来定,别为了吞吐把数据可靠性搭进去。
4.4 参数计算:线程数到底设多少
线程数不是拍脑袋定的,有个粗略的估算方法。假设集群A的峰值TPS是2000,单条消息平均处理耗时50ms,那么需要的并发线程数大约是:
所需线程数 = TPS × 单条耗时(秒) = 2000 × 0.05 = 100但这是理论值,实际要考虑消费有批量、有IO等待。如果单次拉取16条批量处理,处理一批耗时200ms,那么:
所需线程数 = (TPS / 批量大小) × 单批耗时(秒) = (2000 / 16) × 0.2 = 25所以消费线程数设在20到30之间比较合理。业务线程池可以在此基础上再放大1.5倍,因为业务处理通常比消息解析慢。这些数字只是起点,上线后一定要看监控调整——消费堆积(消息积压量)持续增长就加线程,CPU打满就减线程或者优化业务逻辑。
5. 常见问题与排查技巧实录
双消费者跑起来之后,真正考验人的是出问题的时候。下面这些是我和身边同行踩过的坑,整理成速查表,遇到问题可以对照排查。
5.1 典型问题速查表
| 现象 | 可能原因 | 排查方向 | 解决手段 |
|---|---|---|---|
| 只有一套集群消费正常,另一套不动 | 消费组名冲突或订阅关系错误 | 查控制台消费组订阅关系 | 改消费组名,核对topic |
| 两套都消费慢,互相拖累 | 共享线程池或连接池被打满 | 看线程dump和连接池监控 | 拆分独立线程池和连接池 |
| 消息重复消费严重 | 位点提交失败或消费超时 | 查broker日志的位点提交记录 | 缩短单批处理时间,调大超时 |
| 服务启动后消费组不在线 | 消费者启动失败被吞异常 | 看启动日志有无MQClientException | 检查NameServer连通性 |
| 发布后大量消息重放 | 未优雅关闭,位点未提交 | 查关闭日志 | 确保shutdown被调用 |
| 某集群NameServer切换后消费中断 | 客户端未及时感知路由变化 | 查客户端路由信息刷新日志 | 配置多个NameServer地址 |
5.2 一个真实的排查案例
有次线上出现一个诡异现象:集群B的消费突然停了,但集群A完全正常,两套消费者在同一个进程里。第一反应是集群B的broker出问题了,但登上去看broker一切正常,消费组也在线,就是没有消费进度。
排查过程是这样的:先看应用日志,发现集群B的消费线程全部处于WAITING状态,卡在业务线程池的submit上。再一看业务线程池的队列,满了。但奇怪的是,集群A和集群B用的是独立的线程池啊,为什么B的队列会满?
继续挖,发现B的业务处理里有一个共享的数据库连接池,而集群A的消费逻辑也在用同一个连接池。那天集群A来了一波流量高峰,把连接池占满了,集群B的业务线程拿不到连接,全部阻塞,队列迅速堆积到满,然后CallerRunsPolicy让拉取线程自己执行,拉取线程也被阻塞,最终整个B的消费停滞。
根因是连接池共享,不是线程池共享。这个案例告诉我们,隔离要做到所有共享资源层面,不只是线程池。后来我们给两套消费者配了独立的数据库连接池,问题再没出现过。
经验:做资源隔离时,列一个清单——线程池、连接池、缓存、限流器、锁,凡是可能成为瓶颈的共享资源,都要评估是否需要隔离。宁可多隔离一点,也别让一套集群的问题扩散到另一套。
5.3 监控要盯哪些指标
双消费者场景下,监控比单消费者更重要,因为问题更容易被掩盖。我必看的几个指标:
- 消费堆积量(Diff):每套集群分开看,任何一套持续增长都要告警。
- 消费TPS:两套的TPS曲线应该各自独立,如果一条抖动另一条跟着抖,说明有耦合。
- 消费RT:单条消息处理耗时,突然升高往往是下游依赖出问题。
- 线程池活跃度和队列长度:这是发现资源争抢的第一现场。
- 位点提交延迟:位点长时间不提交,说明消费卡住了。
这些指标建议在同一个监控面板上分两栏展示,方便对比。一旦发现两套的指标有同步波动,基本可以断定存在共享资源耦合,顺着这个线索去查准没错。
5.4 几个容易忽略的细节
第一个是日志隔离。两套消费者的日志如果混在一起,排查时很难区分。建议在日志的MDC里加上集群标识,或者用不同的logger name,这样grep的时候能精准过滤。
第二个是消费位点重置。有时候需要重置某个消费组的位点重新消费,操作时一定要确认清楚是哪个集群的哪个消费组,别手抖把另一套的位点也重置了。这种误操作恢复起来非常麻烦。
第三个是客户端ID。RocketMQ客户端会生成一个clientId,默认包含IP和实例名。同一个JVM里两个消费者实例的clientId要能区分开,否则在broker侧可能被识别为同一个客户端。一般框架会自动处理,但如果你手动设置了setInstanceName,要确保两个实例的名字不同。
6. 进阶优化:让双消费者跑得更稳
基础版本跑通之后,还有一些优化手段能让这套架构更健壮。这部分属于锦上添花,但如果你对稳定性要求高,值得投入。
6.1 动态开关与降级
给每套消费者加一个动态开关,通过配置中心控制。当某套集群出现问题时,可以快速关闭对应的消费者,避免它拖累整个服务。这个开关要能在不重启服务的情况下生效,实现方式可以是监听配置变更,然后调用消费者的suspend()和resume()方法。
降级策略也要提前想好。比如集群B的消息非核心,当系统压力大时,可以临时降低B的消费线程数,把资源让给A。这种动态调整能力在流量高峰期特别有用。
6.2 消费幂等设计
双消费者场景下,消息重复的概率比单消费者高,因为涉及两套位点管理。所以消费逻辑必须幂等。常见的幂等方案有:用消息的唯一key做去重表、用业务主键做唯一索引、用Redis记录已处理的消息ID。
我一般用"业务主键+状态机"的方式做幂等:处理前先查当前状态,如果已经是目标状态就跳过。这种方式比纯去重表更贴合业务,也不会因为去重表膨胀而影响性能。
6.3 灰度与流量切换
如果这个双消费者服务是从单集群演进过来的,上线时要做灰度。可以先让新服务只消费集群B,观察一段时间稳定后,再把集群A的消费也切过来。切换时注意位点的衔接,避免消息漏消费或大量重放。
具体做法是:新服务启动时,把消费位点设置为当前时间点(CONSUME_FROM_TIMESTAMP),然后停掉旧消费者,这样新旧之间只有很小的重叠窗口,重复量可控。
7. 我个人的几点实操体会
这套双消费者架构我在几个项目里都用过,从最初的踩坑到后来的稳定运行,积累了一些文档里不会写的体会,分享给准备动手的你。
最重要的一条是:隔离要做到彻底,不要心存侥幸。我一开始也觉得线程池隔离就够了,结果被连接池共享坑了一次。后来我把所有共享资源都列了清单逐一隔离,虽然配置麻烦了点,但再没出过互相拖累的问题。隔离的成本是一次性的,不隔离的代价是每次故障都要重新排查。
第二条是:消费组命名和监控面板要提前规划。这两样东西后期改起来成本很高,命名混乱会让你在排查时浪费大量时间,监控不分开则根本发现不了耦合问题。前期多花十分钟规划,后期省下的是几小时的排查。
第三条是:核心消息和非核心消息一定要区别对待。重试次数、提交策略、线程资源,都应该按重要程度分级。一刀切的配置要么浪费资源,要么牺牲可靠性,两头不讨好。
最后一条,也是我觉得最容易被忽视的:优雅关闭比优雅启动更重要。启动出问题顶多是消费晚一点,关闭出问题可能导致位点回退、消息大面积重放。所以shutdown()的调用、位点的最终提交、线程池的优雅停止,这些关闭流程一定要测试到位,别等发布时才第一次验证。
这套方案后续还能往几个方向扩展:比如把消费者的配置完全外置到配置中心,实现动态调参;比如引入更细粒度的消费限流,按topic甚至按消息tag限流;再比如把两套集群的消费结果做实时对账,及时发现数据不一致。这些都属于在稳定基础上的进一步打磨,等你的双消费者跑顺了,可以逐步加上。