43.2 投递语义 (at-most / at-least / exactly-once)

一句话

投递语义不是一个开关,而是三段能力的组合:生产端幂等(重试不产生重复)、存储端去重/事务(重复消息只落一份)、消费端位点与输出原子(处理与提交要么都成、要么都不成)——缺任何一段,端到端就不可能是 exactly-once。

三段拆开看

段 失败模式 要什么能力 没有它会怎样
生产端 网络超时后重试 幂等生产(producer id + 序列号) 同一条消息被写入两次
存储端 重试/重放 事务标记 + 去重(按 producer 序列号) 重复进入日志,消费端看到两条
消费端 处理完、提交前崩溃 位点与输出原子提交(事务或幂等写) 重投导致重复处理,或提前提交导致丢

🔑 43.1 已经从“重复量”角度算过 at-least-once 的代价(43.1 消息模型 (点对点 / 发布订阅));本节把它拆成三段,穷举开关组合,看每种语义到底丢在哪、重在哪。

代码:三个开关,八种组合

package main

import "fmt"

const (
	nMsgs     = 1000 // 业务上应该被处理的消息数(唯一)
	retryRate = 0.20 // 生产端需要重试的比例
	ackBatch  = 10   // 每 10 条提交一次位点
)

// run 返回“实际处理次数”与“其中重复的次数”
// p=生产幂等  b=存储去重  c=消费端原子提交
func run(p, b, c bool) (processed, dup int) {
	// ① 生产端:不幂等时,重试会让 broker 多落 200 份
	total := nMsgs
	if !p {
		total = nMsgs + int(float64(nMsgs)*retryRate)
	}
	// ② 存储端:按 (producer, seq) 去重,把多出来的份数收回去
	if b {
		total = nMsgs
	}

	// ③ 消费端:处理完一批、提交位点前崩溃
	//    非原子:效果已生效,回退位点重放整批 → 多处理 ackBatch 条
	//    原子:处理与提交一起回滚,重放后只生效一次 → 不多处理
	processed = total
	if !c {
		processed += ackBatch
	}
	dup = processed - nMsgs
	if dup < 0 {
		dup = 0
	}
	return
}

func main() {
	type combo struct {
		p, b, c bool
		name    string
	}
	combos := []combo{
		{false, false, false, "全关(最朴素)"},
		{true, false, false, "只开生产幂等"},
		{false, true, false, "只开存储去重"},
		{false, false, true, "只开消费原子提交"},
		{true, true, false, "生产 + 存储"},
		{true, false, true, "生产 + 消费"},
		{false, true, true, "存储 + 消费"},
		{true, true, true, "全开(exactly-once)"},
	}
	fmt.Printf("场景:%d 条唯一消息、%.0f%% 生产超时重试、每 %d 条提交一次位点、提交前崩溃一次\n\n",
		nMsgs, retryRate*100, ackBatch)
	fmt.Printf("%-24s %-10s %-8s %s\n", "开关组合", "处理次数", "重复", "语义")
	for _, c := range combos {
		processed, dup := run(c.p, c.b, c.c)
		sem := "at-least-once"
		if dup == 0 {
			sem = "exactly-once(端到端)"
		}
		fmt.Printf("%-24s %-10d %-8d %s\n", c.name, processed, dup, sem)
	}
}

本机实测(macOS,Apple M1,Go 1.27):

场景:1000 条唯一消息、20% 生产超时重试、每 10 条提交一次位点、提交前崩溃一次

开关组合                     处理次数       重复       语义
全关(最朴素)                  1210       210      at-least-once
只开生产幂等                   1010       10       at-least-once
只开存储去重                   1010       10       at-least-once
只开消费原子提交                 1200       200      at-least-once
生产 + 存储                  1010       10       at-least-once
生产 + 消费                  1000       0        exactly-once(端到端)
存储 + 消费                  1000       0        exactly-once(端到端)
全开(exactly-once)         1000       0        exactly-once(端到端)

读数:

组合 重复次数 重复从哪来
全关 210 200 来自生产重试、10 来自消费重放
只开生产幂等 10 生产侧 200 消掉了,消费侧 10 还在
只开存储去重 10 同上(生产幂等与存储去重是二选一,不必都开)
只开消费原子提交 200 消费侧 10 消掉了,生产侧 200 还在
生产(或存储)+ 消费 0 段 1/2 二选一,段 3 必须开

🔑 exactly-once 是一个“端到端属性”,不是一个“中间件配置项”。broker 能保证的是“日志里不重复”(事务 + 幂等生产),但“MQ → 数据库”这一段仍要靠消费端的幂等写或事务。所以现实里的正确表述是:at-least-once + 幂等消费 ≈ exactly-once 效果。

三种语义的成本

语义 生产端 消费端 延迟 吞吐 状态
at-most-once 发完就算 先提交后处理 最低 最高 无
at-least-once 重试直到确认 处理完再提交 中 高 无(靠去重表)
exactly-once(broker 内) 幂等生产 事务 + 位点原子提交 高(事务提交开销) 下降(事务标记、两阶段) 事务状态、去重表

⚠️ exactly-once 的“状态”成本常被忽略:broker 要维护 producer 序列号与事务标记,消费端要维护“已处理到哪个位点 + 输出去重键”,这些状态随吞吐与保留时间增长。高吞吐场景下,很多团队选的是 at-least-once + 幂等,而不是打开事务。

反模式

反模式 后果
认为“开了事务消息就是 exactly-once” 跨系统仍会重复(数据库写入不在事务里)
消费端先提交位点再处理 崩溃即丢消息(at-most-once)
只靠 broker 去重,不做消费幂等 重平衡/重放时仍重复处理
为了 exactly-once 把所有链路都开事务 吞吐掉一半,延迟翻倍
用“消息 ID 去重”但 ID 由消费端生成 每次重投都是新 ID,去重失效

自检

Q1 为什么“生产端幂等 + 存储端去重 + 消费端不原子”仍然会重复?

▸ 答案:因为重复可能发生在消费侧:消费者处理完消息、还没提交位点就崩溃,重启后会从上次提交的位点重放——这条消息已经处理过,但业务效果(写库、发短信)又发生了一次。生产端与存储端的去重只能保证“日志里没有重复消息”,管不到“消费后产生的副作用”。

Q2 exactly-once 为什么在高吞吐场景下代价高?

▸ 答案:因为它要求“读-处理-写”在一个事务边界内,并且位点提交与输出写入原子化。这带来:① 事务协调开销(两阶段提交或事务标记);② 位点不能批量大幅提交,必须与输出同步;③ broker 与消费端都要维护额外状态(事务 ID、序列号、去重键);④ 失败恢复更慢(要回滚/清理未完成事务)。所以很多系统选“至少一次 + 幂等”,把成本从“全局事务”降到“单条去重”。

Q3 一个团队说“我们用 at-least-once,但业务上等价于 exactly-once”。这句话在什么条件下成立?

▸ 答案:当且仅当每个消费动作都幂等(或能去重):写库用唯一键/条件更新、发消息带幂等键、调用外部接口带幂等 token、发短信/邮件用“业务事件 ID”去重。只要有一处副作用不幂等(比如“余额加 100”),重复投递就会造成真实损失。所以这句话不是语义保证,而是工程承诺——需要逐个消费动作验证。

关联