可靠性与幂等消费
消息丢失只发生在三个环节:生产者发送、Broker 副本、消费者提交。本章把前两个环节做到"一定写进去",再在消费端用"至少一次 + 幂等"拼出事实上的精确一次。主线用 franz-go(acks/幂等/重试默认即生产级),kafka-go 的"默认值埋坑"作为存量对照保留。
前置阅读:04-生产者、06-偏移量与提交。本章代码延续这两章的
cl(生产客户端)和group(手动提交消费客户端,骨架见 03 章)。
全景:消息会在哪三个环节丢
生产者 ──① 发送失败未处理/不等 acks──▶ Broker ──② 副本数不足/ISR 收缩/脏选举──▶ 磁盘日志
│
③ 先提交后处理、处理中途宕机、Offset 提交过大
▼
消费者- 环节①(生产端):错误被吞掉、不等确认就返回、重试次数用完直接丢。
- 环节②(Broker 端):副本数为 1 的分区所在 Broker 磁盘坏了就真没了;
acks=all但min.insync.replicas=1形同虚设。 - 环节③(消费端):Offset 提交时机不对——提交早于处理会丢(见 06 的两张图),提交晚于处理会重。
生产端 + Broker 端保证**“消息一定在 Kafka 里”;消费端保证“消息一定被处理完”。但"先处理再提交"必然带来重复投递(处理完还没提交就崩了),所以可靠性叙事必须靠幂等消费**收尾:
重复投递不是故障,是设计。 Kafka 的语义是至少一次(at-least-once),“不丢"靠机制,“不重"靠你的业务代码。
生产端:确保消息一定写进去
acks:franz 默认就在安全档,kafka-go 默认在裸奔档
franz-go 的默认组合已经是生产级:acks = AllISRAcks()(-1)+ 幂等生产开启 + 请求级重试 20 次。也就是说 03 章那个最简 cl 客户端,什么都不加就满足本节全部要求——这正是把主线换成 franz-go 的原因之一:可靠性的正确姿势是"默认值”,而不是"每个新人都要背一遍的配置清单”。
kafka-go 存量警告照旧:直接构造 &kafka.Writer{} 时 RequiredAcks 零值就是 RequireNone(0,发了不管),源码注释明写 “Defaults to RequireNone”——存量代码里大概率一直在 fire-and-forget。任何业务 Writer 必须补齐"五件套"(写法见 04 章存量块)。
三种 acks 的语义与丢失风险(概念不分客户端):
| acks | 值 | 什么时候返回 | 丢消息风险 |
|---|---|---|---|
NoAcks / RequireNone |
0 | 不等任何确认 | Broker 崩了/写盘失败都不知情,仅限日志采样类场景 |
OneAcks / RequireOne |
1 | Leader 写成功即返回 | Leader 未同步给 Follower 就宕机 → 丢 |
AllISRAcks() / RequireAll |
-1 | ISR 中所有副本写成功 | 需配合 min.insync.replicas(见下文),否则 ISR 只剩 Leader 时形同 acks=1 |
错误分类处理 + 兜底落库
franz 的重试(请求级 Retries 默认 20 + 记录级无限次)只覆盖"可恢复"错误;RetryTimeout 到点后错误显式交回你手里,必须接管,否则等于丢。常见处理分层:
// 发送一条订单事件;失败分类处理,最终兜底进本地消息表
func publish(ctx context.Context, cl *kgo.Client, orderID string, payload []byte) error {
rec := &kgo.Record{
Key: []byte(orderID),
Value: payload,
// Topic 由 cl 的 DefaultProduceTopic("order-events") 兜底(03 章)
}
withMsgID(rec, snowflake()) // 全局 msgID 进 Header,去重键设计见下文
ctx, cancel := context.WithTimeout(ctx, 10*time.Second)
defer cancel()
err := cl.ProduceSync(ctx, rec).FirstErr()
if err == nil {
return nil
}
var ke *kerr.Error
switch {
// Leader 切换/迁移中:franz 内部已重试过,走到这里说明超时太久,直接交给兜底
case errors.As(err, &ke) && (ke.Code == kerr.LeaderNotAvailable.Code ||
ke.Code == kerr.NotLeaderForPartition.Code):
// ISR 不足(Broker 端 min.insync.replicas 挡住写入),恢复副本后才有救
case errors.As(err, &ke) && (ke.Code == kerr.NotEnoughReplicas.Code ||
ke.Code == kerr.NotEnoughReplicasAfterAppend.Code):
// 消息超限额(MessageTooLarge),重试一万次也不会成功
case errors.As(err, &ke) && ke.Code == kerr.MessageTooLarge.Code:
log.Printf("消息过大被拒: orderID=%s", orderID)
}
// 兜底:写本地消息表,由后台补偿任务重投(见下方"本地消息表")
return saveToOutbox(rec, err)
}对照记忆:kafka-go 存量用 errors.Is(err, kafka.MessageSizeTooLarge) 等协议码常量,语义一样,写法两套(kerr.Error 是"错误对象带 Code/Retriable 元数据",见 03 章)。
本地消息表模式(不依赖事务的最小兜底):发送失败时把消息写进业务库的 kafka_outbox 表(和主业务同一事务),由定时任务扫描重投。
// outbox 表:id, topic, part_key, payload, err_msg, status(pending/sent), created_at
// 与订单写入同一事务提交,保证"业务成功则消息必发"
func saveToOutbox(rec *kgo.Record, cause error) error {
topic := rec.Topic
if topic == "" {
topic = "order-events" // DefaultProduceTopic 兜底值,落表前补全
}
return db.Exec(
`INSERT INTO kafka_outbox (topic, part_key, payload, err_msg, status, created_at)
VALUES (?, ?, ?, ?, 'pending', NOW())`,
topic, string(rec.Key), rec.Value, cause.Error(),
).Error
}注意:补偿任务重投意味着消息可能被发两次(原请求其实成功了、只是响应超时)。这正是"至少一次"的代价,由消费端幂等消化(见下文)。
幂等生产:franz 默认就有,kafka-go 做不到
重复生产的原因:发送超时后客户端自动重试,但 Broker 其实已经写成功了第一条——于是同一逻辑消息落进分区两次。Kafka 的解法是幂等 Producer(给 Producer 分配 PID + 序号,Broker 端按序号去重),代价是 acks 必须为 all、且去重只在单分区单 Producer 会话内生效。
- franz-go:幂等默认开启,上面那个
cl已经在去重了。幂等模式下允许最多 5 个 in-flight 请求且保证同分区不乱序(KIP-214;关闭幂等才会退回"最多 1 个 in-flight")。要关只能kgo.DisableIdempotentWrite()——除非 Broker 是 3.0 以下且没给客户端账号授IDEMPOTENT_WRITE集群 ACL(3.0 起默认允许,老集群报TopicAuthorizationFailed类权限错时才想到它)。 - kafka-go v0.4.51 的
Writer没有暴露任何幂等/事务字段(协议层结构体里有TransactionalID,高层 API 未封装)。需要这两项时直接换 franz-go(选型依据见 03-Go客户端franz-go)。
Broker 端:写进去的不丢
acks=all 只是客户端的等待姿势,真正的保护在 Broker 配置上。三件套:
# 1. 建 Topic 时副本至少 3(单机开发无所谓,集群必须)
.\bin\windows\kafka-topics.bat --create --topic order-events \
--bootstrap-server localhost:9092 --partitions 6 --replication-factor 3
# 2. ISR 中至少 2 个副本落盘才算写成功(3 副本容忍挂 2 个而不丢?不是——
# 是"挂 1 个还能写、挂 2 个拒绝写入",用可用性换持久性)
.\bin\windows\kafka-topics.bat --alter --topic order-events \
--bootstrap-server localhost:9092 --config min.insync.replicas=2
# 3. 禁止脏选举:宁可分区不可用,也不能让落后副本当 Leader 后抹掉数据
.\bin\windows\kafka-configs.bat --bootstrap-server localhost:9092 \
--entity-type brokers --entity-default --alter \
--add-config unclean.leader.election.enable=false三者的组合语义,用一句话记:RF=3 + ISR≥2 + 禁止脏选举 = 单副本宕机不丢、不中断;双副本宕机停写(拒绝丢数据)。
min.insync.replicas=2但 ISR 只剩 1 个副本时,Producer 会收到NotEnoughReplicas——这就是上面错误分类里那一条的由来。unclean.leader.election.enable=true(默认 false)允许非 ISR 副本当 Leader,新 Leader 的日志比原 Leader 旧,落后部分的数据对后续写入者不可见,等于丢。金融/订单类 Topic 严禁打开。- 副本同步机制(ISR/LEO/HW)的细节在 12-集群与高可用。
注意:不要试图用
log.flush.*强制每条消息 fsync 来获得"绝对不丢"。Kafka 的持久性靠跨机器副本冗余,不靠单机刷盘——强制刷盘会把吞吐打到骨折,而单机磁盘损坏时副本早已在别的机器上。这是 Kafka 设计哲学里最容易误解的一点。
消费端:处理完再提交,重复靠幂等消化
提交时机决定语义
| 语义 | franz-go 做法 | 风险 |
|---|---|---|
| 至多一次 | 自动提交档 + 先提交后处理(不推荐;kafka-go 的 ReadMessage 接近此档) |
处理失败/宕机 → 丢 |
| 至少一次 | 手动提交:先处理成功再 CommitRecords(03 章骨架) |
提交前崩溃 → 重 |
| 事实精确一次 | 至少一次 + 业务幂等 | 需要去重设计(下文) |
生产上"至多一次"几乎只出现在允许丢的采样统计里。默认姿势就是 03/06 章的手动/标记式提交骨架:
// 03 章的全手动循环(group 已配 DisableAutoCommit):处理成功后才提交
for {
fs := group.PollRecords(ctx, 500)
if err := fs.Err0(); errors.Is(err, context.Canceled) || errors.Is(err, kgo.ErrClientClosed) {
return
}
var done []*kgo.Record
fs.EachRecord(func(r *kgo.Record) {
if err := handle(r); err != nil {
// 不提交:本条以及后续都会被重放;重试上限与死信处理见 10
log.Printf("处理失败 offset=%d: %v", r.Offset, err)
return
}
done = append(done, r)
})
if len(done) > 0 {
if err := group.CommitRecords(ctx, done...); err != nil {
// 提交失败也要处理,否则这批会重复消费——好在幂等兜着
log.Printf("提交失败: %v", err)
}
}
}kafka-go 存量对应 FetchMessage + CommitMessages,循环形状见 03 章速查。
去重键设计:别拿 Offset 当唯一 ID
一个高频误区:用 Topic-Partition-Offset 做去重键。它不能跨分区扩容(扩分区后 offset 会重新分配),也不是业务语义。正确的去重键是消息自带的业务身份:
- 首选:业务主键 + 业务阶段,如
order-20261013-000123:paid——同一订单的"已支付"事件天然幂等; - 没有业务主键时:生产者在
Headers里带全局唯一 ID(雪花/UUID),消费端按它去重。
// 生产端:塞一个全局 msgID 进 Header(franz 的 Header 是 []kgo.RecordHeader{Key string; Value []byte})
func withMsgID(rec *kgo.Record, msgID string) *kgo.Record {
rec.Headers = append(rec.Headers, kgo.RecordHeader{Key: "x-msg-id", Value: []byte(msgID)})
return rec
}
// 消费端:从 Header 取
func msgIDOf(hs []kgo.RecordHeader) string {
for _, h := range hs {
if h.Key == "x-msg-id" {
return string(h.Value)
}
}
return "" // 没有则退回业务键
}kafka-go 存量里同构函数签名换成 kafka.Message / []kafka.Header 即可,10 章的 Header 工具函数两边共用思路。
三种去重实现
① Redis SETNX——高吞吐首选
// 依赖 go-redis;key 带 Topic 维度避免串号
func once(ctx context.Context, rdb *redis.Client, r *kgo.Record) (bool, error) {
key := fmt.Sprintf("kafka:dedup:%s:%s", r.Topic, msgIDOf(r.Headers))
// TTL > 消息最大重放窗口(retention + 重试时长),但要短于业务允许的"同 ID 再来"间隔,
// 正常一条业务消息的 ID 本来就是唯一的,TTL 3~7 天足够
return rdb.SetNX(ctx, key, 1, 72*time.Hour).Result()
}
// 消费循环里(handle 之前判):
ok, err := once(ctx, rdb, r)
if err != nil { /* Redis 故障降级:宁可重复处理,不要卡消费 */ }
if !ok {
done = append(done, r) // 重复消息:视为已处理,正常提交跳过
return
}② 数据库唯一索引——强一致场景零额外组件
把去重和写业务数据放进同一个本地事务,天然原子:
// INSERT IGNORE 或 ON DUPLICATE KEY,靠 (topic, msg_id) 唯一索引判重
res, err := db.ExecContext(ctx,
`INSERT INTO consumed_msg (topic, msg_id, created_at) VALUES (?, ?, NOW())
ON DUPLICATE KEY UPDATE msg_id = msg_id`, r.Topic, msgIDOf(r.Headers))
if err != nil { /* 回滚重试 */ }
if n, _ := res.RowsAffected(); n == 0 {
// 影响行数 0 = 该消息已消费过,直接提交 Offset 跳过
}③ 状态机版本号——订单/库存类业务的最优解
不维护任何去重表,用业务状态本身判重:
// 事件携带"目标状态 + 前驱状态",UPDATE 只在前驱匹配时生效
res, _ := db.ExecContext(ctx,
`UPDATE orders SET status = 'paid'
WHERE order_no = ? AND status = 'created'`, orderNo)
n, _ := res.RowsAffected()
if n == 0 {
// 状态已推进过(重复消息),幂等地提交跳过
}注意:Redis 去重表要接受它不是 100% 可靠——Redis 挂掉重启、或去重记录写入与业务写入不是同一事务时,仍可能出现小概率重复。对"绝对不能重复扣款"级别的场景,用方案②/③把去重收进数据库本地事务。
事务(Exactly-Once):Go 侧只有 franz-go
Kafka 事务(EOS)覆盖的是"读-处理-写"作为一个原子单元:消费 Offset 的提交和产出新消息在同一个 EndTxn 里生效,崩溃时整体回滚。典型场景:A topic 消费 → 转换 → 写 B topic → 下游用 read_committed 读 B,中间任何一步失败都不留痕迹。
kafka-go 做不了;franz-go 提供 GroupTransactSession:
// franz-go:消费 order-raw → 转换 → 写 order-clean,全程精确一次
gts, err := kgo.NewGroupTransactSession(
kgo.SeedBrokers("localhost:9092"),
kgo.ConsumerGroup("clean-order"),
kgo.ConsumeTopics("order-raw"),
kgo.TransactionalID("order-cleaner-1"), // 每个实例唯一,重启复用同一 ID
)
defer gts.Close()
ctx := context.Background()
for {
fetches := gts.PollRecords(ctx, 100)
if err := fetches.Err0(); errors.Is(err, context.Canceled) || errors.Is(err, kgo.ErrClientClosed) {
return
}
if err := gts.Begin(); err != nil {
continue // 事务开启失败:franz-go 内部已恢复/重试
}
var recs []*kgo.Record
fetches.EachRecord(func(r *kgo.Record) {
recs = append(recs, &kgo.Record{
Topic: "order-cleaner-input",
Key: r.Key,
Value: transform(r.Value),
})
})
// 生产 + Offset 提交都登记在本事务内
errs := gts.ProduceSync(ctx, recs...).Errs()
endAs := kgo.TryCommit
if len(errs) > 0 {
endAs = kgo.TryAbort // 生产失败:整笔事务回滚,下次 poll 重放原消息
}
committed, err := gts.End(ctx, endAs)
if err != nil || !committed {
// 事务未成功(fencing/超时等):franz-go 会自动恢复事务状态,消息重新投递
}
}约束记三条:transactional.id 必须每个 Producer 实例全局唯一且重启不变(否则僵尸事务无法 fence),且与 DisableIdempotentWrite() 互斥(事务必然含幂等);事务默认超时 txnTimeout 40s(客户端)/transaction.timeout.ms(Broker,默认 1 周),长事务要调;下游消费事务 Topic 要只读已提交消息——franz 用 kgo.FetchIsolationLevel(kgo.ReadCommitted()),kafka-go 存量用 IsolationLevel: kafka.ReadCommitted。
端到端检查清单
上线前逐条对照:
- 生产端:franz-go 默认已满足 acks=-1 + 幂等 + 重试(确认没被
DisableIdempotentWrite关掉;老集群查IDEMPOTENT_WRITEACL);RetryTimeout已显式设置,兜底链路能按时失败;kafka-go 存量 Writer 必须显式RequiredAcks: RequireAll+MaxAttempts/退避;发送失败有分类处理(kerr码 /errors.Is协议码)和兜底(本地消息表/告警);消息带唯一业务键或x-msg-idHeader - Broker:
replication.factor ≥ 3;min.insync.replicas = 2;unclean.leader.election.enable = false;offsets.topic.replication.factor ≥ 3(__consumer_offsets本身也不能是单副本) - 消费端:先处理再提交(
DisableAutoCommit/AutoCommitMarks+CommitRecords/MarkCommitRecords);新组起点显式ConsumeStartOffset(AtEnd)(05 章);幂等去重方案已选型;重试有上限,超限进死信(10 章);消费者组内实例数 ≤ 分区数(05 章) - 架构:接受"至少一次 + 幂等"是常态,只有 Kafka 内部流加工链路才上事务;监控 ISR 收缩、消费 Lag、死信 Topic 三条曲线
实战建议
- 90% 的业务系统不要碰 Kafka 事务。事务链路复杂(fencing、僵尸事务、read_committed)。“franz 默认的 acks=-1+幂等 + min.insync=2 + 手动提交 + 数据库幂等"这套组合,可靠性已经等价于事务方案,且每一步都能单独排障。
- 幂等方案选型看成本:高吞吐日志/积分累加 → Redis SETNX;资金/库存变更 → 唯一索引或状态机版本;两者混用很正常——Redis 挡量,数据库兜底。
- 默认值要背,但要背对客户端:franz 的坑在"默认太安全"的反面——
RetryTimeout不封顶时记录级重试近乎无限;kafka-go 的坑在"默认裸奔”——acks、压缩、幂等全要手配。存量迁移时,把这两套默认差异写进 code review 清单。 - 幂等生产的去重范围要讲清楚:franz 默认幂等只消除单分区、单 Producer 会话内的重复;跨会话(进程重启)和跨分区的重复仍然靠消费端幂等兜底。“开了幂等就不用去重表"是本章最常见的误读。