Kafka 本身没有原生提供延迟消息、死信队列和重试队列,但借助它的分区机制、消费位移模型和 topic 存储能力,完全能搭出一套能上生产环境的队列体系。这篇文章我把这套方案的设计思路、核心配置和踩坑记录完整写出来,给同样被消息可靠性困扰的朋友一个参考。
做后端这些年,凡是涉及订单超时、支付回调、对账补偿这类业务,几乎都绕不开延迟队列、死信队列、重试队列这三件事。Kafka 没有原生提供“延迟消息”“消费失败重试”“死信存储”这些东西,但借助它的分区机制、消费位移模型和 topic 的存储能力,完全能搭出一套能上生产环境的队列体系。这套方案我实际上线用了一段时间,稳定性和排查效率都扛住了业务压力,所以把它拆开讲一遍。正在选型消息中间件、准备在 Kafka 上构建可靠投递链路的开发者,或者已经被消息乱序、重试风暴、死信堆积折磨过的同学,这篇文章应该能给你一套可直接落地的参考。
1. 为什么选择用 Kafka 构建这三类队列
1.1 一个消息从产生到最终处理要经过哪些环节
从业务视角看,三个队列对应三类明确的诉求。延迟队列解决的是“现在不发、到点再发”的问题,比如订单下单后 15 分钟未支付自动关闭,直播开播前 30 分钟给用户发提醒,活动结束后延迟一段时间再进入结算流程。重试队列解决的是“发送或处理失败后不能放弃,要按照退避策略再来几次”的问题,比如调用下游接口偶发超时、网络抖动、库存扣减暂时失败。死信队列解决的是“重试到上限还是失败,不能再占着在线链路,需要归档”的问题。
把这三个需求串起来看,其实就是一条消息的生命周期:消息先进入延迟暂存区,到期后转投业务处理;业务消费失败后进入重试环节,重试期间冷落一段时间再回到业务;达到最大重试次数后进入死信归档。一个设计良好的队列体系,本质上就是在 Kafka 之上把这条流水线用 topic 分组、消费组隔离和偏移量提交策略搭起来。
1.2 Kafka 的哪些特性撑起了这套方案
Kafka 适合干这件事,核心是三条。第一,分区有序。同一个 key 的消息会投到同一个分区,业务系统用业务单号做 key,同一笔订单的回调、重试、状态更新天然按顺序落盘。这个特性对重试链路极其重要。第二,消费位移机制灵活。手动提交 offset 的前提下,消息是“处理完再提交”还是“只要读到就提交”,可以由业务自己掌控。第三,topic 本身就是持久化存储。延迟消息在到期之前可以一直躺在 Kafka 里,不需要额外的数据库或者 Redis 来保存待执行任务。
但必须泼盆冷水:Kafka 不是为延迟调度设计的。它没有消息级 TTL,没有排队等待语义,没有优先级。要做延迟队列,必须引入“转投”的思路:不让消息在消费端干等,而是用一个延迟档位的 topic 作为暂存区,到期后重新投递到目标业务 topic。所有时间控制都发生在应用层,而不是 broker 层。
1.3 整体架构:三级 Topic 流水线模型
我实际采用的模型是三级 topic 流水线:delay 暂存区-> biz 业务处理 -> retry 重试区 -> dlq 死信区。延迟和重试在物理上不一定必须分开,但从运维和监控的角度分开更好,每个环节的消费组、lag、失败原因都能独立观测。
链路大致是:生产者在产生消息时,如果业务要求延后执行,就判断延迟时长落在哪个档位,投递到对应的 delay_xxx topic;延迟消费组只做一件事,轮询延迟 topic,消息到期就转投业务 biz_xxx topic;业务消费者消费 biz_xxx 成功后提交 offset,失败时根据异常类型决定投回 retry_xxx 还是直接投递 dlq_xxx;重试消费组负责从 retry_xxx 中取出到期消息,重新转投 biz_xxx;当消息重试次数达到阈值,由重试消费组统一写入 dlq_xxx。
这套模型最核心的好处是:每一环的输入输出都是 Kafka topic,没有额外依赖。链路里任何一个环节阻塞,消息都不会丢,只是在 topic 里躺着。对运维来说,看 lag 就能定位是延迟环节卡了还是重试环节卡了。
2. 延迟队列:最容易被设计复杂的部分
2.1 两种延迟方案的取舍:单 Topic 轮询 vs 分级延迟 Topic
网上的延迟队列实现主流就两类。
一类是单 topic 加消费端判断。所有延迟消息进同一个 delay 主题,消费者 poll 到消息后判断当前时间是否到了执行时间,没到就 sleep 一段时间再继续 poll,或者把消息重新塞回本地内存。另一类是分级延迟 topic,事先定义一组延迟档位,比如 5s、30s、1min、5min、30min、1h、24h,生产者按目标延迟把消息投递到对应档位的 topic,每个档位由独立消费组负责探测和转投。
我强烈建议生产环境选择分级延迟 topic。原因有三个。第一,单 topic 方案里,如果一条延迟 24 小时的消息在队列头部,后面所有秒级消息都要被它拖住,除非你做时间戳排序加跳表这类复杂结构,但在 Kafka 消费者模型下很难做好。第二,单 topic 的所有消息由同一个消费组处理,处理能力会被延迟最久的消息拉平,吞吐上不去。第三,分级之后每个档位可以独立扩缩容,秒级档位的消费组可以多开分区,天级档位少开几个线程,资源利用率更均匀。
2.2 消息结构与延迟档位设计
延迟档位的选择要结合业务场景定,不需要太细。我常用的档位是 5s、30s、1min、5min、30min、1h、6h、24h,基本覆盖绝大多数业务。档位过细会让 topic 数量膨胀,broker 的文件句柄和副本开销都会上升;档位过粗又会让延迟误差变大。比如一个 1 分 20 秒的任务,如果只有 1min 和 5min 两档,要么提前 20 秒执行,要么晚 3 分 40 秒执行,业务往往都接受不了。
为了让延迟档位和业务需求匹配,消息结构里必须带上精确的执行时间,而不是只依赖档位。我建议每个延迟消息至少包含这些字段:
| 字段 | 作用 |
|---|---|
| messageId | 全局唯一标识,贯穿整个生命周期,禁止在重试转投时重新生成 |
| bizId | 业务主键,用于排序和幂等 |
| bizType | 业务类型,转投后业务消费者靠它路由 |
| payload | 业务载荷 |
| delayTime | 目标执行时间戳,以它为准判断到期 |
| retryCount | 当前重试次数 |
| maxRetryCount | 最大重试次数 |
消费端在判断到期时,永远以 delayTime 为准,topic 档位只是初筛。这样即使生产者投错了档位,也只是消费端多等一会儿,不会提前执行。
2.3 到期转投的逻辑与 offset 策略
延迟消费组的核心逻辑是:poll 到消息,判断 delayTime 是否已到,已到则转投业务 topic 并提交 offset,未到则视情况处理。未到期的消息怎么处理,这里有一个关键设计点。
最简单的做法是未到期就不提交 offset,等消息再次 poll 时重新读到,时间到了再转投。但这样做有一个隐患:如果 poll 之后处理时间较长,或者消费组发生重平衡,未提交 offset 的分区会被重新消费,已经转投成功的消息会被重复转投。避免重复的惯用手段是消费幂等,但延迟消息转投并不总能做到幂等。
我更推荐的另一套做法是:每次 poll 都正常提交 offset,未到期的消息不立即处理,而是把消息对象放进消费者进程内的一个延迟队列结构里,到了 delayTime 再执行投递。如果担心进程重启丢内存,可以把未到期消息重新投递回当前档位 topic,再提交 offset。这两种方案各有取舍:进程内方案依赖内存状态,开发复杂度低但重启会丢;重新投递方案多了一次 broker 读写但可靠性更高。我实际用的时候,对秒级和分钟级消息用进程内延迟队列,对小时级以上消息用重新投递方案,兼顾性能和可靠性。
注意:延迟档位 topic 不要设置很长的保留时间。delay_5s 这个 topic 里的消息如果因为消费故障积压了几个小时,其实已经失去延迟执行的意义,统一设置 retention.ms 为 6 到 24 小时即可,避免无效数据长期占用磁盘。
转投业务 topic 时,生产者要设置合适的 acks。延迟环节的消息只要进过 Kafka,就已经有持久化保证,转投不应该再把可靠性做低,我统一用 acks=all。
3. 重试队列与死信队列:把失败变成可控流程
3.1 消费失败后的分支判断
业务消费者处理 biz_xxx 时抛异常,第一步不是重投,而是判断异常类型。临时性异常,比如下游接口超时、限流返回 429、数据库连接抖动,这类错误值得重试,应该进入重试队列。永久性异常,比如请求参数非法、业务状态已经变更导致无法处理、消息里的数据格式损坏,这类错误无论重试多少次都会失败,应该直接进入死信队列。
有人会在业务消费端把异常类型判断和投递逻辑耦合在一起,我建议抽象成一个统一的重试投递器。业务消费代码只需要抛出 BizException,投递器根据异常里的 retriable 标记决定是投 retry 还是投 dlq。这样业务逻辑里不会散落一堆 KafkaTemplate 调用,后续调整重试策略也只需要改一处。
3.2 重试次数、退避策略与顺序性矛盾
重试次数必须跟着消息走,不能放在消费者内存里。消费者实例可能重启,多实例并发消费时次数还会分散。我的做法是把 retryCount 写在消息体里,每次转投前自增。重试消费组读到 retry_xxx 的消息时,先判断 retryCount 是否达到 maxRetryCount,达到就投递 dlq_xxx,没达到就按退避策略计算下一次执行时间。
退避策略我用的是指数退避加随机抖动。核心公式:delay = baseMs * 2^retryCount + random(0, 500)。baseMs 通常取 1000,那么第一次重试延迟约 1 秒,第二次约 2 秒,第三次约 4 秒,以此类推。随机抖动是为了避免大量消息同时到期形成尖峰流量。最大重试次数一般控制在 5 到 7 次,超过 7 次以后,指数退避的时间已经很长,业务上通常已经不能接受,不如尽早进死信人工介入。
这里必须正视一个矛盾:重试会破坏消息的时序。比如同一笔订单先有一条创建订单消息和一条支付成功消息,如果支付成功先被消费,依赖的创建订单又因为临时故障进入重试,那么重试回来以后顺序就反了。对于强顺序业务,要保证同一个 bizId 的所有消息使用同一个 key 投递,让它们落在同一分区。分区内 Kafka 保证写序,消费时按 offset 处理,重试消息重新入队后依然在分区尾部,所以真正的顺序保证仍然依赖业务侧的最终一致,不是简单依赖重试队列顺序。
3.3 死信队列的数据契约与救济通道
死信队列经常被当成垃圾箱,这是最需要纠正的认知。死信的价值有两个:一是把无法处理的消息从在线链路剥离,避免阻塞正常业务;二是保留完整的失败现场,供事后排查和数据修复。
因此写入 dlq_xxx 的消息不能只存原始业务内容,必须把重试信息一起带过去。我常用的死信消息结构是:messageId、bizId、bizType、payload、原始消息写入时间、首次消费失败时间、最后一次失败原因、重试次数、投递死信时间、原始 topic 和 partition 信息。丢进去之后再配一个死信巡检程序,按 bizType 分组统计,超过阈值的自动告警。
还有一个设计细节:谁负责写死信。我建议由重试消费组来写,而不是业务消费者直接写。原因是重试次数的判断逻辑放在重试消费组,可以保证“先重试、再死信”这个顺序在代码里只有一条执行路径。如果业务消费者也能直接投死信,很容易出现某次异常被误判为永久失败,实际只是临时故障。
4. 生产环境参数调优与踩坑记录
4.1 分区数、副本因子与确认机制的权衡
分区数的设定是这套体系里最容易被拍脑袋决定的事。我的经验是:业务 topic 的分区数结合预期的消费吞吐量来定,每个分区大约能承担每秒数百到上千条的消息处理能力;延迟档位 topic 的分区数可以比业务 topic 少一些,因为延迟消费组只需要做探测和转投,计算量小;重试 topic 和死信 topic 通常 2 到 3 个分区就够,它们的流量远小于业务 topic。
分区数一旦确定,后续调整会引入消息重分布,对顺序性影响很大,所以设计阶段定好分区策略比后期扩容重要得多。一个业务单号只用一个固定 key 进入对应分区,消费者实例数量建议等于分区数,避免出现一个消费者空闲而另一个消费者积压的倾斜问题。
副本因子在 Kafka 里默认可以是 3。延迟队列、重试队列和死信队列的可靠性依赖的就是副本机制,生产环境不要用单副本。acks 的选择上,生产者侧设置 acks=all 并保证 min.insync.replicas=2,等于同时接受了“至少两个副本写入成功”才算成功。延迟消息丢了不能接受,重试消息丢了等于一次失败被吞掉,死信消息丢了等于排查线索断了,所以三个队列的可靠性标准应该一致。
4.2 五类高频故障的现场复盘
第一类:延迟消息未到期被转投。现象是业务消息比预期时间提前很多到达。原因是延迟消费组在 poll 到消息后没有判断 delayTime 就开始转投。这个看起来低级,但实际很多初版实现都会犯。排查时先看消息体里的时间戳,再看消费代码的到期判断是否真的生效。
第二类:重试消息和业务消息共用一个消费组。现象是重试消息转到 biz_xxx 后根本没人消费,lag 持续增长。原因是 retry 转投和 biz 消费用了相同的 group.id,消费者认为这些消息是自己所在的组投递的,直接把 offset 当作已经处理提交。重试消费组和业务消费组必须使用不同的 group.id,各管各的消费位点。
第三类:消息无限重试闭环。现象是某一条消息在 biz 和 retry 两个 topic 之间反复转投。原因是重试消费组没有在转投前检查 retryCount。定位方法就是打印 retryCount,连续观察几次投递记录,然后加上 maxRetryCount 判断,超过阈值直接投 dlq。
第四类:session.timeout.ms 配置太短导致频繁重平衡。现象是消费组不断 rebalance,消费速度很慢。原因是消费者的 poll 处理耗时超过了 session 超时时间,broker 认为消费者下线。解决方法是调大 session.timeout.ms,或者把处理逻辑异步化,减少单次 poll 的阻塞时间。
第五类:DLQ 消息级联堆积。现象是 dlq_xxx 的 lag 每天增长,没人处理,磁盘占用升高。根因是死信队列只写不读。DLQ 必须设计消费程序,哪怕消费后只是落库和告警,也要让消息流动起来,否则时间长了会拖垮 broker。
4.3 监控指标与告警阀门
我重点盯四组指标。第一组是延迟链路的消费 lag,delay 档位 topic 的 lag 如果持续增长,说明延迟消费组出现故障,或者分区数不够。第二组是 retry 队列的入队速率和出队速率,如果入队速率持续大于出队速率,说明业务消费失败率在上升,需要检查下游服务。第三组是 DLQ 的写入速率,这个指标平时应该接近 0,一旦突然有量,大概率是业务逻辑改坏或外部依赖大面积故障。第四组是消息从投递到最终消费的滞留时间,通过消费端记录消息时间戳和当前时间差值来统计。
工具上,用 kafka-consumer-groups 命令行脚本就能看消费组 lag,配合 Kafka 自带的 JMX 指标或者 Prometheus 采集器做趋势告警都可以。关键是阈值要结合业务设定,延迟档位秒级的 lag 超过几百条就要告警,DLQ 只要出现新增消息就应该告警,不能等积压到几万条才发现。
5. 常见问题定位与排查速查
5.1 延迟时间不准确怎么定位
延迟不准确的常见原因有三个。第一个是服务器时钟不同步。延迟消息是否到期依赖当前时间和消息里的 delayTime 比较,如果生产者和消费者的服务器时钟差了几十秒,延迟自然不准,先同步所有节点的时间。第二个是 poll 间隔太长。Kafka 消费者每次 poll 拉取一批消息,如果 max.poll.interval.ms 配得很大,或者消费逻辑里处理动作过慢,转投动作会集中在一个周期之后爆发,延迟精度被拉低。第三个是档位不匹配。比如消息要延迟 3 分钟,但业务投到了 30 分钟档位,那不是延迟时间不准,是生产者选档逻辑错了,要看生产者代码里的档位选择函数。
5.2 消息无限重试的闭环问题
无限重试的本质是状态没有被有效终结。重试消费组转投回 biz_xxx 之前,必须检查 retryCount 是否大于等于 maxRetryCount。这个检查不能放在业务消费者里,因为业务消费者如果每次都抛异常,永远不会有机会执行到判断逻辑。把判断放到重试消费组的转投逻辑里,一旦超过阈值直接写死信。
还有一类隐蔽的循环不是代码循环而是数据循环。比如重试消息在 retry 和 biz 之间来回转投,但 messageId 每次都被重写,导致无法区分这条消息是不是重试过的。消息在写入 retry 时一定不要重新生成 messageId,用原始 messageId 贯穿整个生命周期。
5.3 死信堆积后的处理手段
如果死信已经堆积起来,先不要急着清空,搞清楚原因再动手。第一步看 DLQ 消息里的失败原因,按 bizType 和异常类型分组统计,判断是单条数据问题还是批量数据问题。第二步根据失败原因分类处理:临时故障类的死信可以通过补偿程序重新投递到业务 topic,注意重新投递时要重置 retryCount,避免再次进入死信循环;数据错误类的死信需要先人工修复数据,再触发补偿。第三步是补一套巡检工具,每天定时扫描 DLQ,无消费死信告警。
5.4 排查速查表
| 现象 | 可能原因 | 快速排查手段 | 解决方案 |
|---|---|---|---|
| 延迟消息提前到达 | 到期判断未生效或未按 delayTime 判断 | 打印消息体里的 delayTime 和当前时间 | 统一按 delayTime 判断到期,delay 档位仅做初筛 |
| 延迟消息迟迟不执行 | 分区数太少或消费组 lag 持续增长 | 查看 delay 档位 topic 的 lag | 扩容分区数并增加延迟消费组实例 |
| 重试消息没被消费 | 重试消费组和业务消费组 group.id 相同 | 查看两个消费组的 group.id | 使用不同消费组隔离位点 |
| 消息无限重试 | 转投前未检查 retryCount | 观察同一 messageId 的多次投递记录 | 在重试消费组统一判断最大重试次数 |
| 死信堆积 | DLQ 无消费者或消费逻辑异常 | 查看 dlq_xxx 的 consumer lag | 建设 DLQ 巡检消费程序并监控写入速率 |
| 消费组频繁重平衡 | session.timeout.ms 过短或 poll 处理超时 | 查看 broker 端 rebalance 日志 | 调大 session 超时、优化消费处理耗时 |
| 同一业务消息乱序 | 重试消息和普通消息 hash 到不同分区 | 检查消息 key 是否统一使用 bizId | 统一 key 保证进入同一分区,重试用原 key |
这个表在我排查问题的时候几乎每一条都踩过,建议直接存一份,后面遇到类似现象先从表里对号入座。
最后分享一个实际体会。这套方案在 Kafka 上跑了一段时间后,我最大的感受是:延迟队列、死信队列、重试队列本质上不是 Kafka 的功能,而是一套围绕“转投”和“消费位移”设计出来的应用层协议。只要消息结构里把 messageId、bizId、delayTime、retryCount 这些字段定义清楚,不同团队之间用 topic 名称就能达成约定,联调成本很低。如果一开始就想着把延迟精度做到毫秒级,或者让重试完全不打乱顺序,很容易把系统改复杂。先接受 Kafka 在这类场景下的能力边界,再把每个环节的 lag 和失败原因可视化,这套方案就已经比很多靠临时实现又无法解释消息去向的方案可靠得多。