资讯详情

资讯详情

一个消费服务如何同时消费两套消息集群?RocketMQ双消费者实战

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消费者说明consumeThreadMin1010最小消费线程数consumeThreadMax2020最大消费线程数业务线程池核心数88独立线程池互不共享业务线程池队列200200有界队列防止OOMpullBatchSize3232单次拉取条数业务线程池一定要用有界队列并且配置合理的拒绝策略。我见过用无界队列的消息一堆积队列无限增长最后直接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 groupIdorg.apache.rocketmq/groupId artifactIdrocketmq-client/artifactId version4.9.4/version /dependency版本选择上两套集群的broker版本如果不同客户端版本要就低不就高选能兼容两边的最低版本。比如A集群是4.9B集群是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( ListMessageExt msgs, ConsumeConcurrentlyContext context) { try { bizPool.submit(() - processMessages(msgs)); return ConsumeConcurrentlyStatus.CONSUME_SUCCESS; } catch (RejectedExecutionException e) { // 队列满且拒绝策略兜底失败返回重试 return ConsumeConcurrentlyStatus.RECONSUME_LATER; } } private void processMessages(ListMessageExt 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限流再比如把两套集群的消费结果做实时对账及时发现数据不一致。这些都属于在稳定基础上的进一步打磨等你的双消费者跑顺了可以逐步加上。
觉得有用,分享给同行:

为您的企业打造数字门面

稳重轻奢商务风格,端正雅致视觉,长效耐看不易过时。

立即咨询 →