Kafka

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 更省心。详细的能力对比见《方案对比》。

发现内容有问题?

告诉我们哪里需要改进。