Pulsar
收录于 工程治理体系
从业务问题理解 Pulsar 消费模式:它和 Kafka key 模型到底差在哪
Pulsar 的 Exclusive、Failover、Shared、Key_Shared 不是四个档位,而是四种不同的业务处理策略。更该区分的,是 Kafka key 主要在决定分区,Pulsar orderingKey 还在决定同一实体由谁消费。
设计Puslar消息系统时,团队经常上来就先看 API:Exclusive、Failover、Shared、Key_Shared,然后问一句哪个更高级。这个问法不算错,但一般不够值钱。更靠前的问题是:业务到底在怕什么。
有的场景怕并发竞争,有的怕实例挂掉以后没人接手,有的怕任务摊不出去,有的怕同一实体的状态流被打乱。Pulsar 会把订阅模式拆成四种,说到底也是因为这四类问题不是一回事。
我把它们理解成四种业务处理策略:
Exclusive:强单执行Failover:主备接管Shared:工作队列Key_Shared:同实体串行,不同实体并行
这样看,比背定义更接近工程现场。
别把四种模式当成同一类问题
这四种模式表面上都在回答“消息怎么投给 consumer”,但它们保护的业务目标不一样。
Exclusive 保护的是只有一个执行者。
Failover 保护的是挂了以后有人接。
Shared 保护的是任务能尽快摊开。
Key_Shared 保护的是同一个实体别乱序,但不同实体还能并行。
落到业务上反而更容易判断:我到底是在做单执行、接管、工作队列,还是状态流。
Exclusive:业务本来就不该并行
Exclusive 最重要的价值,不是它简单,而是它干净。
它适合的场景是“根本不应该并行”的系统。像全局状态推进器、leader 型控制任务、串行补偿流程、只能由一个实例推进的业务游标,重点在于不要出现并发竞争、重复推进和状态撕裂,吞吐反而靠后。
这类场景里,把“只能有一个执行者”这件事直接交给消息系统表达,比在应用层自己补锁、补选主、补兜底要直接得多。
最小配置一般就是这样:
Consumer<byte[]> consumer = client.newConsumer()
.topic("controller-events")
.subscriptionName("controller-sub")
.subscriptionType(SubscriptionType.Exclusive)
.subscribe();
这里重点看的是这条链路在系统里已经被声明成了强单执行。只要业务语义就是这样,Exclusive 多数时候就是最稳的做法。
Failover:重点不是多实例,而是接管语义
Failover 容易被误解成“比 Exclusive 高一级,因为它能挂多个 consumer”。这还是把重点放偏了。
它解决的就是主备和接管。
同一时刻只有一个 active consumer 在处理,其他实例是 standby。active 挂掉以后,standby 接上。对很多业务来说,这比“能不能同时跑多个 consumer”更重要,因为它要的是有人接手,而且接手的时候别把原来的顺序搞乱。
如果你有 Kafka 背景,这一层会比较容易建立直觉。Kafka 的 consumer group 也是按 partition 独占来分工,实例挂掉以后再重分配。两边的实现方式不一样,但心智很接近:处理权是独占的,接管是允许的。
Consumer<byte[]> consumer = client.newConsumer()
.topic("payment-events")
.subscriptionName("payment-sub")
.subscriptionType(SubscriptionType.Failover)
.subscribe();
Failover 特别适合这几类业务:
- 要主备接管
- 要保 partition 级顺序
- 希望团队迁移成本尽量接近 Kafka 的 partition 心智
- 愿意继续让 partition 承担主要并行单位
如果你的第一优先级是“挂了能有人接,接的时候别把本来的处理顺序弄乱”,它通常就是最自然的选择。
Shared:它更像工作队列
Shared 更适合任务池型的场景。
它有价值的地方在于消息可以比较自然地摊给一组 worker。谁空谁拿,先把活分出去。对发邮件、图片处理、普通异步任务、推送下发、可重试后台 job 这类业务,这种语义就很顺。
因为这类场景更关心的是:
- 活能不能尽快分出去
- 谁来处理一般不重要
- 单个实体的顺序不是第一优先级
Consumer<byte[]> consumer = client.newConsumer()
.topic("jobs")
.subscriptionName("jobs-sub")
.subscriptionType(SubscriptionType.Shared)
.subscribe();
如果业务的核心目标就是“把这批任务尽快摊开”,Shared 一般会比 Key_Shared 更合适。因为它没有“同一个实体必须留在一个 consumer 上”的额外约束。
Key_Shared:状态流和任务队列不是一回事
Key_Shared 处理的是另一类高频场景:同一个用户、订单、会话、设备、租户的事件流。
这些业务的共同点在于:只要求同一个实体的顺序,同时又不想因为这点顺序要求,把整个系统都锁回单线程。你要的是:
同一个实体串行,不同实体并行。
这正是 Key_Shared 的价值。
Consumer<byte[]> consumer = client.newConsumer()
.topic("order-events")
.subscriptionName("order-sub")
.subscriptionType(SubscriptionType.Key_Shared)
.subscribe();
如果从业务建模上看,Key_Shared 更像是在说:并行边界不再只靠 partition 决定,还可以落到实体这一层。
这也是它和 Shared 的根本差别。Shared 关心的是任务能不能摊开;Key_Shared 关心的是摊开的同时,某个实体的处理权别被打散。
Kafka 的 key 和 Pulsar 的 key,不是同一回事
需要单独拎出来的一点,就是两边的 key 模型不要混着理解。
在 Kafka 里,key 默认首先影响的是路由。更贴切一点说,是它先决定消息进哪个 partition。后面再由 consumer group 的 partition assignment 去决定哪个 consumer 处理这个 partition。
一句话概括就是:
key -> partition -> consumer
所以 Kafka key 更像一个分区键。它当然也会间接影响处理顺序和消费归属,但那是通过 partition 这一层间接成立的。只要 partition ownership 变了,同一个 key 后面跟着换 consumer 也很正常。
两边确实不一样。
Pulsar 把这件事拆得更开。
它有 key(),也有 orderingKey()。前者主要是路由语义,后者是 Key_Shared 下的分发语义。如果没有显式设置 orderingKey,才会回退到普通 key。
所以在 Pulsar 里,更接近真实情况的理解是:
key:消息怎么路由orderingKey:同一个实体的消息怎么分给 consumer
我更愿意把它翻译成:
- Kafka key 更像分区键
- Pulsar orderingKey 更像实体处理键
这不是概念游戏,它会直接影响系统设计。
如果你在 Kafka 里要更多消费并行,通常会很早开始想 partition 数量,因为 partition 数基本决定了 group 的并行上限。
如果你在 Pulsar Key_Shared 里做实体流,后面更敏感的往往不在 partition 数量上,而在 key 分布均不均、热点会不会集中到少数实体、某个 hot key 会不会把单个 consumer 长时间压住。
最后一种模式遇到突发流量,真正该怎么处理
用户、订单、设备这些实体流一旦碰到突发流量,Key_Shared 最容易让人误判。第一反应多半是再加几个 consumer,看能不能把流量摊开。
听着挺对。
这招只对一种情况有用:流量是很多 key 一起涨,而且分布还算均匀。
如果问题是单个 hot key 突然打高,比如某个租户、大促订单流、某个超级设备、某个异常 session 一下子冲上来,那多加 consumer 通常拆不开。因为 Key_Shared 的前提就是同一个 key 同一时刻只给一个 consumer 处理。
所以最后一种模式碰到突发流量时,思路不能只停在“横向扩容”。更常见的是下面三种解法,而且它们解决的不是同一层问题。
第一种,改串行边界,也就是改 key。
如果业务允许把原来那条串行边界拆细,就把 tenantId 拆成 tenantId + shopId,把 deviceId 拆成 deviceId + channel,或者把同一订单里的不同阶段拆成更细的处理键。这样做的本质,是承认原来的 key 太粗,把本来可以并行的东西硬压在了一条线上。
这招只有在业务真的允许放宽顺序边界时才能用,不能为了吃峰值把语义改坏。
第二种,把热点实体单独剥离。
如果顺序边界不能改,那更稳的做法通常是把 hot key 或 hot tenant 从共享承载面里单独拿出来,给它独立 topic、独立 subscription,甚至独立 consumer 池和限流阈值。这样你至少不会让一个热点实体长期挤占普通实体的处理权。
对 ToB 场景来说,这比“继续往共享池里塞更多副本”更现实,因为热点一般就是某个你本来就能识别的重点租户、重点设备或重点订单流。
第三种,在入口先削峰,再把主链路当成状态流来处理。
如果这波流量本来就是短时突发,而且你又必须保同 key 顺序,那主链路通常不该直接硬吃全部瞬时峰值。更常见的做法,是在上游先限流、缓冲、排队,或者先把请求写进一个更便宜的缓冲层,再按主链路能承受的速度回灌到 Key_Shared 订阅里。
这时 Key_Shared 的职责就回到了它最擅长的事:在有顺序约束的前提下,把状态流稳定处理完。
所以 Key_Shared 面对突发流量时,先要问清楚几件事:
- 这是很多 key 一起涨,还是单个 hot key 在打峰值
- 业务顺序边界能不能拆细
- 这个热点实体值不值得单独保护
- 主链路到底该吃实时峰值,还是该只负责有序处理
先看价值,再看边界
Kafka 和 Pulsar 这两套模型都不是没有边界,只是边界落点不一样。
Kafka key 的边界比较清楚:
- 它主要提供 partition-locality,不直接提供 per-key 的 consumer 粘性
- 同一个 key 是否稳定落到同一个 partition,还会受 partitioner 配置影响
- 真正决定消费归属的,还是 partition assignment
Pulsar Key_Shared 的边界也很明确:
- 它保证的是同 key 局部顺序,不是全局顺序
- 它要的是 per-key 级别的消费粘性,不是无限制并行
- batching、ack 方式、negative ack、hot key 分布,都会直接影响它的实际表现
所以如果你要的是全局单流顺序,Key_Shared 不是那个语义。
如果你要的是同一个实体别乱序,但别的实体继续并行,它就很有价值。
如果你要的是工作队列式吞吐,Shared 往往更合适。
如果你要的是主备接管,那还是回到 Failover。
如果业务压根不该并行,就别绕,直接 Exclusive。
回到选型,先把业务怕什么说清楚
如果把文章最后收回业务,我现在更认可的顺序很朴素。
把这套判断路径画出来大概是这样:
flowchart TD
S["业务到底在怕什么?"] --> Q1{"业务该不该并行?"}
Q1 -- "不该并行" --> EX["Exclusive · 强单执行"]
Q1 -- "可以并行" --> Q2{"需要实体级顺序吗?"}
Q2 -- "不需要,谁处理都行" --> SH["Shared · 工作队列"]
Q2 -- "需要" --> Q3{"顺序边界在哪一层?"}
Q3 -- "partition 级 + 要主备接管" --> FO["Failover · 主备接管"]
Q3 -- "实体级 + 不同实体要并行" --> KS["Key_Shared · 同实体串行"]
业务本来就不该并行,比如 leader 任务、全局游标推进、串行补偿,选 Exclusive。
业务重点是接管和顺序,挂了要有人稳稳接上,选 Failover。
业务本质上是任务池,谁处理都行,重点是尽快摊开,选 Shared。
业务是状态流,要同实体串行、不同实体并行,选 Key_Shared。
但选 Key_Shared 时,也别把它想成“既保序又能无限横向扩容”的免费午餐。它能把并行边界从 partition 往实体这一层推进一步,但代价也很具体:顺序边界要想清,ack 约束要接受,hot key 要自己治理,突发流量也不能指望靠多加几个 consumer 自动摊平。
所以我更愿意把这篇文章最后留下的判断写成这一句:
Kafka 的 key 主要解决的是分区;Pulsar Key_Shared 解决的是实体处理权。
这两件事很接近,但不是一回事。把这层分清,再回头看四种消费模式,很多选型问题就不会再混在一起了。