在落地 Pulsar 的过程中,开发者最容易踩的坑集中在两个问题上:
问题一:”我有多个消费者,消息是怎么分发的?顺序能保证吗?”
问题二:”消费者处理业务逻辑很慢,会不会把整个消费流程阻塞住?”
这两个问题的答案,分别对应 Pulsar 的订阅模式(Subscription Type) 和消费模型(Consumption Pattern)。
本文基于 Go 语言,通过完整可运行的代码,逐一拆解四种订阅模式,并给出生产环境中解决阻塞问题的异步消费方案。
环境准备
docker run -d --name pulsar \ -p 6650:6650 \ apachepulsar/pulsar:latest \ bin/pulsar standalone
go mod init pulsar-demo go get github.com/apache/pulsar-client-go/pulsar
go run pulsar_subscription_demo.go
|
一、基础概念:订阅模式是什么?
Pulsar 的订阅(Subscription) 是消费者与 Topic 之间的绑定关系,由三个要素确定:
Topic ──────────────────→ Subscription │ ├─ SubscriptionName(订阅名,唯一标识) ├─ Type(订阅模式,决定分发规则) └─ Cursor(消费进度,记录哪些消息已 Ack)
|
订阅模式在 Consumer 订阅时声明,一旦该订阅名创建,模式不可更改。
consumer, err := client.Subscribe(pulsar.ConsumerOptions{ Topic: "persistent://public/default/my-topic", SubscriptionName: "my-sub", Type: pulsar.KeyShared, })
|
Pulsar 提供四种订阅模式,覆盖从”严格有序”到”最大吞吐”的所有需求:
严格有序 ◄─────────────────────────────────► 最大吞吐 │ │ Exclusive Failover Key_Shared Shared (独占) (主备) (Key路由) (共享)
|
二、Topic 命名规则
在看代码之前,先理解 Pulsar Topic 的完整命名格式:
persistent:// public / default / my-topic ───────────── ────── ─────── ──────── 存储类型 租户 命名空间 Topic名称
persistent = 消息持久化到磁盘(BookKeeper) non-persistent = 消息不落盘,Broker 重启即丢失 public = 默认租户(内置) default = 默认命名空间(内置)
|
三层命名空间(租户 / 命名空间 / Topic)正是 Pulsar 原生多租户的基础,每一层都可以独立配置权限、配额和存储策略。
三、创建客户端连接
客户端是整个应用的单例,一个进程通常只需要一个 Client。
func newClient() pulsar.Client { client, err := pulsar.NewClient(pulsar.ClientOptions{ URL: "pulsar://localhost:6650",
OperationTimeout: 30 * time.Second,
ConnectionTimeout: 30 * time.Second,
}) if err != nil { log.Fatalf("创建 Pulsar 客户端失败: %v", err) } return client }
|
四、Producer:发送消息
4.1 普通消息(无 Key)
func produceMessages(client pulsar.Client, topic string, messages []string) { producer, err := client.CreateProducer(pulsar.ProducerOptions{ Topic: topic,
}) if err != nil { log.Fatalf("创建 Producer 失败: %v", err) } defer producer.Close()
for _, msg := range messages { msgID, err := producer.Send(context.Background(), &pulsar.ProducerMessage{ Payload: []byte(msg), }) if err != nil { log.Printf("发送失败: %v", err) } else { fmt.Printf("[Producer] 发送: %-20s → MsgID: %v\n", msg, msgID) } } }
|
4.2 带 Key 的消息(Key_Shared 模式专用)
func produceMessagesWithKey(client pulsar.Client, topic string, messages []struct{ key, value string }) { producer, err := client.CreateProducer(pulsar.ProducerOptions{ Topic: topic, }) if err != nil { log.Fatalf("创建 Producer 失败: %v", err) } defer producer.Close()
for _, m := range messages { _, err := producer.Send(context.Background(), &pulsar.ProducerMessage{ Key: m.key, Payload: []byte(m.value), }) if err != nil { log.Printf("发送失败: %v", err) } else { fmt.Printf("[Producer] Key=%-10s Value=%s\n", m.key, m.value) } } }
|
五、四种订阅模式详解
5.1 Exclusive —— 独占订阅
核心特点: 同一订阅名在同一时刻只允许 1 个 Consumer 连接,第二个会被 Broker 直接拒绝。
Broker msg-1 ──────────────→ Consumer-1(唯一的 Active Consumer) msg-2 ──────────────→ Consumer-1 msg-3 ──────────────→ Consumer-1 ↑ Consumer-2 尝试连接 → 被 Broker 拒绝(ConsumerBusy)
|
消息顺序:✅ 严格全局有序
func demoExclusive() { client := newClient() defer client.Close()
topic := "persistent://public/default/demo-exclusive"
consumer, err := client.Subscribe(pulsar.ConsumerOptions{ Topic: topic, SubscriptionName: "exclusive-sub", Type: pulsar.Exclusive,
ReceiverQueueSize: 100, }) if err != nil { log.Fatalf("订阅失败: %v", err) } defer consumer.Close()
_, err2 := client.Subscribe(pulsar.ConsumerOptions{ Topic: topic, SubscriptionName: "exclusive-sub", Type: pulsar.Exclusive, }) if err2 != nil { fmt.Printf("[预期行为] 第二个 Consumer 被拒绝: %v\n", err2) }
go produceMessages(client, topic, []string{"msg-1", "msg-2", "msg-3"}) time.Sleep(500 * time.Millisecond)
for i := 0; i < 3; i++ { ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) msg, err := consumer.Receive(ctx) cancel() if err != nil { break }
fmt.Printf("[Consumer] 收到: %s\n", string(msg.Payload()))
time.Sleep(100 * time.Millisecond)
consumer.Ack(msg) fmt.Printf("[Consumer] Ack: %s\n", string(msg.Payload())) } }
|
适用场景:
| ✅ 适合 | ❌ 不适合 |
|---|
| 开发测试环境 | 生产高可用(宕机无法切换) |
| 严格单消费者 + 全局有序 | 需要水平扩展消费能力 |
| 业务逻辑简单,处理速度快 | 处理慢,容易积压 |
5.2 Failover —— 主备订阅
核心特点: 多个 Consumer 可以连接同一订阅,但同一时刻只有主(Active)Consumer 接收消息。主 Consumer 断开后,备(Standby)Consumer 自动升级为 Active,无缝接管。
正常状态: msg-1 ──→ Consumer-1(Active) msg-2 ──→ Consumer-1 Consumer-2(Standby,保持连接,等待接管)
Consumer-1 宕机: Broker 检测到心跳超时(约 30s,可配置) ↓ Consumer-2 自动升为 Active msg-3 ──→ Consumer-2(从上次 Cursor 位置继续,不丢消息)
|
主备选举规则: Pulsar 按 Consumer 连接顺序决定优先级,先连接的优先级高。
消息顺序:✅ 严格全局有序
func demoFailover() { client := newClient() defer client.Close()
topic := "persistent://public/default/demo-failover"
primaryConsumer, err := client.Subscribe(pulsar.ConsumerOptions{ Topic: topic, SubscriptionName: "failover-sub", Type: pulsar.Failover, }) if err != nil { log.Fatalf("主Consumer订阅失败: %v", err) } defer primaryConsumer.Close() fmt.Println("[主Consumer] Active,正在接收消息")
standbyConsumer, err := client.Subscribe(pulsar.ConsumerOptions{ Topic: topic, SubscriptionName: "failover-sub", Type: pulsar.Failover, }) if err != nil { log.Fatalf("备Consumer订阅失败: %v", err) } defer standbyConsumer.Close() fmt.Println("[备Consumer] Standby,等待主Consumer故障") fmt.Println("[说明] 主Consumer宕机 → 备Consumer自动接管 → 从Cursor断点继续 → 不丢消息")
go produceMessages(client, topic, []string{"msg-1", "msg-2", "msg-3"}) time.Sleep(500 * time.Millisecond)
for i := 0; i < 3; i++ { ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) msg, err := primaryConsumer.Receive(ctx) cancel() if err != nil { break } fmt.Printf("[主Consumer] 处理: %s\n", string(msg.Payload()))
primaryConsumer.Ack(msg) } }
|
与 Exclusive 的区别:
| 维度 | Exclusive | Failover |
|---|
| 第二个 Consumer | ❌ 被拒绝 | ✅ 可连接(Standby) |
| 故障切换 | ❌ 无法切换 | ✅ 自动切换(毫秒级) |
| 消费顺序 | ✅ 严格有序 | ✅ 严格有序 |
| 适合场景 | 简单单消费者 | 高可用 + 严格有序 |
5.3 Shared —— 共享订阅
核心特点: 多个 Consumer 同时连接,消息以 Round-Robin(轮询)方式分发,每条消息只投递给一个 Consumer。吞吐量最高,但不保证顺序。
Broker(9条消息,3个Consumer轮询): msg-1 ──→ Consumer-1(50ms 处理) msg-2 ──→ Consumer-2(100ms 处理) msg-3 ──→ Consumer-3(150ms 处理) msg-4 ──→ Consumer-1 msg-5 ──→ Consumer-2 ...
完成顺序(按处理速度): msg-1 ✅ (50ms) msg-4 ✅ (100ms) msg-2 ✅ (150ms) ← 乱序! ...
|
消息顺序:❌ 不保证
Individual Ack(Pulsar 核心优势):
Pulsar Cursor(位图): msg-1 ✅ msg-2 ❌ msg-3 ✅ msg-4 ✅ msg-5 ❌ ↑ ↑ 只重投这两条,其余不受影响
Kafka Offset(线性): offset-1 ✅ offset-2 ❌ offset-3 ✅ offset-4 ✅ ↑ 必须等这里提交,否则重启从 offset-2 开始重放 导致 offset-3、offset-4 被重复消费!
|
func demoShared() { client := newClient() defer client.Close()
topic := "persistent://public/default/demo-shared"
var wg sync.WaitGroup
for i := 1; i <= 3; i++ { wg.Add(1) consumerID := i
consumer, err := client.Subscribe(pulsar.ConsumerOptions{ Topic: topic, SubscriptionName: "shared-sub", Type: pulsar.Shared,
ReceiverQueueSize: 100, }) if err != nil { log.Printf("Consumer-%d 订阅失败: %v", consumerID, err) wg.Done() continue }
go func(c pulsar.Consumer, id int) { defer wg.Done() defer c.Close()
for { ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) msg, err := c.Receive(ctx) cancel() if err != nil { return }
processingTime := time.Duration(id*50) * time.Millisecond time.Sleep(processingTime)
fmt.Printf("[Consumer-%d] 收到: %-12s 耗时: %dms\n", id, string(msg.Payload()), processingTime.Milliseconds())
c.Ack(msg) } }(consumer, consumerID) }
time.Sleep(300 * time.Millisecond)
produceMessages(client, topic, []string{ "task-1", "task-2", "task-3", "task-4", "task-5", "task-6", "task-7", "task-8", "task-9", })
wg.Wait() }
|
适用场景:
- 日志收集、监控告警(量大、顺序无关)
- 任务队列:发邮件、发短信、图片压缩等幂等任务
- 需要弹性水平扩展的通用队列
⚠️ 注意: Shared 模式下任务必须幂等设计,因为 Consumer 宕机时未 Ack 的消息会重新投递,可能导致重复处理。
5.4 Key_Shared —— Key 路由订阅 ⭐ 生产最推荐
核心特点: 融合 Shared(高并发)和 Failover(有序)的优点。同一个 Key 的消息固定路由到同一个 Consumer(该 Key 下严格有序),不同 Key 的消息分发到不同 Consumer(并行处理)。
路由规则:ConsumerIndex = hash(MessageKey) % ActiveConsumerCount
Broker(3个Consumer,3个用户的订单事件): user-A/下单 ──→ Consumer-1 ┐ user-A/支付 ──→ Consumer-1 ├── user-A 严格有序:下单→支付→发货 user-A/发货 ──→ Consumer-1 ┘
user-B/下单 ──→ Consumer-2 ┐ user-B/支付 ──→ Consumer-2 ├── user-B 严格有序:下单→支付→发货 user-B/发货 ──→ Consumer-2 ┘
user-C/下单 ──→ Consumer-3 ┐ user-C/支付 ──→ Consumer-3 ├── user-C 严格有序:下单→支付→发货 user-C/发货 ──→ Consumer-3 ┘
三个用户并行处理,互不等待 → 高吞吐 + 有序
|
func demoKeyShared() { client := newClient() defer client.Close()
topic := "persistent://public/default/demo-key-shared"
var wg sync.WaitGroup
for i := 0; i < 3; i++ { wg.Add(1) consumerID := i + 1
consumer, err := client.Subscribe(pulsar.ConsumerOptions{ Topic: topic, SubscriptionName: "key-shared-sub", Type: pulsar.KeyShared,
}) if err != nil { log.Printf("Consumer-%d 订阅失败: %v", consumerID, err) wg.Done() continue }
go func(c pulsar.Consumer, id int) { defer wg.Done() defer c.Close()
for { ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) msg, err := c.Receive(ctx) cancel() if err != nil { return }
fmt.Printf("[Consumer-%d] Key=%-10s Value=%-6s ← 同Key消息在此Consumer内严格有序\n", id, msg.Key(), string(msg.Payload()))
time.Sleep(50 * time.Millisecond)
c.Ack(msg) } }(consumer, consumerID) }
time.Sleep(300 * time.Millisecond)
produceMessagesWithKey(client, topic, []struct{ key, value string }{ {"user-A", "下单"}, {"user-B", "下单"}, {"user-A", "支付"}, {"user-C", "下单"}, {"user-B", "支付"}, {"user-A", "发货"}, {"user-C", "支付"}, {"user-B", "发货"}, {"user-C", "发货"}, })
wg.Wait() }
|
与 Kafka 分区方案的对比:
| 维度 | Kafka 分区 | Pulsar Key_Shared |
|---|
| 同 Key 有序 | ✅ | ✅ |
| 并行消费 | ✅(受分区数限制) | ✅(不受限) |
| 扩容方式 | 增加分区 → Rebalance(有停顿) | 增加 Consumer → 自动重路由(无停顿) |
| Consumer 数 > 分区数 | ❌ 有 Consumer 空闲 | ✅ 所有 Consumer 均可工作 |
六、Ack 机制深度解析
6.1 三种 Ack 操作
consumer.Ack(msg)
consumer.NegativeAcknowledge(msg)
|
6.2 Ack 的黄金法则
❌ 错误顺序(先 Ack 再处理): Receive → Ack → 业务逻辑失败 → 消息永久丢失(无法找回)
✅ 正确顺序(先处理再 Ack): Receive → 业务逻辑 → 成功 → Ack → 失败 → NegativeAcknowledge(触发重投)
|
6.3 Pulsar Cursor vs Kafka Offset
Kafka(线性 Offset): [✅1][✅2][❌3][✅4][✅5] ↑ Offset 卡在 3,重启后从 3 开始重放 4 和 5 被重复消费 → At-Least-Once 在这里很痛
Pulsar(位图 Cursor): [✅1][✅2][❌3][✅4][✅5] ↑ 只记录 3 未确认,重启后只重投 3 4 和 5 不受影响 → 更精准的 At-Least-Once
|
七、异步消费:解决业务阻塞问题
7.1 问题:同步消费的吞吐量瓶颈
同步消费(单线程):
Consumer 线程 ├─ Receive msg-1 ├─ 业务逻辑(写 DB,耗时 2s)← 整个消费循环阻塞 2s ├─ Ack msg-1 ├─ Receive msg-2 ├─ 业务逻辑(2s) ├─ Ack msg-2 ...
5 条消息串行处理:总耗时 = 5 × 2s = 10s
|
7.2 解决方案:Goroutine 池异步消费
func demoAsyncConsume() { client := newClient() defer client.Close()
consumer, _ := client.Subscribe(pulsar.ConsumerOptions{ Topic: "persistent://public/default/demo-async", SubscriptionName: "async-sub", Type: pulsar.Shared,
ReceiverQueueSize: 1000, }) defer consumer.Close()
sem := make(chan struct{}, 10)
go produceMessages(client, consumer.Topic(), []string{ "task-1", "task-2", "task-3", "task-4", "task-5", }) time.Sleep(300 * time.Millisecond)
startTime := time.Now() var wg sync.WaitGroup
for i := 0; i < 5; i++ { ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) msg, err := consumer.Receive(ctx) cancel() if err != nil { break }
wg.Add(1) sem <- struct{}{}
go func(m pulsar.Message) { defer wg.Done() defer func() { <-sem }()
fmt.Printf("[Goroutine] 开始处理: %s (t=%s)\n", string(m.Payload()), time.Since(startTime).Round(time.Millisecond))
time.Sleep(2 * time.Second)
consumer.Ack(m)
fmt.Printf("[Goroutine] Ack 完成: %s (t=%s)\n", string(m.Payload()), time.Since(startTime).Round(time.Millisecond)) }(msg) }
wg.Wait() fmt.Printf("全部完成,总耗时: %s(同步需 ~10s)\n", time.Since(startTime).Round(time.Millisecond)) }
|
效果对比(5条消息,每条业务耗时 2 秒):
同步消费:msg-1(2s) → msg-2(2s) → msg-3(2s) → msg-4(2s) → msg-5(2s) = 10s
异步消费: t=0ms → 取 msg-1,启动 Goroutine-1 t=1ms → 取 msg-2,启动 Goroutine-2 t=2ms → 取 msg-3,启动 Goroutine-3 t=3ms → 取 msg-4,启动 Goroutine-4 t=4ms → 取 msg-5,启动 Goroutine-5 t=2004ms → 所有 Goroutine 完成 ≈ 2s ✅
|
八、各模式横向对比
| 维度 | Exclusive | Failover | Shared | Key_Shared |
|---|
| 并发 Consumer 数 | 1 | 多(主+备) | 多 | 多 |
| 全局消息顺序 | ✅ | ✅ | ❌ | ❌ |
| Key 内消息顺序 | ✅ | ✅ | ❌ | ✅ |
| 自动故障转移 | ❌ | ✅ | ✅ | ✅ |
| 水平扩展 | ❌ | ❌ | ✅ | ✅ |
| 吞吐量 | 低 | 低 | 最高 | 高 |
| 典型场景 | 测试/简单消费 | 金融账务 | 日志/任务队列 | 订单/用户流 |
九、选型决策树
需要消费者高可用(故障自动切换)? ├─ 否 → Exclusive(简单场景/测试) └─ 是 └─ 需要严格全局顺序? ├─ 是 → Failover(金融账务、配置变更) └─ 否 └─ 需要 per-Key 顺序(同一实体的消息有序)? ├─ 是 → Key_Shared ⭐(订单、用户行为、IoT 设备) └─ 否 → Shared(日志、告警、幂等任务队列)
|
十、生产环境配置建议
Consumer 关键参数
consumer, _ := client.Subscribe(pulsar.ConsumerOptions{ Topic: "persistent://tenant/ns/topic", SubscriptionName: "prod-sub", Type: pulsar.KeyShared,
ReceiverQueueSize: 1000,
AckTimeout: 30 * time.Second, AckTimeoutTimer: 1 * time.Second,
NackRedeliveryDelay: 5 * time.Second,
DLQ: &pulsar.DLQPolicy{ MaxDeliveries: 3, DeadLetterTopic: "my-topic-DLQ", }, })
|
Producer 关键参数
producer, _ := client.CreateProducer(pulsar.ProducerOptions{ Topic: "persistent://tenant/ns/topic", CompressionType: pulsar.LZ4, BatchingMaxPublishDelay: 10 * time.Millisecond,
})
|
总结
Pulsar 订阅模式的设计哲学是:把”顺序”和”吞吐”的权衡暴露给开发者,而不是在系统内部做妥协。
- 需要有序:Exclusive 或 Failover,系统保证全局顺序
- 需要吞吐:Shared,牺牲顺序换取最大并发
- 两者都要:Key_Shared,在 Key 粒度上有序,跨 Key 并行
消费模型上,同步消费适合入门,异步消费(Goroutine 池)才是生产标准。Ack 必须在业务逻辑成功后发出,这是 At-Least-Once 语义的基石。
参考资料