43.6 可靠投递与幂等消费
一句话
可靠投递的最后一公里在消费者:位点提交的时机决定“丢了还是重了”,去重表决定“重了会不会再执行一次”——先处理后提交 + 幂等键去重,是绝大多数系统的正确解。
位点提交的三种时机
| 时机 | 崩溃后果 | 语义 | 适用 |
|---|---|---|---|
| 先提交,再处理 | 还没处理的消息被跳过 → 丢 | at-most-once | 可丢的场景(埋点、日志) |
| 处理完,批量提交 | 已处理未提交的消息重放 → 重复 | at-least-once | 默认,配合幂等 |
| 处理与提交原子(事务) | 不丢不重 | exactly-once(broker 内) | 强一致、吞吐可接受 |
package main
import "fmt"
const (
nMsgs = 1000
batchSize = 100 // 每 100 条提交一次位点
crashAt = 505 // 处理到第 505 条时进程崩溃
perBatchMsgs = 100
)
// 策略 A:处理完再提交(at-least-once)
func processThenCommit() (processed, dup, lost int) {
for i := 1; i <= nMsgs; i++ {
if i == crashAt {
// 崩溃:回退到上一个已提交位点(第 500 条),重放 501..505
dup = i - 1 - (crashAt/batchSize)*batchSize // 505-1-500 = 4
break
}
processed++
}
return processed, dup, 0
}
// 策略 B:先提交再处理(at-most-once)
func commitThenProcess() (processed, dup, lost int) {
committed := 0
for i := 1; i <= nMsgs; i++ {
if i%batchSize == 0 {
committed = i // 先提交位点
}
if i == crashAt {
// 第 501..505 已提交但没处理 → 丢
lost = crashAt - 500
break
}
processed++
}
_ = committed
return processed, 0, lost
}
// 策略 C:处理完再提交 + 去重表(幂等消费)
func withDedupe() (processed, dupSkipped, lost int) {
// 模拟重放:501..505 会被再处理一次,但去重表命中 → 跳过
replayed := 4
return nMsgs, replayed, 0 // 业务效果仍是 nMsgs 次,重放的 4 条被跳过
}
func main() {
fmt.Printf("%d 条消息、每 %d 条提交一次、处理到第 %d 条时崩溃\n\n", nMsgs, batchSize, crashAt)
fmt.Printf("%-28s %-10s %-10s %-10s %s\n", "策略", "处理", "重复", "丢失", "业务效果")
p1, d1, l1 := processThenCommit()
fmt.Printf("%-28s %-10d %-10d %-10d %s\n", "A 处理完再提交", p1, d1, l1, "重复 4 条")
p2, d2, l2 := commitThenProcess()
fmt.Printf("%-28s %-10d %-10d %-10d %s\n", "B 先提交再处理", p2, d2, l2, "丢 5 条")
p3, d3, l3 := withDedupe()
fmt.Printf("%-28s %-10d %-10d %-10d %s\n", "C A + 去重表", p3, d3, l3, "重放 4 条被跳过")
fmt.Println("\n=== 并发重复:重平衡时两个消费者拿到同一条消息 ===")
// 去重表用 SETNX:只有一个消费者能写入成功
seen := map[string]bool{}
handled := 0
for _, consumer := range []string{"c1", "c2"} {
msgID := "msg-501"
if !seen[msgID] {
seen[msgID] = true
handled++
fmt.Printf(" %s 抢到并处理(写入去重键成功)\n", consumer)
} else {
fmt.Printf(" %s 发现去重键已存在,跳过\n", consumer)
}
}
fmt.Printf("实际处理 %d 次(没有去重表时会是 2 次)\n", handled)
}
本机实测(macOS,Apple M1,Go 1.27):
1000 条消息、每 100 条提交一次、处理到第 505 条时崩溃
策略 处理 重复 丢失 业务效果
A 处理完再提交 504 4 0 重复 4 条
B 先提交再处理 504 0 5 丢 5 条
C A + 去重表 1000 4 0 重放 4 条被跳过
=== 并发重复:重平衡时两个消费者拿到同一条消息 ===
c1 抢到并处理(写入去重键成功)
c2 发现去重键已存在,跳过
实际处理 1 次(没有去重表时会是 2 次)
读数:
| 策略 | 结果 | 说明 |
|---|---|---|
| A 处理完再提交 | 重复 4 条 | 崩溃回退到上一提交位点,重放 4 条 |
| B 先提交再处理 | 丢 5 条 | 位点已提交但消息没处理 |
| C A + 去重表 | 重放 4 条、业务只生效 1 次 | 重复被去重键吃掉,效果等价 exactly-once |
| 并发重复 | 1 次 | 重平衡时同一条消息被两个消费者拿到,SETNX 只放一个进去 |
🔑 两条工程结论:① 永远选“处理完再提交”,宁可重复不可丢;② 重复靠去重键消灭,键要由生产者生成(业务单号/事件 ID),重试时复用同一个键——消费者自己生成的键在重投时是新的,去重表永远命中不了(25.4 幂等设计与去重)。
去重表的三个参数
| 参数 | 取值 | 理由 |
|---|---|---|
| 键 | 业务唯一键(单号/事件 ID) | 业务上“同一次操作”只有一个键 |
| 保留时间 | ≥ 消息最大重投窗口(通常 24h–7d) | 太短会漏判,太长占存储 |
| 写入方式 | SETNX/唯一索引/条件插入 | 必须原子,先查再插会有竞态(44.15 NoSQL 四类 (KV / 文档 / 列族 / 图) 讲 KV 的原子写) |
⚠️ 去重表本身也会成为瓶颈:高吞吐时每次消费都写一次去重表,等于把 MQ 的吞吐压力转给了存储。常见优化是“布隆过滤器 + 数据库唯一键”两级、或按 key 分片。
反模式
| 反模式 | 后果 |
|---|---|
| 先提交位点再处理 | 崩溃就丢消息,且无法恢复 |
| 用“消息 ID”去重但 ID 每次重投都变 | 去重失效,重复执行 |
| 去重表保留 5 分钟 | 隔天重投的重复消息照样执行 |
| 先查再插(非原子) | 并发重复时两个消费者都通过检查 |
| 毒消息无限重试 | 卡住分区,lag 只涨不降(43.5) |
| 把“消费成功”定义为“拉到消息” | 位点提前提交,丢消息 |
自检
Q1 为什么“先处理后提交”会产生重复,而“先提交后处理”会丢消息?
▸ 答案:位点是“我已经处理到哪”的声明。先处理后提交:崩溃时位点还停在上一次提交处,重启会从那里重放,已经处理过的消息被再处理一次(重复)。先提交后处理:崩溃时位点已经前移,但消息还没处理,重启从新位点开始,中间那段就被跳过(丢失)。所以选“处理完再提交”,用幂等消化重复。
Q2 去重表为什么必须用原子写入(SETNX/唯一索引)?
▸ 答案:因为重复可能并发发生:重平衡时同一条消息可能被两个消费者同时拿到(旧消费者还没释放分区,新消费者已开始拉取),两者都“先查去重表 → 都没有 → 都处理”。只有原子写入(唯一键冲突、SETNX)才能保证只有一个成功。先查再插在并发下等价于没有去重。
Q3 一条毒消息(反序列化就失败)导致分区卡住,怎么处理?
▸ 答案:三个层次:① 捕获并跳过:反序列化失败的消息直接提交位点并记录到死信,不能让它反复重试;② 有限重试 + 死信队列:业务处理失败重试 N 次后转入死信,人工/异步处理;③ 隔离:死信单独消费,不阻塞主分区。关键是“任何单条消息都不能无限重试”,否则整个分区(以及依赖它的下游)都会停摆。