Files
cs-note/hhs/MQ/02-消息模型/3-MQ-消息模型.md
T
2026-05-24 20:51:06 +08:00

7.8 KiB
Raw Blame History

tags, create time
tags create time
MQ
2026-05-24 19:52

MQ 消息模型

概述

消息模型决定了消息如何从生产者流向消费者。本文对比队列模型与发布订阅模型,深入剖析 Consumer Group 机制,比较 Push/Pull 两种消费模式,并梳理主流 MQ 的消息模型设计。

正文

队列模型 vs 发布订阅模型

消息队列领域有两种基本模型,理解它们的区别是掌握所有 MQ 的基础。

队列模型(Point-to-Point):一条消息只能被一个消费者消费。就像排队取号,每个号码只能被一个窗口叫到。

发布订阅模型(Pub/Sub):一条消息可以被多个消费者组各自消费一次。就像广播,每个家庭都能听到同一条新闻。

graph TD
    subgraph "Queue Model"
        P1["Producer"] --> Q1["Queue"]
        Q1 --> C1["Consumer A"]
        Q1 --> C2["Consumer B"]
        Q1 --> C3["Consumer C"]
    end
graph TD
    subgraph "Pub/Sub Model"
        P2["Producer"] --> T1["Topic"]
        T1 --> SUB1["Consumer Group A"]
        T1 --> SUB2["Consumer Group B"]
        SUB1 --> CA1["Consumer A1"]
        SUB1 --> CA2["Consumer A2"]
        SUB2 --> CB1["Consumer B1"]
    end

[!question] 如果队列模型中,你想让同一条消息被两个不同的服务分别处理,有什么办法?

Consumer Group 机制详解

为什么需要 Consumer Group?

假设一个 Topic 有 100 万条消息需要处理,单个消费者处理太慢。你需要多个消费者并行消费,但又不想同一条消息被重复处理——Consumer Group 就是为此而生的。

同一个 Consumer Group 内的消费者分摊消息(每条消息只被组内一个消费者处理),不同 Consumer Group 各自独立消费全量消息。

如何分配?

以 Kafka 为例,分配策略的核心逻辑:

graph TD
    T["Topic: 6 Partitions"] --> P0["Partition 0"]
    T --> P1["Partition 1"]
    T --> P2["Partition 2"]
    T --> P3["Partition 3"]
    T --> P4["Partition 4"]
    T --> P5["Partition 5"]
    P0 --> C1["Consumer 1"]
    P1 --> C1
    P2 --> C2["Consumer 2"]
    P3 --> C2
    P4 --> C3["Consumer 3"]
    P5 --> C3

常见的分配策略:

  • Range:按范围连续分配,简单但容易不均衡
  • RoundRobin:轮询分配,更均匀但要求 Topic 数一致
  • Sticky:粘性分配,Rebalance 时尽量保持原有分配,减少不必要的重分配

Rebalance 触发条件

当以下事件发生时,Consumer Group 会触发 Rebalance(重新分配 Partition):

  1. 新消费者加入组
  2. 消费者离开组(主动退出或心跳超时)
  3. Topic 的 Partition 数量变化

Rebalance 期间所有消费者暂停消费,因此Rebalance 的速度和频率直接影响系统可用性。

[!question] 如果一个消费者处理消息特别慢,导致心跳超时被踢出组,会触发 Rebalance。Rebalance 又会导致更多消息积压——这是一个恶性循环,你有什么解决思路?

Push 模式 vs Pull 模式

维度 Push 模式 Pull 模式
实时性 高,消息到达即推送 取决于拉取频率
消费者压力 被动接收,容易被打爆 主动拉取,按自身能力控制速率
吞吐量 低(频繁推送开销大) 高(批量拉取)
实现复杂度 Broker 端复杂(需维护推送状态) Consumer 端复杂(需实现拉取循环)
典型代表 RabbitMQ Kafka

Push 模式就像外卖送到家门口——方便,但高峰期外卖员不停按门铃你也会崩溃。Pull 模式就像自己去快递柜取件——按自己的时间来,但需要你主动去"查"。

主流 MQ 的消息模型

Kafka:Consumer Group + Pull

Kafka 的核心设计哲学是"消费者自己来拉":

  • 每个 Topic 分为多个 Partition,是并行消费的基本单元
  • Consumer Group 内的消费者与 Partition 一一绑定(一个 Partition 只能被组内一个消费者消费)
  • 消费者通过 Offset 追踪消费进度,支持回溯消费
// Kafka 消费者组: Pull 模式
func main() {
    consumer, _ := kafka.NewConsumer(&kafka.ConfigMap{
        "bootstrap.servers": "localhost:9092",
        "group.id":          "order-service-group", // Consumer Group
        "auto.offset.reset": "earliest",            // 从最早消息开始消费
    })
    consumer.SubscribeTopics([]string{"order_created"}, nil)

    for {
        msg, _ := consumer.ReadMessage(-1) // 主动拉取,阻塞等待
        processOrder(msg.Value)
    }
}

RabbitMQ:Exchange + Push

RabbitMQ 使用 Exchange(交换器)实现灵活的消息路由:

  • Producer 将消息发送到 Exchange,而非直接发送到 Queue
  • Exchange 根据 Binding Rule(绑定规则)将消息路由到一个或多个 Queue
  • Broker 主动将消息推送给订阅了 Queue 的 Consumer

Exchange 的四种类型:Direct(精确匹配)、Topic(通配符匹配)、Fanout(广播)、Headers(头部匹配)。

RocketMQ:ConsumerGroup + Pull

RocketMQ 的模型介于 Kafka 和 RabbitMQ 之间:

  • 采用 ConsumerGroup 概念,与 Kafka 类似
  • 默认使用长轮询 Pull 模式(Push 的底层实现其实是 Pull 封装)
  • 支持顺序消费和延迟消息等高级特性

Go 代码示例:消费者组消费逻辑

下面是一个简化的消费者组实现,演示了 Partition 分配和消费的核心逻辑:

type ConsumerGroup struct {
    GroupID    string
    Consumers  []string
    Partitions []int
}

// Rebalance: 将 Partition 均匀分配给消费者
func (cg *ConsumerGroup) Rebalance() map[string][]int {
    assignment := make(map[string][]int)
    sort.Strings(cg.Consumers)

    for i, partition := range cg.Partitions {
        // 轮询分配: Partition i 分配给 Consumer i % len
        consumer := cg.Consumers[i%len(cg.Consumers)]
        assignment[consumer] = append(assignment[consumer], partition)
    }
    return assignment
}

// 消费循环: 拉取消息并处理
func Consume(broker Broker, topic string, groupID string, handler func(Message)) {
    partitions := broker.GetPartitions(topic)
    assignment := assignPartitions(groupID, partitions)

    for _, partition := range assignment {
        go func(p int) {
            offset := broker.GetOffset(groupID, p)
            for {
                msgs := broker.Pull(p, offset, batchSize) // 批量拉取
                for _, msg := range msgs {
                    handler(msg)
                }
                offset += len(msgs)
                broker.CommitOffset(groupID, p, offset) // 提交消费进度
            }
        }(partition)
    }
}

这段代码展示了三个关键设计:

  1. 轮询分配保证 Partition 均匀分布到各消费者
  2. 批量拉取提升吞吐量,减少网络往返
  3. 手动提交 Offset 确保消息不丢失(处理完才提交)

[!question] Push 模式看起来更实时,为什么 Kafka 选择了 Pull?

Kafka 选择 Pull 模式有三个核心考量:

  1. 消费者速率控制:消费者按自己的处理能力拉取,天然避免被打爆。Push 模式下 Broker 不知道消费者的处理速度,需要额外的流控机制。
  2. 批量消费:Pull 模式允许消费者一次拉取一批消息,显著提升吞吐量。Push 模式逐条推送,网络开销大。
  3. 消息回溯:Pull 模式下消费者可以自由控制 Offset,实现"回放"历史消息。Push 模式下消息被推送后就消费掉了,回溯困难。

当然 Pull 模式的代价是实时性不如 Push——但 Kafka 通过**长轮询(Long Polling)**弥补了这个短板:消费者发起拉取请求,如果没有新消息就阻塞等待,直到有消息到达或超时。

关联笔记