Skip to content
重试、死信与延迟消息

重试、死信与延迟消息

RabbitMQ 原生就有死信交换机和延迟插件(见 rabbitmq 手册),Kafka 一概没有——它的哲学是"日志不可变、消费进度只是 Offset",重试和延迟都要用 Topic 拼模式。本章给出一套能直接落地的 Retry Topic + DLT 方案(主线 franz-go),以及延迟消息的几种诚实做法。

前置阅读:08-可靠性与幂等消费(重试必然带来重复,先想好幂等)、09-顺序性与分区设计(重试会破坏顺序)。


为什么"失败不提交"不等于重试

06/08 章的手动提交模型里,处理失败不提交 Offset,消息会被重新投递——看起来天然自带重试,实则有三个问题:

  1. 队头阻塞:一条毒丸消息卡住整个分区,后面的正常消息全跟着反复重放;
  2. 重试间隔不可控:循环里紧接着就重读,DB 故障时一秒内撞它几十次;
  3. 永远到不了"放弃"这一步:毒丸永远不会自己消失,分区 Lag 报警会一直响。

所以生产上要做的是:失败的消息离开主队列,进入带节奏的重试通道,超过上限进死信,主队列继续前进。


标准模式:Retry Topic 阶梯 + DLT

order-events ──消费失败──▶ order-events-retry-10s ──再失败──▶ order-events-retry-1m
     ▲                          │                                  │
     └──────处理成功,回投主 Topic◀──────────────────────────────────┘
                                                                 最终失败
                                                                  ▼
                                                        order-events-dlt(只进不出,人工/工单介入)

要点:

  • 重试 Topic 结构与主 Topic 一致(同样的分区数、同样以业务 Key 路由),尽量保住同实体顺序;
  • x-attempt Header 计数,每级重试 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=-1

DLT 分区数给 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-集群与高可用 的演进小节。


实战建议

  1. 重试层级别贪多:3 级(10s/1m/10m)+ DLT 覆盖 99% 的依赖抖动;更多层级只是运维负担。
  2. 重试通道幂等先行:消息进重试就注定可能被处理多次,上 Retry Topic 之前先把 08 章的幂等做好,顺序问题回头查 09 章事故二。
  3. DLT 是告警器不是垃圾桶:只有"进入 DLT 就有人工流程承接",这套模式才成立;否则只是把丢消息换了个更隐蔽的姿势。
  4. 延迟消息先看选型再看实现:业务以延迟为核心(订单超时、定时通知批量触发)时,RocketMQ/Asynq 的内置能力远比在 Kafka 上自建可靠;已重仓 Kafka 的团队用方案②/③ 也够。
  5. 把 x-attempt、x-ready-at、x-fail-topic 这些 Header 命名写进团队规范,全链路排障时这些字段就是黑匣子。

参考资料