重试、死信与延迟消息
RabbitMQ 原生就有死信交换机和延迟插件(见 rabbitmq 手册),Kafka 一概没有——它的哲学是"日志不可变、消费进度只是 Offset",重试和延迟都要用 Topic 拼模式。本章给出一套能直接落地的 Retry Topic + DLT 方案(主线 franz-go),以及延迟消息的几种诚实做法。
前置阅读:08-可靠性与幂等消费(重试必然带来重复,先想好幂等)、09-顺序性与分区设计(重试会破坏顺序)。
为什么"失败不提交"不等于重试
06/08 章的手动提交模型里,处理失败不提交 Offset,消息会被重新投递——看起来天然自带重试,实则有三个问题:
- 队头阻塞:一条毒丸消息卡住整个分区,后面的正常消息全跟着反复重放;
- 重试间隔不可控:循环里紧接着就重读,DB 故障时一秒内撞它几十次;
- 永远到不了"放弃"这一步:毒丸永远不会自己消失,分区 Lag 报警会一直响。
所以生产上要做的是:失败的消息离开主队列,进入带节奏的重试通道,超过上限进死信,主队列继续前进。
标准模式:Retry Topic 阶梯 + DLT
order-events ──消费失败──▶ order-events-retry-10s ──再失败──▶ order-events-retry-1m
▲ │ │
└──────处理成功,回投主 Topic◀──────────────────────────────────┘
最终失败
▼
order-events-dlt(只进不出,人工/工单介入)要点:
- 重试 Topic 结构与主 Topic 一致(同样的分区数、同样以业务 Key 路由),尽量保住同实体顺序;
x-attemptHeader 计数,每级重试 Topic 配独立的消费者组,消费时按该级的延迟时长 sleep(最简单)或到点再投下一级;- 阶梯不必贪多:10s → 1m → 10m → DLT 三级够覆盖绝大多数"依赖抖动";
- DLT 消费端默认不存在——进了死信就是"需要人看",配 Lag>0 告警。
生产端:失败消息改投重试 Topic
franz 一个客户端就能干这活:Record.Topic 非空时覆盖 DefaultProduceTopic(03 章),所以重试/死信/主 Topic 共用 cl,不需要像 kafka-go 那样专门再建一个"不带 Writer.Topic 的 retryW"。
// 重试等级表:Topic 名 + 本级停留时长
var retryLevels = []struct {
topic string
hold time.Duration
}{
{"order-events-retry-10s", 10 * time.Second},
{"order-events-retry-1m", time.Minute},
{"order-events-retry-10m", 10 * time.Minute},
}
// bumpToRetry 把失败消息打入下一级重试 Topic,attempt +1。
// r 来自 group 消费者的 PollRecords:Key/Value/Headers 原样带着走,
// murmur2 分区器保证同 Key 在重试 Topic 里也进同一分区号。
func bumpToRetry(ctx context.Context, cl *kgo.Client, r *kgo.Record, attempt int) error {
if attempt >= len(retryLevels) {
// 阶梯用尽 → 死信(带失败原因,方便排查)
r.Headers = setHeader(r.Headers, "x-fail-topic", r.Topic)
r.Headers = setHeader(r.Headers, "x-fail-attempt", strconv.Itoa(attempt))
r.Topic = "order-events-dlt"
return cl.ProduceSync(ctx, r).FirstErr()
}
lvl := retryLevels[attempt]
r.Topic = lvl.topic
r.Headers = setHeader(r.Headers, "x-attempt", strconv.Itoa(attempt+1))
r.Headers = setHeader(r.Headers, "x-ready-at", strconv.FormatInt(
time.Now().Add(lvl.hold).UnixMilli(), 10))
return cl.ProduceSync(ctx, r).FirstErr()
}
// setHeader 覆盖式写入 Header(同 key 只保留一个值);[]kgo.RecordHeader 与 []kafka.Header 同构
func setHeader(hs []kgo.RecordHeader, key, val string) []kgo.RecordHeader {
for i := range hs {
if hs[i].Key == key {
hs[i].Value = []byte(val)
return hs
}
}
return append(hs, kgo.RecordHeader{Key: key, Value: []byte(val)})
}消费端:带延迟判断的重试消费者
// 消费 order-events-retry-10s 的循环(重试组客户端按 03 章 group 的建法换一个
// ConsumerGroup/ConsumeTopics 即可)。"延迟"的实现:
// 拉取后若未到 x-ready-at,就地等一等再处理(单分区流量小,sleep 不伤大雅);
// 更讲究的做法是把"未到期消息重新投回本 Topic 并更新 ready-at",避免占住消费循环。
func consumeRetry(ctx context.Context, retryGroup *kgo.Client, cl *kgo.Client) {
for {
fs := retryGroup.PollRecords(ctx, 200)
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 ready := readyAtOf(r.Headers); ready > 0 {
if wait := time.Until(time.UnixMilli(ready)); wait > 0 {
select {
case <-time.After(wait):
case <-ctx.Done():
return // 未提交,关机会重放,靠幂等兜底
}
}
}
attempt := attemptOf(r.Headers) // 从 x-attempt Header 解析,缺省 0
if err := handle(r); err != nil {
if err2 := bumpToRetry(ctx, cl, r, attempt); err2 != nil {
log.Printf("转入重试通道失败: %v", err2) // 不提交,留在本 Topic 重放
return
}
done = append(done, r) // 已成功转走,主流程推进
return
}
done = append(done, r)
})
if len(done) > 0 {
retryGroup.CommitRecords(ctx, done...)
}
}
}
func attemptOf(hs []kgo.RecordHeader) int {
for _, h := range hs {
if h.Key == "x-attempt" {
n, _ := strconv.Atoi(string(h.Value))
return n
}
}
return 0
}
// readyAtOf 与 attemptOf 同构:从 x-ready-at Header 解出毫秒时间戳,缺省 0(立即可处理)
func readyAtOf(hs []kgo.RecordHeader) int64 {
for _, h := range hs {
if h.Key == "x-ready-at" {
ms, _ := strconv.ParseInt(string(h.Value), 10, 64)
return ms
}
}
return 0
}主 Topic 的消费者同理:03 章手动提交骨架里 handle(r) 失败就 bumpToRetry(ctx, cl, r, 0),然后把原消息计入 done 提交掉——主队列不再为它停留,队头阻塞解除。
注意:毒丸消息(每次都 panic 的那种)被封顶进入 DLT,是整个方案的意义所在。任何"无限重试"的实现(包括 sleep 等不到就 continue)都会把重试 Topic 也拖成队头阻塞。另注意 franz 的
CommitRecords按分区取最大 Offset 累积提交(06 章)——EachRecord里return跳过的失败消息如果 Offset 比同批已提交的小,会被"捎带提交";对毒丸这无所谓(反正已转走),但对"失败留在原地等重放"的消息,要接受同批尾部一并推进的语义,或者失败时整批不提交。
Topic 创建与告警
# 主/重试/DLT 一起建;DLT 不设置过期,进一条留一条
.\bin\windows\kafka-topics.bat --create --topic order-events-dlt \
--bootstrap-server localhost:9092 --partitions 1 --replication-factor 3
.\bin\windows\kafka-configs.bat --bootstrap-server localhost:9092 \
--entity-type topics --entity-name order-events-dlt --alter \
--add-config retention.ms=-1DLT 分区数给 1 通常就够(它是异常通道,不是吞吐通道);监控上 DLT 分区 Lag 持续增长 和 重试 Topic 出现 attempt 顶格消息 都应该直接触发工单,而不是等人去看大盘。程序化建这批 Topic 用 kadm(03 章的 adm):adm.CreateTopics(ctx, 6, 3, nil, "order-events-retry-10s", ...)。
死信回放
事故恢复后把 DLT 倒回主 Topic 重放——用官方脚本两行搞定:
# 导出全部死信 → 逐行回投主 Topic(消息体不变;attempt 头在导出时丢失,回放后重新计数)
.\bin\windows\kafka-console-consumer.bat --topic order-events-dlt --from-beginning \
--bootstrap-server localhost:9092 --property print.key=true \
--property key.separator="|" --max-messages 100000 > dlt_dump.txt
# 按 key|value 格式回投(控制台生产者默认 tab 分隔,用 property 对齐)
.\bin\windows\kafka-console-producer.bat --topic order-events \
--bootstrap-server localhost:9092 --property parse.key=true \
--property key.separator="|" < dlt_dump.txt需要完整保留 Headers 的回放得写程序:非组模式(03 章 scan 的写法,起点 AtStart)拉 DLT,逐条把 Key/Value/Headers 原样塞回 Record{Topic:"order-events"} 投 cl——思路同上面的 consumeRetry,此处不再展开。
延迟消息:Kafka 没有原生,四种诚实做法
场景:下单 30 分钟未支付则自动取消、优惠券到期前 1 天提醒、Push 分批发送。按实现成本排列:
| 方案 | 精度 | 成本 | 适用 |
|---|---|---|---|
| ① 消费端 sleep / ready-at 轮询 | 秒级 | 低 | 量小、延迟短(就是上面重试用的那招) |
| ② 延迟等级 Topic 表 | 分级 | 中 | 像 RocketMQ 那样固定档位(5s/30s/1m/…),档位内用 ready-at 判断 |
| ③ 外部调度器代管 | 高 | 中 | Redis ZSet / 时间轮服务按时间戳回投 Kafka(本质是把延迟外包) |
| ④ 换工具 | — | — | 延迟是核心需求时,RocketMQ/Asynq 直接内置,别硬做(见 07-MQ选型与架构) |
方案② 的生产端(按档位选 Topic):
// 与 ①② 共用的 retryLevels 类似,建一组 delay-<级别> Topic
// order:cancel-30m 由专门的消费者订阅,到期后投回业务 Topic
func sendDelayed(ctx context.Context, cl *kgo.Client, levelTopic string, r *kgo.Record, fireAt time.Time) error {
r.Topic = levelTopic // 同样靠 Record.Topic 覆盖 DefaultProduceTopic
r.Headers = setHeader(r.Headers, "x-ready-at", strconv.FormatInt(fireAt.UnixMilli(), 10))
return cl.ProduceSync(ctx, r).FirstErr()
}消费端逻辑与 consumeRetry 完全同构:未到 x-ready-at 就等,到点投回业务 Topic。档位 Topic 的好处是"30 分钟档的消息堆在 cancel-30m 里慢慢睡",互不干扰;坏处是要为每个档位维护一套 Topic + 消费者。
注意:不要为了省 sleep 去实现"broker 端延迟"的奇技淫巧(按时间戳建海量分区之类)。延迟语义 Kafka 生态至今没有原生解(KIP 提案多年未落地),要么接受自建调度器的复杂度,要么在选型时就引入专职组件。
Kafka 4.x 的新变量:Share Groups(Queues)
Kafka 4.2(2026-02)把 Share Groups(对外叫 Kafka Queues)推进到生产可用:消费者组内消息逐条 ACK、可指定 RENEW(处理慢时申请续期防止被抢回)——语义上向 RabbitMQ 的工作队列靠了一大步。它不直接提供"延迟消息",但"处理失败不丢、不队头阻塞"的模型(消息被一个消费者领走、超时自动可重分配)能替代上面 Retry Topic 的一部分职能。
客户端支持:franz-go 已实现——03 章的 kgo.ShareGroup(...) + MarkAcks/FlushAcks 就是全部所需 API(AckAccept/AckRelease/AckReject/AckRenew 四态);kafka-go 不会跟进。kafka-go 存量代码"失败不提交重放"的老路子,在 Share Groups 下对应 AckReject(显式拒绝、可带 release back 语义),概念上更直白。
现实评估:协议较新(4.2 GA、生态适配期),线上核心链路仍建议先按本章的 Topic 模式落地,把 Share Groups 当观察项和小流量试点。详见 12-集群与高可用 的演进小节。
实战建议
- 重试层级别贪多:3 级(10s/1m/10m)+ DLT 覆盖 99% 的依赖抖动;更多层级只是运维负担。
- 重试通道幂等先行:消息进重试就注定可能被处理多次,上 Retry Topic 之前先把 08 章的幂等做好,顺序问题回头查 09 章事故二。
- DLT 是告警器不是垃圾桶:只有"进入 DLT 就有人工流程承接",这套模式才成立;否则只是把丢消息换了个更隐蔽的姿势。
- 延迟消息先看选型再看实现:业务以延迟为核心(订单超时、定时通知批量触发)时,RocketMQ/Asynq 的内置能力远比在 Kafka 上自建可靠;已重仓 Kafka 的团队用方案②/③ 也够。
- 把
x-attempt、x-ready-at、x-fail-topic这些 Header 命名写进团队规范,全链路排障时这些字段就是黑匣子。