43.1 消息模型 (点对点 / 发布订阅)

一句话

点对点是“一条消息只交给一个消费者”(队列语义),发布订阅是“一条消息给每个订阅者各一份”(广播语义);选型的分水岭是这条消息该被处理一次,还是被所有关心它的人各看一次——成本、重复与顺序问题都由这个选择决定。

两种模型,一张图

点对点(Queue / 竞争消费)              发布订阅(Topic / 广播)
producer ──► [ 队列 ] ──► consumer A    producer ──► [ Topic ] ──┬──► subscriber 1(各一份)
                        └► consumer B                            ├──► subscriber 2(各一份)
                        └► consumer C                            └──► subscriber 3(各一份)
维度 点对点 发布订阅
一条消息投几次 1 次 订阅者数量次
消费语义 竞争消费(load balance) 广播(每个订阅者都收到全量)
扩消费者的效果 提高吞吐(分摊) 每个新订阅者都增加一份投递与存储
典型场景 任务队列、削峰、订单处理 事件通知、缓存失效、多下游数据同步
消息保留 消费后删除(或按时间) 按订阅位点/保留期,各订阅者独立进度

Kafka 同时支持两种:同一个 Topic 内,不同消费组(consumer group)之间是发布订阅,组内的消费者之间是点对点。这不是“两种产品”,而是用消费组这个维度把两种语义组合起来——这是它比传统队列灵活的地方。

数字一:放大倍数与存储

100 万条消息、3 个消费者、5 个订阅者,每条 1 KiB:

模型 投递次数 存储(7 天)
点对点 100 万 1.0 GB/天 → 7 GB
发布订阅 500 万(5 倍) 5.1 GB/天 → 36 GB

🔑 发布订阅的成本是订阅者数量 × 消息量。加一个订阅者不是“多一个消费者”,而是“多一份全量流量与存储”。所以“把所有事件都广播出去”很容易在半年后变成账单事故。

如果下游只是“关心其中一部分消息”,正确做法不是多一个订阅者,而是:

做法 效果
按 topic 拆分(订单/支付/库存各一个) 每个订阅者只收自己关心的
服务端过滤(按 tag/header) 网络与存储只传匹配的
消费后转发(一个订阅者扇出到多个内部模块) 跨订阅者的扇出只发生一次

数字二:消费能力不足会怎样

生产 5000 条/秒,3 个消费者各 1000 条/秒:

消费能力 = 3 × 1000 = 3000/s
积压速度 = 5000 − 3000 = 2000/s
10 万条队列:50 秒填满

填满之后只有三条路:丢消息(最坏)、生产端阻塞/背压(保护系统但拖慢上游)、扩容消费者(受分区数限制)。这就是 43.5 积压与背压(43.5)的核心:队列不是无限缓冲,它是“有界的延迟”。

现象 含义 动作
消费延迟稳定在几百 ms 正常波动 观察
延迟持续线性上涨 消费能力 < 生产 加消费者/加分区/优化消费逻辑
队列长度周期性归零又涨 批次/定时任务 削峰或调批大小
重平衡频繁 消费者抖动 调心跳与超时(见 43.4 消费组与再均衡)

数字三:至少一次语义下的重复

消费者每 100 条 ack 一次,处理到第 500,050 条时宕机——本批已处理 50 条但还没 ack,重启后这 50 条会被重投:

指标 数值
投递次数 1,000,050
处理的唯一消息 1,000,000
重复 50(0.005%)
重复的绝对量(1 亿条/天) 5,000 条/天

0.005% 看起来很小,但在 1 亿条/天的规模下就是每天 5000 条重复。“精确一次”不是 broker 的承诺,而是消费端用去重键换来的(见 25.4 幂等设计与去重)。broker 能保证的是:

语义 broker 保证 代价
at-most-once 最多一次,可能丢 先 ack 再处理,最快
at-least-once 至少一次,可能重复 处理完再 ack,最常用
exactly-once(broker 内) 事务 + 幂等生产 + 位点原子提交 性能下降、跨系统不成立

跨系统(MQ → 数据库、MQ → 另一个 MQ)时,“精确一次”必须由业务侧的去重/事务来保证。

拉还是推

模式 做法 优点 缺点
推(push) broker 主动发给消费者 延迟低 消费者能力弱时被打爆,需要背压
拉(pull) 消费者按需拉一批 消费者控制节奏,天然背压 空轮询浪费,需要长轮询/批量
长轮询 拉不到就挂起,有数据立刻返回 兼顾延迟与背压 连接数与超时管理

Kafka 是拉模型(靠 fetch.min.bytes + 长轮询降延迟),RabbitMQ 推模型(靠 prefetch limit 做背压)。选择的关键不是“谁更快”,而是谁来决定消费速率:消费者自己决定(拉)还是 broker 决定(推)。

顺序:只在分区内成立

同一分区(partition)内:严格有序
跨分区:无序

要保序,必须让“需要保序的消息”落在同一分区:用业务键(订单 ID、用户 ID)做分区键。代价是热点:大客户的订单全落在同一分区,吞吐受单分区上限约束(见 43.3 顺序与分区)。

需求 做法
同一订单的事件有序 用订单 ID 做分区键
全局严格有序 单分区(吞吐上限 = 单消费者能力)
只需“同一实体有序” 实体 ID 做 key,接受跨实体乱序
乱序也能处理 用版本号/时间戳做幂等与覆盖

可运行的投递账本

package main

import "fmt"

func main() {
	const messages = 1_000_000
	const subscribers = 5
	const consumers = 3

	// ① 点对点 vs 发布订阅:投递次数
	p2p := messages
	pubsub := messages * subscribers
	fmt.Printf("① %d 条消息,%d 个消费者,%d 个订阅者\n", messages, consumers, subscribers)
	fmt.Printf("   点对点:%d 次投递(每条只给一个消费者)\n", p2p)
	fmt.Printf("   发布订阅:%d 次投递(每个订阅者各一份,放大 %.0f 倍)\n", pubsub, float64(pubsub)/float64(p2p))

	// ② 存储:1 KiB/条,保留 7 天
	const kib = 1024
	fmt.Printf("② 存储(1 KiB/条):点对点 %.1f GB/天,发布订阅 %.1f GB/天(7 天:%.0f vs %.0f GB)\n",
		float64(messages*kib)/1e9, float64(pubsub*kib)/1e9,
		float64(messages*kib*7)/1e9, float64(pubsub*kib*7)/1e9)

	// ③ 消费能力与积压:生产 5000/s,每消费者 1000/s
	produce, perConsumer := 5000.0, 1000.0
	capacity := perConsumer * consumers
	backlogPerSec := produce - capacity
	fmt.Printf("③ 生产 %.0f/s,%d 个消费者 × %.0f/s = %.0f/s → 每秒积压 %.0f 条\n",
		produce, consumers, perConsumer, capacity, backlogPerSec)
	if backlogPerSec > 0 {
		limit := 100_000.0
		fmt.Printf("   10 万条队列 %.0f 秒填满;之后要么丢消息,要么生产端阻塞(背压)\n", limit/backlogPerSec)
	}

	// ④ at-least-once:消费者每 100 条 ack 一次,崩溃时未 ack 的那一批要重投
	const batch = 100
	crashAt := 500_050 // 处理到第 500,050 条时宕机:本批已处理 50 条,还没到 100 条一次的 ack
	deliveries := messages
	duplicates := crashAt % batch
	deliveries += duplicates
	fmt.Printf("④ 至少一次语义:%d 次投递处理 %d 条消息,崩溃重投 %d 条(重复率 %.3f%%)\n",
		deliveries, messages, duplicates, float64(duplicates)/float64(messages)*100)
	fmt.Println("   精确一次(幂等消费)不是 broker 的承诺,是消费端用去重键换来的")
}

输出:

① 1000000 条消息,3 个消费者,5 个订阅者
   点对点:1000000 次投递(每条只给一个消费者)
   发布订阅:5000000 次投递(每个订阅者各一份,放大 5 倍)
② 存储(1 KiB/条):点对点 1.0 GB/天,发布订阅 5.1 GB/天(7 天:7 vs 36 GB)
③ 生产 5000/s,3 个消费者 × 1000/s = 3000/s → 每秒积压 2000 条
   10 万条队列 50 秒填满;之后要么丢消息,要么生产端阻塞(背压)
④ 至少一次语义:1000050 次投递处理 1000000 条消息,崩溃重投 50 条(重复率 0.005%)
   精确一次(幂等消费)不是 broker 的承诺,是消费端用去重键换来的

参数怎么给

你的场景 模型 关键参数
任务队列/削峰 点对点 分区数 ≥ 消费者数;可见性超时 > 处理时间
事件广播(缓存失效、配置变更) 发布订阅 订阅者数量受控;消息带版本号,消费端幂等
多下游数据同步 发布订阅 + 服务端过滤 按 tag 过滤,避免全量复制
同一实体的状态变更 点对点 + 分区键 分区键 = 实体 ID,保序
跨系统通知 点对点 + 重试 + 死信 至少一次 + 幂等消费 + 死信队列
大促削峰 点对点 + 有界队列 队列长度上限 + 生产端背压

把语义落到 Kafka 参数:producer / consumer 两份

下面两份用 Kafka 4.3 单节点跑通。注意 4.x 的脚本去掉了 .sh 后缀,控制台工具改用 --command-config(旧的 --producer.config / --producer-property 已弃用)。

# producer.properties
bootstrap.servers=10.0.1.21:9092,10.0.1.22:9092,10.0.1.23:9092
acks=all
enable.idempotence=true
max.in.flight.requests.per.connection=5
retries=2147483647
delivery.timeout.ms=120000
linger.ms=5
batch.size=32768
compression.type=lz4
# consumer.properties
bootstrap.servers=10.0.1.21:9092,10.0.1.22:9092,10.0.1.23:9092
group.id=order-consumer
enable.auto.commit=false
isolation.level=read_committed
max.poll.records=500
参数 值 对应哪个语义 配错的后果
acks=all 所有 ISR 确认 至少一次、不丢 acks=1:leader 挂了就丢;acks=0:发出去就不管
enable.idempotence=true 开 生产端重试不写重复 重试产生重复,消费端被迫全量幂等
max.in.flight.requests.per.connection 5 幂等下仍保序的上限 调大 + 非幂等 = 重试乱序
delivery.timeout.ms 120s 重试总预算,要大于 broker 的请求超时 太小:高峰期大量失败;太大:故障时长时间挂住
linger.ms + batch.size 5ms / 32KB 吞吐与延迟的折中 0ms 小批:吞吐低;太大:端到端延迟上升
compression.type lz4 带宽与 CPU 的折中 不压缩:跨 AZ 流量成本高
enable.auto.commit false 至少一次(处理完再提交) 自动提交 = 先提交后处理,崩了就丢
isolation.level read_committed 事务消息不读未提交 read_uncommitted 会读到回滚的事务
max.poll.records 500 单次处理量 × 耗时 < max.poll.interval.ms 处理超时触发 rebalance,重复消费

broker / 主题级还要配两条,否则上面的 acks=all 会被架空:min.insync.replicas=2(三副本集群才有意义,单节点验证不了)、unclean.leader.election.enable=false(宁可不可用,也不让落后副本当 leader)。acks=all 只在「ISR 数量 ≥ min.insync.replicas」时才真的等多数派。

# 主题的 ISR 与 min.insync
kafka-topics --bootstrap-server 10.0.1.21:9092 --describe --topic orders

# 生产 + 消费各跑一次(配置随 --command-config 传入)
printf 'k-1\n' | kafka-console-producer --bootstrap-server 10.0.1.21:9092 \
  --command-config producer.properties --topic orders
kafka-console-consumer --bootstrap-server 10.0.1.21:9092 \
  --command-config consumer.properties --group order-consumer --topic orders \
  --from-beginning --max-messages 1

# 积压:LAG 持续上涨 = 消费能力不足,不是 broker 问题
kafka-consumer-groups --bootstrap-server 10.0.1.21:9092 --describe --group order-consumer

⚠️ 不要指望 enable.idempotence 给你端到端“恰好一次”:它只覆盖「生产端重试」这一小段,限单分区、单生产者会话。消费端重启、跨系统写库、跨 Topic 转发都会重新引入重复——幂等仍要业务自己做。

反模式

反模式 后果
给每个下游都开一个订阅者收全量 流量与存储按订阅者数量线性放大
把点对点当广播用(多个 consumer 各收一份) 同一任务被重复执行
把广播当点对点用(只起一个消费者) 订阅者之间抢消息,各自只看到一部分
用消息队列当数据库查历史 保留期一过就查不到;查询能力极弱
消费能力不足却只调大队列 积压变成延迟,最终丢消息
跨分区还假设全局有序 乱序导致状态错乱
依赖 broker 的 exactly-once 跨系统 数据库与 MQ 不在一个事务里,仍会重复
每条消息单独 ack ack 开销吃掉吞吐,批量 ack 更常见
没有死信队列 毒消息无限重投,堵住整个分区

自检

Q1 100 万条消息、5 个订阅者,发布订阅的投递次数是 500 万。如果这 5 个订阅者里有 3 个其实只关心 10% 的消息,成本能怎么降?

▸ 答案:不要用“5 个订阅者收全量”,改成服务端过滤或拆分 topic:① 按业务拆 topic(订单/支付/库存),每个订阅者只订阅自己关心的;② 用 tag/header 在 broker 侧过滤,只投递匹配的消息,网络与存储成本按 10% 计;③ 只保留 1 个订阅者做“扇出适配器”,它在内部把消息分发给多个模块——跨订阅者的复制只发生一次。三种手段都在降低“每人一份全量”的放大倍数。

Q2 生产 5000/s、3 个消费者各 1000/s,队列 10 万条。50 秒后会发生什么?为什么“把队列调大”不是解决方案?

▸ 答案:50 秒后队列满,之后只有三种结果:丢弃(丢消息)、阻塞生产端(背压,上游变慢)、或消费端扩容。把队列调大只是把“50 秒”变成“500 秒”,积压仍在增长,最终仍会满——而且更晚发现、故障更大。根因是消费能力 3000 < 生产 5000,要么加消费者/分区,要么优化消费逻辑(批量、异步、减少下游调用),要么在入口限流。队列是缓冲,不是产能。

Q3 0.005% 的重复率看起来可以忽略,为什么还必须做幂等?

▸ 答案:因为重复的绝对量随规模放大:1 亿条/天 × 0.005% = 5000 条/天;如果这些是支付、发券、扣库存,每天就有 5000 次资损或错账。而且重复不是均匀分布的——崩溃、重平衡、网络抖动会让重复集中在某一批消息上(比如一次重平衡重投几万条)。幂等的成本(一个去重键 + 唯一约束)远低于对账与赔付的成本。

Q4 为什么 Kafka 能在同一个 Topic 上同时提供“点对点”和“发布订阅”?

▸ 答案:因为它把“消费进度”放在消费组这一层:同组内的消费者竞争同一批分区(点对点,扩容即分摊);不同消费组各自维护位点、各收全量(发布订阅)。所以两种语义不是二选一的产品形态,而是“组的数量”决定的配置。代价是:每个消费组都要独立保存位点、独立承受全量流量——订阅者越多,broker 的出口带宽与存储压力越大。

Q5 拉模型和推模型,哪个更容易做背压?为什么?

▸ 答案:拉模型天然有背压——消费者拉多少由自己决定,处理不过来就不拉(或降低 max.poll.records),broker 不会硬塞。推模型里 broker 掌握节奏,必须靠 prefetch limit、窗口、消费者主动 nack/pause 来限速,配置不当就会把消费者打爆(内存里堆满未处理消息)。所以高吞吐、消费者异构的场景多用拉(Kafka),低延迟、消费者能力可预期的场景多用推(RabbitMQ)。

Q6 acks=all + enable.idempotence=true,消息就不会重复了吗?

▸ 答案:只保证“生产端重试不会在 broker 写出重复”,且限单分区、单生产者会话。消费端重启后的重复投递、跨 Topic 转发、写库与发消息不在一个事务里,都会重新引入重复——所以消费端必须幂等(去重表/唯一键/版本号)。另外 acks=all 要配 min.insync.replicas≥2 才真的等多数派,否则等于只等了一个副本。

关联