Kafka
Kafka 本身不支持延时消息,但可以通过分区策略和消费者逻辑实现。
先把这句话拆开看。“Kafka 本身不支持延时消息”是事实:Kafka 的核心抽象是分区追加日志,消费者按 offset 顺序拉取,同一分区内严格有序、绝不乱序。它没有一个”这条消息 30 分钟后再给我”的概念——投递时刻由消费进度决定,而不是由消息自己携带的时间戳决定。
“Kafka 不支持”还有一层更现实的原因:Kafka 的分区日志是按 offset 排的,不是按时间排的。日志里的 LogAppendTime 和 CreateTime 都只是消息上的一个字段,Kafka 从不按它们重排消息。谁要是想”等时间到了再消费”,就必须自己在外面再套一层调度。
这套调度通常有三个位置可以放:
| 放置位置 | 谁来决定”什么时候能消费” | 代价 |
|---|---|---|
| 生产端分区策略 | 生产者把消息投到”时间桶”分区 | 消费端要自己判断分区是否到期 |
| 中间延迟主题(分级延迟 topic) | 一个搬运服务把到期消息转到业务 topic | 多一跳,需要额外的搬运服务 |
| 消费端重新调度 | 消费者拿到消息后发现没到期,再投回延迟 topic | 消费端逻辑最复杂,offset 容易错位 |
我们线上系统用的是第一种,下面先讲它,再讲后两种。
整体思路是:把分区当成时间桶。
Kafka 的分区天然是”多个并行、有序、各自独立”的通道。定时器领域有一个经典做法叫 wheel-based bucket(如 Java 的 TimerWheel),本质就是把时间切成若干个桶,每个桶对应一个 channel。Kafka 的分区正好可以充当这个桶——只要约定”分区号等于时间片”。
时间分片与分区的对应:
2026-10-21 10:00 ─┬─ 分区 0 ─┬─ 10:00 ~ 10:01 的消息
│ │
2026-10-21 10:01 ─┼─ 分区 1 ─┤
│ │
2026-10-21 10:02 ─┼─ 分区 2 ─┤
│ │
2026-10-21 10:03 ─┼─ 分区 3 ─┤
│ │
2026-10-21 10:04 ─┴─ 分区 4 ─┘
约定:分区 P 只放"第 P 个时间片"产生的消息
消费端:只有当 wall clock 走到第 P 片,才开始消费分区 P
分区策略
生产者需要两样东西:当前时间片编号和分区数。
public class TimePartitionProducer {
private static final int PARTITION_COUNT = 24; // 24 个时间片
/**
* 时间片编号 = (当前时间戳 / 片长) % 分区数
* 片长 = 一个时间片覆盖的毫秒数
*/
private int currentSlot() {
long slotMs = TimeUnit.MINUTES.toMillis(1); // 1 分钟一个片
long slot = System.currentTimeMillis() / slotMs;
return (int) (slot % PARTITION_COUNT);
}
public void send(String key, String value, long delayMs) {
ProducerRecord<String, String> record =
new ProducerRecord<>("order-delay-topic", currentSlot(), key, value);
// 关键:消息头里带上真正的到期时间,精度才不受片长限制
record.headers().add("execute-at",
String.valueOf(System.currentTimeMillis() + delayMs).getBytes(StandardCharsets.UTF_8));
producer.send(record);
}
}
消费者侧要做三件事:算当前片、判断分区是否到期、只消费到期的分区。
public class TimePartitionConsumer implements ConsumerRecord<String, String> {
private final int partitionCount = 24;
private final long slotMs = TimeUnit.MINUTES.toMillis(1);
/** 这个分区现在到期了吗? */
private boolean isDue(int partition) {
long currentSlot = System.currentTimeMillis() / slotMs;
return (currentSlot % partitionCount) == partition;
}
@Override
public void onPartitionsAssigned(Collection<TopicPartition> partitions) {
// 消费前先 pause 未到期的分区
for (TopicPartition tp : partitions) {
if (!isDue(tp.partition())) {
consumer.pause(tp);
}
}
}
@Override
public void onPartitionsRevoked(Collection<TopicPartition> partitions) {
// 交还分区时提交 offset,避免重复消费
consumer.commitSync();
}
}
再配一个后台线程,定期把到期的分区 resume 起来:
@Scheduled(fixedDelay = 5_000)
public void releaseDuePartitions() {
Set<TopicPartition> assigned = consumer.assignment();
for (TopicPartition tp : assigned) {
if (isDue(tp.partition()) && paused.contains(tp)) {
consumer.resume(tp);
paused.remove(tp);
}
}
}
这套方案能跑,但它有四个绕不开的硬伤:
1. 精度被片长绑架。 上例中片长是 1 分钟,意味着所有消息的最早触发时间都是”下一个整分钟”。如果片长取 1 秒,24 个分区只够覆盖 24 秒;取 1 小时,触发就要等最多 1 小时。分区数乘片长等于可覆盖的总时间跨度,这是一个硬性乘积约束。
2. 轮转一圈会有消息”提前”到达。 生产者按当前片投递,而消费者按”到达该片”消费。如果服务重启、或某个分区积压,跨过一圈之后消息会被重复投递一次,需要消费者自己去重。
3. 消费者必须持有所有分区。 因为要判断到期,consumer 必须 assign 到全部分区,再 pause 未到期的那些。分区一多,consumer 的内存占用和 rebalance 成本都上来了。
4. 这是”按时间片分区”,不是”按业务分区”。 同一个订单的消息可能被分散到不同分区,业务上的顺序保证就没了。
另外两种做法
中间延迟主题(分级延迟 topic),是 RocketMQ 内部的做法,也是最接近”原生延时队列”的思路:
分级延迟 topic:
业务 topic ──► delay-1s ─┐
──► delay-10s ─┤
──► delay-1m ─┼─► 搬运服务(roller)──► 业务 topic
──► delay-10m ─┤ (按到期时间转移)
──► delay-1h ─┘
生产者:把 15 分钟到期的消息投到 delay-1h(或最接近且不早于延时的档位)
搬运服务:每 1 秒扫描一遍 delay-1m,到期的转到业务 topic
档位是离散的,15 分钟的延时会被放进 1 小时档位,于是实际触发时间可能比预期晚 45 分钟——这是 RocketMQ 18 个延迟级别最大的坑。用 Kafka 复刻这套,需要自己维护 N 个 topic 和搬运服务,成本不低。
消费端重新调度,是”拿到就自己管”的思路:
public void onMessage(ConsumerRecord<String, String> record) {
long executeAt = Long.parseLong(header(record, "execute-at"));
if (executeAt > System.currentTimeMillis()) {
// 没到期:重新投回延迟 topic,然后提交 offset
producer.send(new ProducerRecord<>("delay-topic", record.key(), record.value()));
consumer.commitSync(); // 提交后原消息才算处理完
return;
}
handle(record); // 到期了,真正处理
}
它的好处是精度高(不受片长约束),坏处是”重投”和”提交 offset”这两步不是原子的:中间崩掉就会丢消息或重复。更麻烦的是 max.poll.interval.ms(默认 5 分钟)——如果消费端直接 sleep 等到期,会被判定为消费者失活而被踢出消费组。所以正确的做法是不 sleep,而是重投,用重投次数表达”还要再等多久”。
优点
- ✅ 高吞吐量
- ✅ 分布式
- ✅ 持久化保证
这三条是 Kafka 的立身之本,用它做延时队列,天然继承了这套能力:
高吞吐量。 分区并行加顺序写磁盘,百万级 TPS 是常规操作。延时队列最怕的是”同一时刻大量任务到期”,而 Kafka 面对突发流量只需要增加消费者就行。
分布式。 消费组把分区分给多个消费者,扩容是加机器的事,不需要改架构。
持久化保证。 消息落在磁盘上,配合 acks=all 和多副本,broker 挂掉不会丢消息。这一点比纯内存方案强太多。
缺点
- ❌ 实现复杂
- ❌ 需要消费者逻辑处理延时
- ❌ 精度受限于轮询间隔
这三条是真实存在的,每一条展开都很实际:
实现复杂。 上面的几段代码只是骨架,真正能上生产的版本还要处理:分区暂停与恢复的时机、rebalance 期间的 offset 提交、消息在”投回延迟 topic”和”提交 offset”之间的原子性、消费者崩溃后的重复投递去重。这些每一个都是独立的 bug 来源。
需要消费者逻辑处理延时。 延时不再是中间件的能力,而是业务代码的责任。消费端要判断到期、要重投、要管 offset,也要知道自己处理的是”延时消息”还是”真实消息”。这份逻辑每个消费组都得写一遍,而且很容易写错。
精度受限于轮询间隔。 无论是分区策略(片长)、分级 topic(轮转间隔)还是重投(轮询周期),触发精度都不可能优于调度周期。真要做到秒级以内,还得配一个专门的时间轮服务,参见《时间轮原理》。
运维成本也不低。 分区数要和片长一起规划,改动就是一次分区扩容;延迟 topic 的保留期必须覆盖最大延时,否则消息会被 retention 策略直接删掉——这是用 Kafka 做延时队列最常见的数据丢失原因。
最后一句实话:Kafka 不是为延时设计的。 用它做延时队列,本质是在用一个”日志系统”模拟一个”定时器”,中间隔着一层自己维护的调度逻辑。如果业务对延时有强需求,RocketMQ 这类原生支持延迟消息的中间件,或者《Redis ZSet 方案》里那种专用结构,往往比硬套 Kafka 更省心。详细的能力对比见《方案对比》。
发现内容有问题?
告诉我们哪里需要改进。
