1. 分不清这两种模式,消息队列就很容易“不听话”
我见过不少团队,消息队列用了大半年,突然有一天生产环境出了怪事:同一个消息,明明只想让库存服务去处理,结果订单服务和积分服务也各消费了一份;又或者反过来,一个消息推给了一个消费者组,组里好几个节点抢着处理,处理完了还互相都以为对方没处理完,业务数据就重复了。排查到最后,根子往往不在代码逻辑,而在最基础的那个问题上——你到底用的是点对点模式,还是发布/订阅模式。
这个主题看着基础,其实信息量很大。消息队列几乎所有的高级问题,不管是重复消费、消息丢失、消费组设计、延迟队列,还是面试时被追问的“Kafka的消费组和RabbitMQ的Exchange到底怎么理解”,底层都是这两套消息分发模型在起作用。把这篇文章看完,你能得到三样东西:一套能直接讲清楚两种模式差异的话术;一套基于Spring Boot和Redis Stream的落地示例;还有一个应对面试追问的知识点框架。
先说一个容易让人蒙圈的背景。很多人以为“点对点”和“发布/订阅”是两种不同的消息队列软件,或者以为是两种不同的协议,其实都不是。它们是同一套消息中间件内部支持的两种消息路由语义。ActiveMQ有Queue和Topic两种Destination,RabbitMQ用队列加Exchange组合出两种投递行为,Kafka的消费组机制内部同时存在这两种模式,Redis Stream通过消费者组也把两种模式都用上了。所以问题不是“选哪种消息队列”,而是“在你正在用的这个队列里,怎么定义消费者之间的关系”。
1.1 一个看似简单却总被搞混的问题
先问一个特别基础的问题:消息队列里的消息,消费完了之后去哪儿了?不同的人给出的答案完全不一样。有人说消息被消费者拿走之后就没了,有人说消息还在队列里可以重复读,还有人说同一个消息可以同时发给很多个系统。这三种回答,恰好对应了消息队列演进中的不同设计。
如果消息只是被一个消费者领取后移除,这就是典型的点对点模式。如果消息被广播给所有订阅者,每个订阅者各自拥有一份副本,这就是发布/订阅模式。而Kafka这种比较“现代”的设计,则用消费组的概念把这两种模式统一到了一起:同一个消费组内部是竞争关系,一条消息只给组内的一个消费者;不同消费组之间是订阅关系,每个组都能独立读到同一条消息。
在没把这个问题想明白之前,任何代码层面的效率优化都是虚的。因为你可能连“这条消息应该被谁消费”这个最基础的语义都没定义对。
1.2 点对点模式:一条消息只属于一个消费者
点对点模式,英文叫Point-to-Point,缩写P2P。它的核心语义是:生产者把消息发送到一个队列(Queue),队列里的每一条消息只会被一个消费者接收并处理一次。消息一旦被成功消费,通常就从队列中移除了。
怎么理解?可以把它想成一张只有单座的电影票。电影院放一场电影,票卖出去一张,就只有买到票的那个人能进场,其他人不能再拿这张票进去。更准确地说,这个队列就像一条只能一个人通过的窄桥,桥这头的人把东西送过去,桥那头只有一个人能接到。
在这种模式下,生产者和消费者之间没有强耦合关系,两者处理速度可以不一样,队列天然起到削峰填谷的作用。多个消费者之间是竞争关系,叫“竞争消费者”,系统可以通过增加消费者来提升处理速度,也就是常见的水平扩容。ActiveMQ的Queue、RabbitMQ里多个消费者订阅同一个Queue、以及Kafka里同一个消费组内的多个消费者,本质上都是这种模式。
这里的坑在于,很多人以为“点对点”必须是“一对一”,也就是一个生产者只能配一个消费者。实际上,点对点允许有多个生产者、多个消费者,只是每一条具体消息,只会被其中的某一个消费者消费。它强调的是消息的独占性,不是参与方的数量。
1.3 发布/订阅模式:一份消息广播给所有订阅者
发布/订阅模式,英文Publish/Subscribe,缩写Pub/Sub。它的核心语义是:生产者把消息发布到一个主题(Topic)或频道(Channel),所有订阅了这个主题的消费者都能收到同一份消息的副本。
类比一下就很好懂。这就像广播电台。电台发射信号,只要你的收音机调到了对应频率,就能收到同样的节目,收听的人再多也不影响电台本身,电台也不关心谁在听。在系统中,消息发布方根本不需要知道有多少个订阅方存在,订阅方也不需要你单独注册,订阅关系是松散的。
在这种模式下,同一个消息可以被多个不同的业务系统消费,每个系统拿到的消息都是完整的。它天然解决了“一份数据变更需要通知多个下游系统”的问题。比如用户下单后,订单系统要发通知给库存系统、积分系统、数据分析系统,这时发布/订阅模式就非常合适。
但这里同样有陷阱。很多人把RabbitMQ的Topic Exchange当成某种“特殊的发布/订阅”,其实RabbitMQ里一个Queue被多个消费者绑定时,消息在队列内部是按点对点分配的;要实现真正的发布/订阅,得让每个订阅者都有独立队列,再通过Exchange广播把消息复制到各个队列。这个细节在面试中非常容易被问到。
最后用一个表格把两者的核心差异列出来,方便直接对照:
| 对比维度 | 点对点模式 | 发布/订阅模式 |
|---|---|---|
| 核心语义 | 一条消息只被一个消费者消费 | 一条消息广播给所有订阅者 |
| 消费者关系 | 竞争关系,互相争抢消息 | 平等关系,各自独立消费 |
| 消息去留 | 消费后移除(或标记消费) | 每个订阅者各自维护一份消费进度 |
| 生产与消费耦合度 | 低,存储天然起到缓冲作用 | 更低,发布方完全不感知订阅方 |
| 典型场景 | 任务分发、异步处理、削峰填谷 | 事件广播、数据同步、多系统通知 |
| 离线容错 | 消费者暂时离线消息排队等待 | 取决于订阅者是否有持久化存储/消费组机制 |
这个表格看着简单,但读的时候一定要结合你正在用的消息中间件来看,因为不同中间件对这两种模式的实现细节差别很大,也容易踩坑。接下来进入实操层面,把这两种模式放到真实代码里彻底拆开。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 从存储模型看两种模式的本质区别
2.1 同一个消息队列里怎么同时实现两种模式
理解两种模式,光看概念不够。真正拉开差距的,是消息中间件底层怎么存消息、怎么记录消费进度。这个底层机制决定了你在使用时会遇到什么样的问题。
以RabbitMQ为例。它的核心模型是Queue,消息进了Queue之后,不管有多少消费者订阅了这个Queue,每条消息只是按轮询或预处理机制分配给了其中一个消费者,消费完即确认删除。这是最纯粹的点对点。如果想让一份消息广播给多个系统,你需要定义一个Exchange,用fanout类型把消息复制到多个Queue里,每个下游系统各自持有Queue,这才算发布/订阅。简单说,RabbitMQ里“想发布/订阅,先复制消息”。
Kafka则不太一样。它把消息存在Topic的多个分区里,一条消息只落在某一个分区。消费者以消费组为单位去读。同一个消费组里的多个消费者会分摊不同分区的消息,组内不会出现两个消费者读到同一条消息的情况,这就是点对点。而不同的消费组去订阅同一个Topic,各读各的,互不影响,这就是发布/订阅。Kafka的巧妙之处是,复制消息这件事不是发生在存储层,而是发生在消费层:每个消费组都有独立的消费位移,等于每个组都在Topic上拥有一份逻辑上的独立副本。
Redis Stream的模型介于两者之间,也比较适合拿来写代码演示。Stream本身是一个只追加的日志结构,消息持久化存储在Redis里。你可以在Stream上创建多个消费者组,组内多个消费者共享消费进度,组与组之间又各自维护进度。这非常贴近Kafka的消费组模型,但Redis Stream自带一个Pending Entries List(简称PEL),用来记录已投递但未确认的消息,这又比Kafka原生机制更直白地暴露了消息确认这件事。
这三类中间件的对比如下:
| 中间件 | 点对点实现方式 | 发布/订阅实现方式 | 消费者故障处理 |
|---|---|---|---|
| RabbitMQ | 多个消费者争抢同一个Queue | Fanout/Topic Exchange复制消息到多个Queue | 消息未被ACK时重回Queue |
| Kafka | 同一个消费组内分摊分区 | 不同消费组各自消费 | 消费者宕机触发Rebalance,重投未提交位移的消息 |
| Redis Stream | 同一个消费组内分摊消息 | 不同消费组各自消费 | 未ACK消息进PEL,可XCLAIM转移 |
2.2 用Spring Boot加Redis Stream把两种模式实际跑一遍
纸上谈兵没意思,我用Redis Stream加Spring Boot写一个例子,把两种模式跑通。这个场景是“订单系统下单成功之后,需要处理三件事:发短信给用户、通知仓储扣库存、同步给数据分析平台”。
先看生产者。订单系统发布一条消息到Stream,一个名为stream:order:created的键:
java复制// 引入依赖后注入 RedisTemplate
@Autowired
private RedisTemplate<String, String> redisTemplate;
public void publishOrderCreated(OrderCreatedEvent event) {
Map<String, String> body = new HashMap<>();
body.put("orderId", event.getOrderId());
body.put("userId", event.getUserId());
body.put("amount", event.getAmount().toString());
body.put("skuId", event.getSkuId());
// 使用 opsForStream 向 Stream 追加消息
redisTemplate.opsForStream()
.add("stream:order:created", body);
}
刚才那段代码只是把消息写进去了,谁来消费还没定。Redis Stream里,不创建消费组直接读的用法,在实际生产环境很少用,因为消费者之间没有任何协调。这里的重点是在Stream上创建消费组,而组与组之间天然就是发布/订阅语义。
比如仓储服务和短信服务都关心订单创建消息,但它们处理逻辑完全不同,也不希望互相影响进度。那就为它们各建一个消费者组:
java复制public void setupConsumerGroups() {
// 仓储服务所属的消费组
try {
redisTemplate.opsForStream().createGroup(
"stream:order:created", "group:inventory");
} catch (RedisSystemException e) {
// 组已存在会报错,忽略即可
}
// 短信服务所属的消费组
try {
redisTemplate.opsForStream().createGroup(
"stream:order:created", "group:sms");
} catch (RedisSystemException e) {
// 组已存在会报错,忽略即可
}
}
有了group:inventory和group:sms两个消费者组后,这个拓扑结构就很有意思了。这两个组读的是同一条Stream里的同一个消息,不需要复制任何数据,每个组各自消费一遍,这就是发布/订阅。
再往下一层。仓储这个服务,如果部署了3个实例,它们都属于group:inventory,那么一条订单消息只会被其中某一个实例拿到。不可能三个仓储实例都去扣一次库存,否则库存就扣超了。这个组内互斥的逻辑,就是点对点。
代码上,仓储服务的每个实例用相同消费组名、不同消费者名去拉消息:
java复制@Scheduled(fixedDelay = 1000)
public void pollInventoryTask() {
// 当前实例名称,部署时通过环境变量传入,保证每个实例不同
String consumerName = Environment.getProperty("HOSTNAME", "inventory-1");
List<MapRecord<String, Object, Object>> records =
redisTemplate.opsForStream().read(
Consumer.from("group:inventory", consumerName),
StreamReadOptions.empty().count(10).block(Duration.ofSeconds(3)),
StreamOffset.create("stream:order:created", ReadOffset.lastConsumed())
);
for (MapRecord<String, Object, Object> record : records) {
String orderId = (String) record.getValue().get("orderId");
try {
inventoryService.deductStock(orderId);
// 业务处理成功后,确认这条消息
redisTemplate.opsForStream().acknowledge(
"stream:order:created", "group:inventory", record.getId());
} catch (Exception e) {
// 处理失败:不确认,消息会留在 PEL 中
log.error("库存扣减失败,orderId={}", orderId, e);
}
}
}
这里有几个操作值得细看。ReadOffset.lastConsumed()表示从当前组已经消费的位置继续往下读,这是正常消费的写法。acknowledge做的是消息确认,告诉Redis这条消息处理完了,可以不再追踪。如果某条消息一直不确认,它会一直躺在组的Pending列表里,这就是后来做死信处理和故障恢复的基础。
短信服务的代码结构完全一样,只需要把消费者组名换成group:sms,再把里面的业务逻辑换成发短信即可。这就是发布/订阅里的“一套代码,两个订阅者”。
2.3 消费者故障时,两种模式的处理路径完全不同
很多人用消息队列,最害怕的就是“消息丢了”和“重复消费”。而这两种问题,恰好和消息是否被确认、消费者是否正常退出有直接关系。
在点对点模式下,一条消息一旦被某个消费者领取但未确认,其他消费者是不能同时消费这条消息的,但消息本身不会凭空消失。在Redis Stream里,这条消息进入消费组的PEL列表,等待被确认或者被超时重新分配。在RabbitMQ里,如果消费者断开且没发ACK,消息会被重新放回队列头部,投递给其他消费者。这就是“至少一次投递”语义的由来:正常情况下一条消息被消费一次,但消费者中途挂了,就会再投一次,于是有了重复消费问题。
在发布/订阅模式下,问题更复杂一些。这里先分清两种发布/订阅的实现。第一种是纯广播式,像Redis原生的PUBLISH/SUBSCRIBE,消费者离线就收不到消息,没有任何补发机制,适合实时性要求高的场景,但也丢得起消息。第二种是带消费组的订阅式,像Kafka和Redis Stream这样,每个组都有自己的消费进度,消费者离线期间,消息不会丢,等它恢复后还能从上次的位置继续读。
所以在面试或者技术方案里,只要说到“发布/订阅”,最好反问一句:你指哪种?是临时订阅者,还是需要消息持久化等待离线消费者回来补消费的那种?这两种对于消息可靠性的承诺完全不同。如果你的业务一条都不能丢,就别用纯Pub/Sub,得用带消费组机制的模型,并且配合ACK确认机制使用。
Redis Stream的XCLAIM命令,是处理组内消费者故障的利器。比如某个仓储实例处理到一半宕机了,它拉走的消息还没有ACK,一直躺在PEL里。这时候另一个实例可以用XCLAIM把这些超时未确认的消息转移到自己名下继续处理:
java复制public void claimTimeoutMessages(String consumerName, Duration timeout) {
// 先查询当前组里所有 pending 消息
PendingMessages pendingMessages = redisTemplate.opsForStream()
.pending("stream:order:created", "group:inventory");
List<ByteRecord> idleRecords = new ArrayList<>();
for (PendingMessage p : pendingMessages) {
// 根据 idle 时间判断是否超时未被确认
if (p.getElapsedTimeSinceLastDelivery() != null
&& p.getElapsedTimeSinceLastDelivery().compareTo(timeout) > 0) {
// 把这条消息重新分配给当前消费者
redisTemplate.opsForStream().claim(
"stream:order:created", "group:inventory", consumerName,
Duration.ZERO, p.getIdAsString());
}
}
}
这个机制是消息队列可靠性里非常核心的一块。很多团队日志里出现“消息处理了两次”的告警,多半就是超时重投导致的,而不是代码逻辑本身跑了两遍。
3. 实践中的热点困扰:重复消费、消息丢失和延迟消息
3.1 重复消费是从哪里冒出来的
说到重复消费,这是搜索热词里排名很高的问题,几乎每个用消息队列的团队都遇到过。先说结论:绝大多数重复消费问题都不是消息队列“多发”了,而是消费端在“确认”这个环节出了岔子。
拿Redis Stream的流程来说,消费者读出消息后,执行完业务代码,再发ACK。这个顺序意味着:如果业务代码执行成功了,但ACK没有发出去,比如进程在两者之间崩溃,或者网络抖动把ACK丢了,这条消息就会被判定为“未处理”。等消费者恢复或者超时机制触发后,消息会被重新投递一次,于是重复消费就产生了。
RabbitMQ的场景也一样。消费者处理完消息,还没来得及发送Basic.Ack,连接断了,RabbitMQ认为消费失败,把消息重新入队,下一次又被另一个消费者拿到。这不是中间件的bug,而是“至少一次投递”这种可靠性模型下必然存在的副作用。你要防止消息丢失,就要允许偶尔重复,然后靠消费端自己去保证幂等。
所以面试和实际工作中,回复“怎么解决重复消费”这个问题时,标准答案其实分两步:先说明为什么中间件无法从根本上避免重复,再给出消费端的幂等设计方案。如果只背“用Redis分布式锁防重”,那是远远不够的。
我在实际项目里常用的幂等方案有三种。
第一种是唯一业务键去重。比如订单消息里有orderId,消费端在处理前先去数据库查或者插入一条以orderId为唯一键的记录,如果记录已存在,说明这个消息之前处理过,直接跳过。数据库的唯一索引是最可靠的方式,因为它本身有事务保证。
第二种是记录消费位点或消息ID。如果消息体里带有全局唯一消息ID,消费端可以在处理前把ID放入Redis,设置一个合理的过期时间,利用setIfAbsent实现快速去重。这个方案的优点是性能好,缺点是Redis数据可能丢失,不适合严格幂等的场景。
第三种是利用业务自身的状态机判断。比如订单状态流转里,如果订单已经是“已支付”状态,那么重复收到“支付成功”的消息时,直接返回成功,不做任何变更。这种方案最彻底,因为它把幂等融入到了业务逻辑里,而不是额外加一套防护机制。
3.2 Redis Stream拉取消息时,怎么做才不容易出问题
热词里有一条是“spring boot redis stream 如何拉取队列消息”。很多初学者直接用redisTemplate.opsForStream().read()去读Stream,发现每次开一个消费者,都能读到全量消息。这其实就是没搞懂之前说的消费组机制,直接读取不带组的Stream时,每个消费者都是独立的,彼此之间没有任何协调,消息被A读了之后,B还能再读到。
如果业务要做的是任务队列,也就是一条消息只能被一个Worker处理,就必须使用消费者组模式,让所有Worker加入同一个组。前面示例中仓储服务的写法,就是正确的组内拉取模式。
如果业务要做的是广播通知,也就是每个系统都要收到全量消息,那就让每个系统的实例各自组建一个消费者组。组之间天然隔离,互不影响消费进度。
另外要注意,拉取消息不能只调一个方法就完事,必须配套ACK逻辑。很多生产事故都是这样发生的:消费者把消息从Stream里拉出来了,业务处理完,但没有调acknowledge方法。表面上看消息已经被读到了,处理也正常,但流的PEL里会积压越来越多“未确认”的消息。时间一长,内存和性能都会出问题,重启之后这些消息还会被重新投递,造成大面积的重复消费。
再补充一个非常重要的细节:ReadOffset.lastConsumed()适合正常消费场景,但有一种情况会漏消息——如果某个消费者把消息拉走了,处理失败回滚,消息留在PEL里,而组内其他消费者再执行lastConsumed()读取时,会直接忽略掉那些Pending消息。也就是说,失败的这些消息不会自动重新加入正常消费通道。需要有一个定时任务去扫描PEL,把超时的消息重新分配,也就是前面用XCLAIM处理的操作。严格的话,还要把超过重试次数的消息单独挪到死信Stream里,方便人工介入排查。这不是锦上添花,而是生产环境必须要有的兜底逻辑。
3.3 延迟消息是怎么实现的
延迟消息是另一个高频问题。比如下单后15分钟未支付自动关闭订单,这个场景显然是等不到用户主动发消息的,而是需要在订单创建后延迟处理。消息队列本身并不天然支持“延迟投递”,所以要用一些变通方案,并且方案会因为中间件的不同而不同。
用Redis Stream实现延迟队列,常见的做法是用一个ZSet当时间轮。每条消息的score设置为“预期执行时间”,用一个定时任务每秒扫描一次当前时间到score之间的元素,把到期的消息捞出来,再发到真正的Stream里,转给业务消费者处理。
java复制public void schedule(String taskId, long delaySeconds) {
long executeAt = System.currentTimeMillis() + delaySeconds * 1000;
redisTemplate.opsForZSet().add("delay:order:close", taskId, executeAt);
}
@Scheduled(fixedDelay = 1000)
public void scanDueTasks() {
long now = System.currentTimeMillis();
// 找出所有到期任务
Set<String> dueTasks = redisTemplate.opsForZSet()
.rangeByScore("delay:order:close", 0, now);
for (String taskId : dueTasks) {
// 发到正常业务 Stream
redisTemplate.opsForStream().add("stream:order:close",
Map.of("orderId", taskId));
// 从延迟集合中移除
redisTemplate.opsForZSet().remove("delay:order:close", taskId);
}
}
这个方案实现简单,适合延迟精度要求不高的场景。但有一个分布式环境下的坑:如果服务部署多个实例,定时任务会在每个实例上都跑一遍。不做分布式锁的话,同一个到期任务可能被多个实例同时捞走,重复发送到Stream里。解决办法是给定时任务加分布式锁,但这就把复杂度往上推了一截。
如果用的是RabbitMQ,延迟消息可以用TTL加死信队列实现,也可以直接用RabbitMQ 3.8版本之后原生的延迟消息插件。用TTL加DLX方案时,具体流程是:消息先发到一个设置了TTL的队列,TTL到期后消息变成死信,被路由到真正处理业务的那个队列。这个方案的缺点是:同一个TTL队列里的所有消息只能有同一个过期时间,如果不同业务需要不同的延迟时间,就得建很多个队列,管理起来很麻烦。原生的延迟消息插件没有这个限制,但需要额外安装插件,并不是所有RabbitMQ托管服务都默认开启。
本身支持延迟消息的中间件,比如RocketMQ,就省事很多。所以如果你在技术选型时就知道业务里会有大量延迟消息,选型阶段就应该把这点考虑进去。先看业务对延迟时间粒度的要求、对可靠性的要求,再决定是用现成能力还是自己造轮子。
4. 面试被追问时,怎么把两种模式讲出区分度
4.1 标准答法和进阶答法的差距
“消息队列里点对点模式和发布/订阅模式的区别是什么”,这是面试中的高频题。这道题看似送分,实际上淘汰率非常高,因为大多数人只答出了表面的一句话差异,而面试官想听的是你有没有在真实场景里用过这两种模式。
及格线的答法是:点对点模式中,一条消息只能被一个消费者消费,消费后消息即被确认删除;发布/订阅模式中,一条消息会被所有订阅者消费。点对点适合任务分发,发布/订阅适合事件广播。这个说法没错,但也就只有这个水平了。
进阶的答法可以先从一个熟悉的中间件切入。比如这样说:以Kafka为例,同一个消费组内部,一条消息只会被组内的一个消费者实例消费,这是点对点语义;多个不同的消费组订阅同一个Topic,每个组都能独立消费到同一条消息,这就是发布/订阅语义。因此Kafka在同一个Topic上,同时实现了两种模式。组内的竞争消费通过分区分摊实现,组间的独立消费通过独立的消费位移实现。这个答法说明你理解消费组机制,而不是只背了教材概念。
再往上走一层,可以延伸到选型思考。比如点对点模式适合异步任务处理,因为天然支持水平扩容,但要注意消息唯一下发到组内一个实例后,处理能力受限于单分区或单队列的顺序性。发布/订阅模式适合系统间解耦和事件驱动架构,但如果上下游系统差异大,要注意背压问题和消费速度不一致导致的堆积。能顺着这个思路往下讲,面试观感会好很多。
4.2 容易被追问的关联知识点
一旦你提到Kafka的消费组,后面很可能被追问“消费者数量超过分区数会发生什么”。答案是:同一消费组内,一个分区最多只能被组内的一个消费者消费,所以消费者数量超过分区数时,超出的消费者会空闲,拿不到任何消息。这也是很多团队给Kafka扩容时遇到的经典问题,加消费者不一定提升吞吐,得先看分区数够不够。这一点和RabbitMQ的Queue模式有很大差异:RabbitMQ的队列消息是动态分发给消费者的,多开几个消费者基本能提升处理速度,而Kafka的性能上限更多由分区数决定。
还有一道常规追问是关于push和pull的。RabbitMQ默认用的是push模式,Broker主动把消息推给消费者,这种模式实时性好,但如果有消费者的处理速度跟不上,消息会在消费者本地积压,造成内存压力。Kafka和Redis Stream的消费者组模式属于pull模式,消费者主动拉取,拉多拉少由自己控制,这给了消费端更好的流量控制能力,但代价是会引入一定的拉取延迟。真实项目中“延迟”这个问题通常不是瓶颈,可以靠长轮询缓解,但push模式下消费者被压垮的问题,反而难处理得多。
如果聊到可靠性,面试官的下一步基本会落在“如何保证消息不丢、不重、不乱序”三个问题上。不丢靠持久化加ACK确认;不重靠消费端幂等;不乱序靠消息Key哈希到同一分区或同一队列。这个时候,你再把两种模式结合进去讲会更立体:点对点模式下,多个消费者并发消费时天然会带上乱序风险,所以如果需要严格顺序,通常要让组内只有一个消费者,或者按业务维度路由消息;发布/订阅模式下,不同消费组之间互相不干扰,但如果某个组只有一个消费者,那这个组的消费能力又会被限制住。这些问题没有标准答案,全看业务在吞吐、顺序、可靠性之间怎么取舍。
我个人在实际项目里踩过不少消费组的坑之后,最大的体会是:选消息中间件和设计消息模型时,不用太纠结某个模式“能不能用”,更重要的是先回答清楚两个问题——第一,每一类下游系统之间,是否需要独立消费同一份消息?需要就建多个组,不需要就共用一个组;第二,消息消费者如果宕机了,消息可以等它回来继续消费,还是宁可丢弃也不能阻塞?这个定下来,用点对点还是发布/订阅,其实是顺理成章的事。把这个思考顺序沉淀成自己的方法论,面试的时候能讲出的内容,就远比背概念要扎实得多。
