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就已经有持久化保证转投不应该再把可靠性做低我统一用 acksall。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 的选择上生产者侧设置 acksall 并保证 min.insync.replicas2等于同时接受了“至少两个副本写入成功”才算成功。延迟消息丢了不能接受重试消息丢了等于一次失败被吞掉死信消息丢了等于排查线索断了所以三个队列的可靠性标准应该一致。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 监控指标与告警阀门我重点盯四组指标。第一组是延迟链路的消费 lagdelay 档位 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 和失败原因可视化这套方案就已经比很多靠临时实现又无法解释消息去向的方案可靠得多。