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”),重复投递就会造成真实损失。所以这句话不是语义保证,而是工程承诺——需要逐个消费动作验证。