我们团队在某个核心订单系统里被一个问题反复折磨:下游支付回调有时候延迟,有时候失败,还有极端情况下消息根本没法处理。这些问题叠加在一起,就是订单状态迟迟不变、用户投诉不断增加、人工后台天天有人翻数据。后来我们决定在现有 Kafka 技术栈上,把延迟队列、重试队列、死信队列这三件套一次性补齐,彻底把消息治理这件事做了个闭环。这也是我今天想跟你聊的内容:基于 Kafka 怎么实现这三个队列,为什么这么选,以及落地过程中你会踩到哪些坑。
这套方案适合有一年以上 Kafka 使用经验,但还没系统做过消息治理的团队参考。我先说结论:用 Kafka 实现延迟、重试、死信队列完全可行,性能和可靠性都有保证,但前提是你要理解 Kafka 的模型约束,接受它不像某些消息中间件自带延迟等级、死信交换机,需要我们自己用代码补上这一层。本文会把三个队列的语义拆清楚、给出可运行的方案,然后把我在实际项目中遇到的典型问题一并记录下来。
1. 先弄清楚三个队列到底解决什么问题
我们做技术方案,最忌讳直接跳到"怎么实现",结果写了一堆代码却没对准业务痛点。延迟、重试、死信这三类队列,本质上对应的是消息处理生命周期里的三个典型困境:消息不该现在被处理、消息暂时处理不了、消息永远处理不了。
1.1 延迟队列:解决"不该现在执行"的问题
延迟队列最常见的场景就是订单超时未支付自动关闭。用户下单以后,系统期望在比如 30 分钟后检查一次支付状态,没支付就自动取消。如果这个消息立刻发给消费者,消费者会立刻执行关闭逻辑,那所有未支付的订单都会被秒关,这显然不对。所以我们需要一个机制,让这条消息"等到 30 分钟后再被消费者看到"。
类似的场景还有:直播开播前给订阅用户发送提醒,提前 5 分钟触发;优惠券过期前 24 小时发送即将到期的通知;预约类的短信或站内信在指定时间点触达。这些都是延迟队列的典型业务模型。延迟队列的核心价值,就是让消息能够按预期的时间点被消费,而不是消费方收到就处理。
需要注意的是,延迟队列不等于定时任务。定时任务通常是遍历数据然后执行批量操作,延迟队列则是单个消息级别的调度,粒度更细,耦合度更低,消息本身就是任务单元。
1.2 重试队列:解决"暂时处理不了"的问题
重试队列服务的场景非常现实:消费者从 Kafka 拉取消息后,需要调用下游的订单服务、库存服务、风控接口或者第三方支付平台。这些接口不可能永远稳定,总会有超时、限流、返回 5xx 的时候。
如果一遇到接口异常就丢弃消息,结果就是数据丢失,订单状态在多个系统之间长期不一致。如果同一线程原地重试几次再放弃,又可能阻塞消费速度,导致整个消费者组积压。更麻烦的是,有些下游失败是短暂的,比如网络抖动、数据库连接池满了,等 30 秒再试就能恢复,但原地阻塞等待又会让消费线程干等。
重试队列要解决的核心问题,就是把"失败后重新尝试"这件事从消费主链路中搬出来。消费失败后,消息不是被丢弃,而是进入一个重试专用的通道,等待指定的间隔后被再次提交消费。这样下游抖动不会影响消费主场景的速度,消息也不会被轻易放弃。
1.3 死信队列:解决"永远处理不了"的问题
死信队列是最后一道兜底。消息可能因为各种原因处理失败:反序列化失败、消息结构不合法、业务规则已经改变导致无法完成处理、或者重试了 N 次仍然失败。这种情况下,如果继续在正常队列或重试队列里死循环,只会白白消耗资源、堆积日志、拖延后续消息的处理。
这时候就应该把消息"判死刑",投递到一个单独的死信队列中。死信队列里的消息不应该被常规消费者继续重试,而应该由专门的人工补偿程序去读取、分析、修复,或者触发告警让值班同学介入。
死信队列的价值在于:既保留了一条消息的所有上下文,又避免它干扰正常链路。我们从死信队列里能查到这条消息来自哪个原始业务、失败原因是什么、重试了多少次、原始 offset 是多少,这就是问题排查和系统治理的依据。
这三个队列组合在一起,才构成一套完整的消息处理可靠方案。延迟队列控制执行时机,重试队列容忍瞬时故障,死信队列兜底不可恢复的异常,三者缺一不可。
2. 为什么选 Kafka 而不是专用消息中间件
我知道聊到延迟队列、死信队列,很多人第一反应是 RabbitMQ 的 TTL 死信交换机,或 RocketMQ 的延迟等级。今天拿 Kafka 来做,不是因为 Kafka 在这些功能上开箱即用,而是技术选型从来不是单项功能对比,还要看现有技术栈、运维能力和迁移成本。
2.1 几种常见方案的横向对比
先把 RabbitMQ、RocketMQ 和 Kafka 在这三个需求上的表现拉出来说说。
RabbitMQ 方案:RabbitMQ 天然支持消息 TTL 和死信交换机(DLX)。消息设置过期时间后,如果没被消费会进入死信交换机,再路由到另一个队列。这在语义上非常贴合延迟和死信场景,很多团队早期用它实现延迟队列。缺点是 RabbitMQ 的吞吐量相对 Kafka 低不少,消息堆积能力也有限,大量消息积压时内存压力明显。而且 RabbitMQ 的延迟基于 TTL,同一队列里消息若过期时间不同,会出现"队头阻塞"问题。
RocketMQ 方案:RocketMQ 原生支持延迟等级,比如 1s、5s、10s、30s、1m,到最大几个小时的固定等级。它实现得非常优雅,生产者指定延迟等级,Broker 端会自动做延迟投递。同时也支持事务消息,对重试和死信也有一套半内置的方案。但 RocketMQ 和 Kafka 不是同一个生态,如果团队基础设施已经全面围绕 Kafka 建立,引入 RocketMQ 意味着额外的集群、监听和运维成本。
Kafka 方案:Kafka 原生并不提供上述任何能力,它只有一个追加日志模型:生产者写入,消费者按 offset 顺序拉取。但 Kafka 有高吞吐、海量堆积、分区顺序、消费组水平扩展这些能力。延迟、重试、死信都可以在上层用代码实现。我们最终选择 Kafka,是因为团队不想为了这几个队列特性引入一套新的中间件,而是在现有 Kafka 集群上,用多 Topic 加代码调度来补全这三个能力。
我用一个表格把选型关键点列出来,方便你结合自己的处境判断。
| 对比项 | RabbitMQ | RocketMQ | Kafka |
|---|---|---|---|
| 延迟消息 | TTL+DLX,存在队头阻塞 | 内置延迟等级 | 需自研调度 |
| 死信队列 | 内置 DLX | 半内置 | 需自建 Topic |
| 重试机制 | 需手动设计 | 支持 | 需自建 Topic |
| 吞吐量 | 中 | 较高 | 最高 |
| 堆积能力 | 一般 | 较好 | 极强 |
| 运维成本 | 独立集群 | 独立集群 | 复用现有集群 |
| 消息回溯 | 有限 | 有限 | 按 offset 灵活回溯 |
如果你所在团队已经有 Kafka 集群,并且 Kafka 承载着绝大多数业务消息,那在 Kafka 上自研这三个功能,远比再引入一套新中间件划算。
2.2 自研方案的总体架构思路
在 Kafka 上实现延迟、重试、死信,整体思路其实非常统一:用多个 Topic 分别承担不同的语义,然后用生产者或消费者代码控制消息的流转路径。
具体来说,我会在系统里规划这样几类 Topic:
延迟专用 Topic:存放当前还不该被处理的消息,消息体里带上期望执行的时间戳。由一个延迟调度消费者负责扫描,到期就转发到真正的业务处理 Topic。
业务处理 Topic:消费者真正处理业务的地方,也是消息的主战场。
重试 Topic:存放处理失败但还有机会恢复的消息,可能按不同的延迟档位拆分成多个 Topic,比如 5 秒、30 秒、5 分钟。
死信 Topic:存放确认无法处理的消息。
这里要特别说明一点:消息在不同的 Topic 之间流转,往往是靠一个小的转发逻辑来完成的。比如延迟 Topic 扫描到一条到期消息,不是直接让原本的延迟消费者去执行业务,而是把消息投递到业务处理 Topic。这样做的核心好处是职责分离:延迟调度器只负责"定时",业务消费者只负责"处理",两边的并发配置、消费速率、监控告警可以独立设置,互不干扰。
我之前见过一个反面案例:团队把延迟消息直接发到一个消费者里,消费者拉取后判断时间未到就 Thread.sleep,结果消费者线程全部阻塞在 sleep 上,后续消息全被卡住,消费者提交 offset 超时,最终整个消费组重平衡,消息重复消费一大片。这就是没有把"定时"和"处理"解耦造成的灾难。所以我们的设计里,延迟到期后一律投递到独立的业务处理 Topic。
2.3 Topic 规划与消息结构设计
在动手写代码之前,先把 Topic 规划好。我建议每个业务按独立前缀建 Topic,避免业务之间互相干扰。举个具体例子,订单超时关闭场景:
订单延迟 Topic:topic-order-delay。生产者把下单消息扔进来,消息体里带目标执行时间。
订单处理 Topic:topic-order-handler。真正执行订单关闭逻辑的地方。
订单重试 Topic:比如 topic-order-retry-30s,topic-order-retry-5m,按重试间隔拆分。
订单死信 Topic:topic-order-dead-letter。确认无法处理的订单消息最终落在这里。
消息体格式上,我强烈建议从一开始就设计好,不要等到上线后再补。用 JSON 序列化比较通用,但一定要预留下几个关键字段。
| 字段 | 含义 | 示例 |
|---|---|---|
| msgId | 全局唯一消息 ID,用于幂等 | 订单号 + 时间戳生成的 UUID |
| bizType | 业务类型 | ORDER_CLOSE |
| payload | 原始业务数据 | 订单 ID、用户 ID 等 |
| executeAt | 期望执行时间戳(毫秒) | 1735722600000 |
| retryCount | 已重试次数 | 2 |
| sourceTopic | 原始来源 Topic | topic-order-delay |
| sourceOffset | 原始 offset,便于回溯 | 10234 |
| errorMsg | 最近一次失败原因 | 下游库存接口超时 |
字段不在多,够用就行。但 retryCount 和 sourceTopic、sourceOffset 这三个字段你一定留好,一旦消息走到死信,你反查源头就要靠它们。
3. 延迟队列的两种落地姿势
Kafka 本身不提供延迟消息能力,我们无非是通过 Topic 模型、调度扫描、或者中转投递来模拟。这里我把最实用的两种方案展开讲,你根据业务体量和延迟精度需求选择。
3.1 方案一:单一延迟 Topic 加定时轮询
这个方案最容易理解:生产者把消息发到延迟 Topic,消费者启动一个定时任务,每隔几秒拉取一批消息,检查消息体里的 executeAt 是否已经小于等于当前时间。如果到期,就把消息转发到业务处理 Topic;如果没到期,就丢到下一个轮询周期再处理。
实现层面,可以使用 KafkaConsumer 的 poll 语义,但不要用原生 poll 去逐条 sleep 等待。我的做法是写一个独立的延迟调度消费者,它常规拉取消息,把消息放入一个本地优先级队列(按 executeAt 排序),再有一个单独的调度线程触发到期消息的转发。
这里要给一个具体的参数建议:轮询间隔要根据业务对延迟精度的要求来定。如果只是订单超时关闭这种分钟级精度,调度线程每分钟扫描一次就够了,根本不需要秒级精确。如果是短信定时发送这类秒级触达要求的场景,我建议轮询间隔控制在 1 秒左右,不要低于 500 毫秒,否则空转成本太高。
这个方案的优点是实现简单,Topic 数量少,运维成本低,适合延迟消息量不大、精度要求不高的场景。缺点也很明显:如果延迟消息数量特别大,调度消费者每次要遍历大量未到期消息,无效遍历会占用带宽和 CPU。另外,如果消费者重启,本地优先级队列里的消息会丢失,需要借助 Kafka 自身的 offset 提交机制来确保消息不丢,但具体实现相对繁琐。
我实际测过,这个方案在消息量低于每秒几千条、延迟精度秒级可接受的情况下,效果已经很稳。如果量级再大,建议直接上方案二。
3.2 方案二:多 Topic 延迟槽加定时投递
方案一的核心问题是大数据量下的"无效扫描"。方案二换个思路:不再把所有延迟消息放在一个 Topic 里统一扫描,而是按延迟范围拆分成多个延迟槽,每个槽对应一个 Topic 和一台调度器。
举例来说,我们建这样一组 Topic:delay-5s、delay-30s、delay-1m、delay-30m、delay-1h、delay-24h。生产者发送消息时,根据业务需要的延迟时间,路由到最接近但又不小于目标时间的那个 Topic。对应每个 Topic,有一个独立的调度消费者,它专门负责这一档延迟的消息。比如 delay-5s 的调度消费者每 1 秒拉取一次,快速投递;delay-24h 的调度消费者每 30 分钟甚至每小时拉取一次,没必要频繁空转。
这种设计的优势是显而易见的:每个 Topic 的消息延迟范围收敛,调度器空转率大幅降低,消息量天然被分片,扩展性也好。如果某一档消息量激增,可以只针对这一档增加调度消费者实例,不影响其他档位。
代价是 Topic 数量变多,生产端路由逻辑需要维护一个延迟档位表。另外,延迟档位是离散的,如果业务要求的延迟时间不匹配档位,我们需要约定"向上匹配"策略。举个例子,业务要延迟 3 秒,就投递到 delay-5s;要延迟 10 分钟,就投递到 delay-30m。这里会有一点误差,但大多数业务场景并不需要精确到毫秒。
更接近 RocketMQ 延迟等级的做法,就是把档位表做成配置,生产端计算目标延迟时间后,在配置表里找最小且不小于目标档位的值。RocketMQ 提供的常见档位是 1s、5s、10s、30s、1m、2m、3m、4m、5m、6m、7m、8m、9m、10m、20m、30m、1h、2h,这个表可以直接借鉴。
3.3 延迟精度、消息积压与性能取舍
关于延迟队列,有一个最容易被忽略却极其重要的点:你设置的延迟精度,直接决定了调度器收益和集群负载之间的平衡。
如果业务允许分钟级误差,你就不该把调度器的轮询周期压到 1 秒,否则大量空 poll 白白消耗 CPU。我们实际线上的做法是"分档调优":秒级档位的轮询周期是 1 秒,分钟级档位的轮询周期是 30 秒,小时级档位的轮询周期是 5 分钟。你算一笔账:delay-24h 这个 Topic 一天可能只有几千条消息,如果调度器每 1 秒空转一次,一天的空转次数高达 86400 次,一大半是无用功。把扫描周期拉长到 5 分钟,收益几乎是零损耗的。
消息积压也要单独说。延迟 Topic 的消息本身在 Kafka 中就是长时间不消费的状态,这会被 Kafka 记录为消费滞后(Lag)。这个 Lag 是正常的,但你要在监控系统里把它和真正的异常积压区分开。我们的做法是对不同的 Topic 配置各自的 Lag 告警阈值,延迟 Topic 的告警阈值显著放宽,只监控"有没有消费者存活",而不是"offset 有没有落后"。
你可以在调度转发时增加一个条件判断:消息在延迟 Topic 里已经停留了多久。如果一条消息的停留时间远超预期,比如 delay-5s 的消息 30 分钟都没被转发出去,多半是调度消费者挂了,或者下游处理 Topic 消费积压导致无法写入。这时触发告警,比单纯看 Lag 指标更能定位问题。
4. 重试队列:让失败的消费请求有序重来
重试队列的设计核心,不是简单地把消息发回原 Topic 再消费一遍,而是要设计一套有节奏、有上限、有止损的重试机制。
4.1 重试策略设计:间隔、次数与动态退避
重试策略的设计,直接决定系统在下游故障时的表现。固定的"失败后立即重试"没有意义,因为下游刚刚报错,大概率还处在故障状态,立刻再试反而容易加重下游压力。业界通用的是"指数退避"策略:第一次重试等 5 秒,第二次等 30 秒,第三次等 5 分钟,最多重试 5 次。每一次重试的间隔都在变大,给下游充分的恢复时间。
具体档位可以根据业务敏感度调整。我提供一个通用的配置模板:
| 重试次数 | 延迟间隔 | 对应重试 Topic |
|---|---|---|
| 1 | 5 秒 | topic-retry-5s |
| 2 | 30 秒 | topic-retry-30s |
| 3 | 5 分钟 | topic-retry-5m |
| 4 | 30 分钟 | topic-retry-30m |
| 5 | 2 小时 | topic-retry-2h |
这里有个实践细节:与其让消息在同一个重试 Topic 中反复消费、反复失败、反复更新 retryCount,不如拆分为多个重试 Topic,分别对不同档位的消息进行处理。这样做的直接好处是,不同间隔的重试彼此隔离,某档重试 Topic 积压了不会影响其他档位;而且消费线程可以根据消息所在 Topic 确定性地知道该等多久,不用从消息体里解析重试次数再计算 sleep 时间。
4.2 基于手动 Ack 加重试 Topic 的消费模型
Kafka 默认是自动提交 offset,也就是说消费者只要 poll 到消息,offset 就会往后移动,哪怕业务逻辑抛异常,这条消息也不会再被消费到。所以在重试场景中,必须关掉自动提交,改为手动提交。
我的实践模型是这样一个循环流程:
第一步,业务消费者从处理 Topic 拉取消息,执行业务逻辑。
第二步,如果执行成功,提交 offset,消息生命周期结束。
第三步,如果执行失败,先判断异常类型。如果是参数错误、数据不合法这类不可重试异常,直接投递到死信 Topic,然后提交 offset。如果是下游超时、限流这类可重试异常,读取消息体里的 retryCount,如果已经超过最大重试次数,投递到死信 Topic;否则把 retryCount 加 1,投递到对应的重试 Topic,然后提交当前 offset。
为什么失败后还要提交 offset?因为消息已经"被处理过"了,虽然处理失败,但它被转移到了重试通道,不再属于当前消费流程的处理范围。如果不提交,该消息会反复出现在当前消费者里,造成无限循环。这一点是新手最容易踩的坑。
重试 Topic 的消费者则按各自的间隔策略去消费,重新调用业务处理逻辑。注意:重试 Topic 消费者的业务处理逻辑,和主处理 Topic 消费者的业务处理逻辑应当保持一致,不能重写一份有出入的处理方法。我们通常把核心处理方法抽成一个公共类,两边都调用同一个方法,避免"主链路成功、重试链路失败"这类判断分叉问题。
4.3 幂等性:重试之前必须解决重复消费
重试机制引入后,消息被消费多次是必然事件,而且是设计如此。如果业务方法本身不具备幂等性,重试就是一个定时炸弹。
以订单关闭场景为例,原本订单状态是"待支付",关闭后变成"已关闭"。如果关闭动作被重复执行,第二次执行时订单已经是"已关闭",再关一次理论上无害。但如果你的处理方法里有金额扣减、库存回滚、发送通知这类副作用操作,重复执行就会造成严重问题。
幂等的最简做法是为每条消息生成全局唯一 msgId,在处理前用 Redis 或数据库记录这个 msgId 的处理状态。处理逻辑先尝试写入状态"处理中",如果发现已经处理过就直接跳过。如果是数据库操作,可以在订单表上加一个 lastProcessMsgId 字段,或者用业务表自身的唯一约束来实现幂等。
我建议在重试场景中一定要用"先记录、后处理"的顺序,不要先处理再记录,否则处理过程中崩溃,重试时无法判断是否已执行。
5. 死信队列:最后的兜底与排查手段
很多团队把死信队列当成"垃圾桶",只管往里扔,从来不看。这是非常危险的做法。死信队列的价值在于它是一条蛛丝马迹链,能帮你定位线上问题。要让死信队列真正发挥作用,必须在设计上做到两点:消息内容足够完整、消费和告警链路足够完善。
5.1 什么样的消息该进死信
不是所有失败消息都应该进死信,死信是"止损"而不是"甩锅"。我的分类标准是三道闸门。
第一道闸门,不可重试的异常。消息反序列化失败、JSON 解析失败、业务 ID 缺失、枚举值不合法,这类问题不管你重试多少次结果都一样。它们应该在第一次失败时就进死信,节省所有后续无意义的消费。
第二道闸门,重试次数耗尽。消息本身没有问题,只是下游一直不可用,或者数据依赖不满足。这类消息在走完所有重试档位后,如果仍然失败,就应该进死信,不能无限重试。无限重试的后果是消息永远在系统里流转,占用资源,而且真正的故障原因反而不容易暴露出来。
第三道闸门,业务规则不再满足的场景。比如一条优惠券发放消息,在重试期间用户已经注销了账号,这时候即使重试成功也没有意义。这类"过期业务"放在死信中,由人工或自动补偿程序判断如何处理。
5.2 死信消息的字段设计与转储
死信消息的内容会直接决定排查效率。我见过很多团队的死信 Topic 里只有原始 payload,没有来源、没有原因,出了线上问题根本没法查。要解决这个问题,投递死信时必须把关键链路信息补全。
实际项目中,我会在死信消息体里保留:
原始业务 payload,一条消息最核心的数据。
sourceTopic 和 sourceOffset,记录它从哪个 Topic 的哪个位置来,配合 Kafka 的 offset 查询 API 可以找到原始上下文。
errorMsg,最近一次异常堆栈信息。
retryCount,累计重试次数,用于判断失败是否在合理次数内。
deadLetterReason,进死信的原因分类,比如 RETRY_EXHAUSTED、DESERIALIZE_FAILED、BIZ_RULE_EXPIRED。
最后投递时间,方便值班人员判断这条消息是什么时候进入死信的。
这些字段不是拍脑袋定的,而是我在实际排障过程中反推出来的。有一次线上出现一批订单消息进入死信,因为没记录 sourceTopic,我们花了一个多小时才定位到是某个晚上的发布变更导致消息体字段变更,而不是消费逻辑故障。从那以后,强制要求所有死信消息必须携带这些字段。
5.3 死信 Topic 的监控与人工处理链路
死信队列如果只是"存着等通知",很容易变成无人区。我建议建立这样一套监控和处理机制:
死信 Topic 的消息数量作为核心告警指标。正常情况下,一个健康的业务死信量应该接近于零。如果短时间内大量消息涌入死信 Topic,一定是业务链路出了问题,比如消息体字段被改坏了、下游接口大面积故障、或者代码里抛了不该抛的异常。告警阈值可以根据业务量设置,比如单日超过 100 条就触发 P1 告警。
死信消费者只做两件事:转储和分析。它把死信消息从 Kafka 中读出,写入 ES 或数据库,方便按 msgId、业务类型、时间范围检索;同时生成死信报表,统计死信原因分布。注意,死信消费者不应该自动把消息发送回原 Topic 重试,这会掩盖问题,也可能导致死信队列和重试队列之间无限循环。
人工处理方面,我建议配置一个简易的补偿管理后台,运营或开发人员搜到具体 msgId 后,能查看死信详情、标记处理状态、触发一次人工重放。重放时可以直接调用业务公共处理类,而不是发送回 Kafka。这能避免重放过程中再次进入死信循环。
6. 完整实现:从生产者到消费者分别怎么写
概念讲得再多,不如一段能跑的代码来得实在。我基于 Java 和原生 Kafka 客户端给出一个最小可运行实现,你把它套进自己的业务骨架里,稍微改改就能用。
6.1 生产端代码:发送延迟与优先级消息
生产者这个环节并不复杂,核心是给消息设置一个足够全局唯一的 msgId,并带上 executeAt 时间戳。这里我直接展示投递延迟消息的代码。
public class DelayMessageProducer { private static final String DELAY_TOPIC = "topic-order-delay"; private final KafkaProducer<String, String> producer; public DelayMessageProducer() { Properties props = new Properties(); props.put(ProducerConfig.BOOTSTRAP_SERVERS_CONFIG, "192.168.1.10:9092"); props.put(ProducerConfig.KEY_SERIALIZER_CLASS_CONFIG, StringSerializer.class.getName()); props.put(ProducerConfig.VALUE_SERIALIZER_CLASS_CONFIG, StringSerializer.class.getName()); props.put(ProducerConfig.ACKS_CONFIG, "all"); props.put(ProducerConfig.ENABLE_IDEMPOTENCE_CONFIG, true); this.producer = new KafkaProducer<>(props); } public void sendDelayMessage(String bizType, String payload, long delayMillis) { long executeAt = System.currentTimeMillis() + delayMillis; String msgId = UUID.randomUUID().toString(); DelayMessage message = new DelayMessage(); message.setMsgId(msgId); message.setBizType(bizType); message.setPayload(payload); message.setExecuteAt(executeAt); message.setRetryCount(0); ProducerRecord<String, String> record = new ProducerRecord<>( DELAY_TOPIC, bizType, JSON.toJSONString(message)); producer.send(record, (metadata, exception) -> { if (exception != null) { // 生产失败要记录日志并考虑本地重试或落盘 log.error("延迟消息发送失败: {}", msgId, exception); } }); } }关于参数设置,我要多说一句:acks 必须设成 all,enable.idempotence 必须打开。这不是可选项,而是为了保证消息不因网络问题而丢失或者重复。Kafka 的幂等生产者能解决"重试导致消息重复"的问题,加上 msgId 的幂等消费设计,两头都守住,才能做到端到端的不丢不重。
6.2 延迟轮询消费者代码:定时扫描与到期投递
延迟消费者的代码是这个方案里最关键的一环。我用优先级队列方案实现单一 Topic 轮询,逻辑上比较直观,也方便做本地调度。
public class DelayQueueConsumer { private static final String DELAY_TOPIC = "topic-order-delay"; private static final String HANDLER_TOPIC = "topic-order-handler"; private final KafkaConsumer<String, String> consumer; private final KafkaProducer<String, String> producer; private final PriorityBlockingQueue<DelayMessage> pendingQueue = new PriorityBlockingQueue<>(1000, Comparator.comparingLong(DelayMessage::getExecuteAt)); private final ScheduledExecutorService scheduler = Executors.newSingleThreadScheduledExecutor(); // 是否停止标志 private volatile boolean running = true; public DelayQueueConsumer() { Properties props = new Properties(); props.put(ConsumerConfig.BOOTSTRAP_SERVERS_CONFIG, "192.168.1.10:9092"); props.put(ConsumerConfig.GROUP_ID_CONFIG, "delay-handler"); props.put(ConsumerConfig.KEY_DESERIALIZER_CLASS_CONFIG, StringDeserializer.class.getName()); props.put(ConsumerConfig.VALUE_DESERIALIZER_CLASS_CONFIG, StringDeserializer.class.getName()); props.put(ConsumerConfig.ENABLE_AUTO_COMMIT_CONFIG, false); props.put(ConsumerConfig.AUTO_OFFSET_RESET_CONFIG, "earliest"); this.consumer = new KafkaConsumer<>(props); this.producer = new KafkaProducer<>(getProducerProps()); } public void start() { Thread pollThread = new Thread(this::pollLoop, "delay-poll-thread"); pollThread.start(); scheduler.scheduleAtFixedRate(this::dispatchExpiredMessages, 0, 1, TimeUnit.SECONDS); } private void pollLoop() { consumer.subscribe(Collections.singletonList(DELAY_TOPIC)); while (running) { ConsumerRecords<String, String> records = consumer.poll(Duration.ofMillis(500)); for (ConsumerRecord<String, String> record : records) { DelayMessage msg = JSON.parseObject(record.value(), DelayMessage.class); pendingQueue.offer(msg); // 记录一条待处理的延迟消息 pendingMessageStore.put(msg.getMsgId(), msg); } consumer.commitSync(); } } private void dispatchExpiredMessages() { long now = System.currentTimeMillis(); List<DelayMessage> readyMessages = new ArrayList<>(); DelayMessage msg; while ((msg = pendingQueue.peek()) != null && msg.getExecuteAt() <= now) { pendingQueue.poll(); readyMessages.add(msg); } for (DelayMessage readyMsg : readyMessages) { // 转发到业务处理 Topic producer.send(new ProducerRecord<>(HANDLER_TOPIC, readyMsg.getBizType(), JSON.toJSONString(readyMsg)), (metadata, exception) -> { if (exception != null) { log.error("到期消息转发失败: {}", readyMsg.getMsgId(), exception); // 转发失败需要告警,不要静默丢弃 } else { pendingMessageStore.remove(readyMsg.getMsgId()); } }); } producer.flush(); } }这里有一个非常关键的代码细节:poll 到消息后立即 commitSync,还是把消息放入优先级队列并转发成功后再提交?我的建议是:poll 到消息、放入本地队列后立刻提交 offset。因为此时消息还在我们本地的 pendingQueue 里,并未真正投递到下游。即使 Kafka offset 已经提交、进程突然崩溃,本地队列中的消息会丢失,但我们可以通过配置 pendingMessageStore(比如 Redis 或本地磁盘)来保存延迟消息快照,重启后从快照恢复。如果你不想引入额外的快照存储,那就不要过早提交 offset,而是等消息转发成功后再提交,代价是重复消息可能性增加,需要配合 msgId 幂等消费。
两种方案各有利弊。如果消息量不大、业务允许偶发重复,我推荐等到转发成功再提交 offset;如果消息量大、需要精确控制,建议引入 Redis 作为消息快照存储。
6.3 业务处理与重试逻辑代码:判断异常走向
业务消费者负责真正的订单关闭逻辑,也是重试和死信分流发生的地方。我把核心处理流程抽成一个公共方法,主消费和重试消费都调用它。
public class OrderHandlerConsumer { private static final int MAX_RETRY_COUNT = 5; private final Map<Integer, String> retryTopicMap = buildRetryTopicMap(); private final KafkaConsumer<String, String> consumer; private final KafkaProducer<String, String> producer; public void handleMessage(DelayMessage message) { try { // 1. 先做幂等检查 if (idempotentService.isProcessed(message.getMsgId())) { return; } // 2. 执行业务处理:关闭订单、更新状态等 orderService.closeOrder(message.getPayload()); // 3. 记录处理成功的消息 ID idempotentService.markProcessed(message.getMsgId()); // 4. 提交 offset 由外层统一管理 } catch (BizException e) { // 不可重试异常,直接进死信 sendToDeadLetter(message, e.getMessage(), "BIZ_EXCEPTION"); } catch (Exception e) { // 可重试异常,判断次数 int retryCount = message.getRetryCount() + 1; if (retryCount > MAX_RETRY_COUNT) { sendToDeadLetter(message, e.getMessage(), "RETRY_EXHAUSTED"); } else { message.setRetryCount(retryCount); sendToRetryTopic(message); } } } private void sendToRetryTopic(DelayMessage message) { // 根据 retryCount 选择对应的重试 Topic String retryTopic = retryTopicMap.get(message.getRetryCount()); producer.send(new ProducerRecord<>(retryTopic, message.getBizType(), JSON.toJSONString(message))); } private void sendToDeadLetter(DelayMessage message, String errorMsg, String reason) { message.setErrorMsg(errorMsg); message.setDeadLetterReason(reason); producer.send(new ProducerRecord<>(DEAD_LETTER_TOPIC, message.getBizType(), JSON.toJSONString(message))); } }这里我想强调一下幂等检查的位置。很多团队把幂等检查放在外层消费者里,这没有错,但更重要的是要把检查逻辑和业务本身融合:先查数据库中的订单状态,如果已经是"已关闭"就直接跳过;如果业务状态和预期不符,同样跳过。只靠 Redis 里记 msgId 是不够的,因为消息可能来自不同的触发路径,比如人工补偿后台触发的任务和原始重试触发的任务,携带的 msgId 不同,但操作的是同一笔订单。这个时候,业务状态本身的幂等才是最终兜底。
6.4 关键参数与配置清单
代码之外,Kafka 的参数配置同样决定成败。我整理一份我常用的参数清单,你可以对照检查自己的配置。
| 组件 | 参数 | 推荐值 | 说明 |
|---|---|---|---|
| Producer | acks | all | 必须,保证消息写入所有副本 |
| Producer | enable.idempotence | true | 防止生产者重试导致重复 |
| Producer | linger.ms | 5-10 | 适当增加以提升批量吞吐 |
| Producer | max.in.flight.requests.per.connection | 5 | 配合幂等确保顺序 |
| Consumer | enable.auto.commit | false | 必须,手动管理 offset |
| Consumer | max.poll.records | 50-200 | 避免单次拉取过多导致处理超时 |
| Consumer | max.poll.interval.ms | 300000 | 防止业务耗时过长触发重平衡 |
| Broker | retention.ms | 至少 7 天 | 延迟消息需要长留存,便于回溯 |
| Broker | message.max.bytes | 按最大消息体调整 | 避免大消息写入失败 |
有一个参数我必须单独提醒:max.poll.interval.ms。默认值是 5 分钟,如果消费者在处理一条消息时耗时超过这个时间,消费者就会被判定为挂掉,从而触发 Rebalance。在重试场景里,业务方法可能调用下游接口,接口超时如果设置很长,很容易超过这个阈值。我的习惯是把该值设置为 10 分钟以上,同时把业务处理的超时时间控制在 5 分钟以内,给两者之间留出足够的安全余量。
7. 常见问题与排查技巧实录
理论推到这,你一定想知道实际运行中都会遇到哪些问题。我把过去踩过的坑整理成一份速查表,每一类问题后面都附上定位思路和解决方案。
7.1 消息延迟不准,总是提前或延后
延迟不准是这类方案最容易被吐槽的问题,原因大概率不在 Kafka 本身,而在调度逻辑。
一种情况是提前消费。业务要求延迟 30 分钟,生产者投递到了 delay-1h 对应的 Topic,等于提前了半小时。这种情况要检查生产端的档位匹配逻辑:我们约定的是"向上匹配",即目标延迟小于等于该档位就投递到该档位。如果业务方传进来的延迟值是 45 分钟,应该投递到 delay-1h,不是 delay-30m。匹配逻辑要是写反了,就会导致大量消息提前执行。
另一种情况是延后消费。比如 delay-5s 档位的调度器每 30 秒才扫描一次,那消息实际延迟可能是 35 秒。解决方法是检查该档位调度器的扫描周期是否和延迟精度匹配。秒级档位必须用秒级扫描周期,否则延迟误差会让人抓狂。
还有一类隐藏问题:调度消费者所在机器和 Kafka 集群之间的时钟同步。如果调度器机器比 Kafka 慢 10 秒,那执行时间就会晚 10 秒。建议所有服务统一接入时间同步,这问题不大,但容易被忽略。
7.2 消息积压且在多个 Topic 间反复流转
我在线上见过最恐怖的现象是:某条消息一直在重试 Topic 和死信 Topic 之间转圈,怎么都处理不完,还触发了一堆告警。最终定位到的原因是异常分类写错了——把"业务规则冲突"这种不可重试异常,错划分到了可重试的分类里,导致每次重试都失败,失败再重试,直到重试次数耗尽进入死信,死信消费者又因为告警配置不当反复触发人工重放。
要避免这种问题,必须先给异常分类画一条清晰的分界线:哪些异常是可恢复的,哪些是不可恢复的。不可恢复的异常,比如数据校验失败、状态机不合法,绝不进入重试队列。可以在公共异常类上增加一个标记接口,比如 RetryableException 和 NonRetryableException,改造起来比在 catch 块里一长串 if else 要省心得多。
积压问题本身也值得警惕。如果重试 Topic 的 Lag 暴涨,说明下游服务可能已经故障一段时间。此时要抑制重试生产者的投递速度,避免故障下游被打爆。我的做法是给重试消费者增加一个简单的熔断开关:连续失败率达到阈值时暂停消费重试 Topic,并触发告警,人工介入确认下游恢复后再打开开关。
7.3 offset 提交异常导致消息丢或重复
消息丢失和消息重复是 Kafka 消费场景两个绕不开的极端。丢失通常发生在自动提交下业务异常后没有处理;重复通常发生在手动提交 offset 时,先处理业务后提交 offset,处理结束、offset 未提交时消费者挂掉,重启后该消息又被拉到。
坦白讲,在 Kafka 的至少一次语义下,重复消费是不可能从底层根除的,我们只能靠幂等来消解后果。真正要谨慎避开的是"丢消息":处理成功后还没来得及提交 offset,这不是问题,因为会重复消费;但处理失败后你提交了 offset,消息就真的丢了。所以在业务逻辑里,我的铁律是:
如果业务处理成功,先执行所有必要的副作用操作,再提交 offset。
如果业务处理失败,先把消息转移到重试 Topic 或死信 Topic,再提交 offset。
只要发生转移动作,消息总会在某个 Topic 里存在,不会丢失。这个顺序看起来简单,但在并发场景里,代码一旦写成"先 commit 再处理"或"失败直接报错不转移",问题就会立刻出现。
7.4 延迟消息量大时 , 单线程调度器顶不住
方案二的扩展性比方案一好,但即使方案二,单个 Topic 的调度器在极端情况下也可能成为瓶颈。比如整点大促时,秒级档位会瞬间涌入大量延迟消息。
遇到这种情况,不要想着把一个消费者做成多线程就完事,Kafka 的单分区只能被单线程消费。正确做法是按分区扩展:延迟 Topic 设置多个分区,调度消费者组设置多个实例,每个实例负责一个或多个分区的延迟扫描。分发规则上,建议按 bizType 或 msgId 取模,保持同一个业务的消息集中在同一个分区,方便局部顺序处理。
如果消息量实在大到 Kafka 消费者组都无法承载,那么延迟调度这一层可以拆出去做成独立服务:延迟消息写入 Kafka 后,由一个独立的延迟计算引擎把消息分配到内存时间轮,到期后再批量回调 Kafka 生产端写入处理 Topic。这个方案是下一档的架构升级,日常业务一般用不到,但你要知道扩展路径是什么。
最后分享一个实操中的小技巧
关于整个链路,最后想分享一个我们内部一直在用的经验:给每一条消息都增加一个系统级标签字段,比如 sysTag,默认值是空字符串。当消息经历过重试、死信、人工重放之后,每经过一跳就在这个字段上追加一跳标识,比如 RETRY_1、DLQ、REPLAY。排查问题时,只需要看这一个字段,整条消息的流转路径就一目了然,配合日志链路能快速定位到底是哪一环出了问题。
这个技巧没有技术门槛,但它在排查复杂跨系统问题时帮了我们大忙。消息治理的本质,不是把消息变成一条单行道,而是让每一次流转都有迹可循。把这条字段加上之后,你会发现自己排查问题时的心态会稳很多。