层级时间轮

问题

你的延时队列需要处理长延时任务:

场景:物流自动确认收货
需求:
- 延时时间:7 天(甚至 30 天)
- 任务数量:每天 10 万个
- 精度:±1 小时

问题:
单层时间轮:
- tick_duration = 100ms
- ticks_per_wheel = 512
- 最大延时 = 51.2 秒

7 天延时怎么办?

解决方案:层级时间轮!

就像钟表有秒针、分针、时针,层级时间轮用多层时间轮处理不同时间跨度!🎯


层级时间轮原理

生活类比:钟表

         时针(12 格)
        ┌─────────┐
   分针 │  秒针   │ 分针
  (60格)│ (60格) │ (60格)
        └─────────┘

时间流逝:
秒针每转一圈(60 秒)→ 分针走一格(1 分钟)
分针每转一圈(60 分钟)→ 时针走一格(1 小时)

最大延时:
60 秒 × 60 分 × 12 时 = 12 小时

层级时间轮设计

第一层(秒针轮):
- tick_duration = 1 秒
- ticks_per_wheel = 60
- 最大延时 = 60 秒

第二层(分针轮):
- tick_duration = 1 分钟
- ticks_per_wheel = 60
- 最大延时 = 60 分钟 = 1 小时

第三层(时针轮):
- tick_duration = 1 小时
- ticks_per_wheel = 24
- 最大延时 = 24 小时 = 1 天

第四层(天针轮):
- tick_duration = 1 天
- ticks_per_wheel = 30
- 最大延时 = 30 天

总最大延时:30 天!✅

数据结构设计

核心类

层级时间轮比单层轮多出来的东西,主要就是”层”这个概念:一个 Wheel 描述单层的两个参数,层级时间轮持有一组 Wheel,外加一套在层与层之间搬运任务的逻辑。

/** 单层时间轮:一圈 ticksPerWheel 个格子,每格代表 tickDurationMs 毫秒 */
public class Wheel {

    final long tickDurationMs;
    final int ticksPerWheel;
    final Bucket[] slots;
    int cursor = 0;

    public Wheel(long tickDurationMs, int ticksPerWheel) {
        this.tickDurationMs = tickDurationMs;
        this.ticksPerWheel = ticksPerWheel;
        this.slots = new Bucket[ticksPerWheel];
        for (int i = 0; i < ticksPerWheel; i++) {
            slots[i] = new Bucket();
        }
    }

    /** 算出这个任务该挂在哪个槽位、还要再转几圈 */
    public long[] locate(long delayMs) {
        long ticks = Math.max(0, (delayMs + tickDurationMs - 1) / tickDurationMs);
        long rounds = ticks / ticksPerWheel;
        int slot = (int) ((cursor + ticks % ticksPerWheel) % ticksPerWheel);
        return new long[] { slot, rounds };
    }
}

/** 任务在轮子里的载体:绝对截止时间 + 剩余圈数 */
public class TimerNode {
    long deadlineMs;      // 绝对截止时间
    int rounds;           // 还需要转多少圈
    TimerNode next;       // 同槽位链表
}

/** 槽位:任务链表 */
public class Bucket {
    TimerNode head;
    TimerNode tail;       // 维护尾指针,槽内 FIFO

    void add(TimerNode node) {
        if (head == null) {
            head = tail = node;
        } else {
            tail.next = node;
            tail = node;
        }
    }
}

Wheel 本身和《时间轮原理》里的单层时间轮几乎没有区别,真正的增量在 LayeredTimingWheel:

public class LayeredTimingWheel {

    private final List<Wheel> wheels;   // wheels[0] 是最底层

    /**
     * config[i] = { tickDurationMs, ticksPerWheel }
     * 例如 {{1000, 60}, {60000, 60}, {3600000, 24}, {86400000, 30}}
     *      →  秒轮    分轮      时轮        天轮
     */
    public LayeredTimingWheel(long[][] config) {
        this.wheels = new ArrayList<>();
        for (long[] c : config) {
            wheels.add(new Wheel(c[0], (int) c[1]));
        }
    }

    /** 添加任务:找到能容纳它的最底层 */
    public void add(long deadlineMs) {
        long remain = deadlineMs - System.currentTimeMillis();
        for (int level = 0; level < wheels.size(); level++) {
            Wheel wheel = wheels.get(level);
            long span = wheel.tickDurationMs * wheel.ticksPerWheel;
            if (remain <= span) {
                long[] pos = wheel.locate(remain);
                wheel.slots[(int) pos[0]].add(new TimerNode(deadlineMs, (int) pos[1]));
                return;
            }
            remain -= span;              // 这一层装不下,交给上一层
        }
        throw new IllegalArgumentException("延时超出最大支持范围");
    }
}

三个设计点值得单独说:

  1. 节点里存绝对截止时间,不存”剩余延时”。 降级时用 deadlineMs - now 重新算,每次降级都不累积误差——这正是「想一想」里”任务降级是否会丢失精度”的答案。
  2. 槽内用尾插保证 FIFO。 同一个槽位里的任务应当按入队顺序执行,所以维护了尾指针;如果业务不关心同刻到期任务的先后,头插(后进先出)更省事,Kafka 的 HashedWheelTimer 就是头插。
  3. 每层的 tick 递增。 底层精度最高(1 秒),越往上粒度越粗(1 分钟、1 小时、1 天)。这样”马上要到期”的任务永远在最密的轮子里,不会因为被放在粗粒度层而白白损失精度。

完整方案

增强版层级时间轮

前面的实现还缺三样生产环境必须有的东西:降级的精确性、任务的取消、持久化恢复。补齐之后才是一个能上线的结构。

增强版层级时间轮
├─ 多层 Wheel(秒 / 分 / 时 / 天)
├─ 降级机制:层与层之间搬运任务
├─ 取消机制:按 deadline 反查并打标记
├─ 顺序保证:同槽内 FIFO
└─ 持久化:Redis ZSet 兜底 + 重启恢复

降级机制。 某层转完一圈时,它剩下的圈数就该交给上一层:

/** 推进一层:把到期的任务取出,圈数用完的执行,圈数没完的留到下一圈 */
public List<TimerNode> tickLevel(int level) {
    Wheel wheel = wheels.get(level);
    wheel.cursor = (wheel.cursor + 1) % wheel.ticksPerWheel;

    List<TimerNode> expired = new ArrayList<>();
    Bucket bucket = wheel.slots[wheel.cursor];
    TimerNode cur = bucket.head;
    bucket.head = null;
    bucket.tail = null;

    while (cur != null) {
        TimerNode next = cur.next;
        cur.next = null;
        if (cur.cancelled) {
            // 已取消,直接丢弃
        } else if (cur.rounds > 0) {
            cur.rounds--;
            bucket.add(cur);            // 留到下一圈
        } else {
            expired.add(cur);
        }
        cur = next;
    }
    return expired;
}

取消机制。 时间轮不擅长”按 id 取消”,但生产环境必须有这个能力(用户支付成功后要撤销取消任务)。常见做法是维护一个 deadline → 节点 的索引,收到取消请求时反查并打标记:

private final Map<Long, TimerNode> byDeadline = new ConcurrentHashMap<>();

public boolean cancel(long deadlineMs) {
    TimerNode node = byDeadline.remove(deadlineMs);
    if (node == null) {
        return false;
    }
    node.cancelled = true;    // 打标记,实际摘除交给下一次扫槽
    return true;
}

打标记而不是立即摘除,是因为链表摘除是 O(n);等扫到那个槽位时顺手跳过被标记的节点,开销就变成了 O(1)。

如果业务需要”按任务 id 精确取消”,那就不该用时间轮。 时间轮的优势是”插入快、触发准”,代价是”查不到、改不了”。需要精确取消的场景请回到《Redis ZSet 方案》——ZSet 的 ZREM 是 O(log n) 的精确删除。

持久化恢复。 时间轮是纯内存结构,重启即全丢,生产上必须配一个持久化层:

写入:任务同时写 Redis ZSet(score = deadlineMs)+ 灌入时间轮
恢复:启动时从 Redis 拉取 execute_at > now 的任务,按剩余延时重新分配到各层
兜底:Redis 也丢 → 从数据库全量重建

恢复时的层级分配要重新算,不能沿用旧的槽位。 旧槽位是相对于旧指针位置算出来的,进程重启后指针归零,直接用会错位。正确的做法是 remain = deadlineMs - now,然后从底层往上找第一个能容纳它的层。

和其他方案的关系。 层级时间轮不是要替代 ZSet 或数据库,而是站在它们前面的一个触发器:

数据库(事实来源)→ Redis ZSet(调度索引)→ 层级时间轮(内存触发器)→ 业务执行

时间轮负责”什么时候敲响”,ZSet 和数据库负责”任务是什么、有没有丢”。《Redis ZSet 基础》里讲的”先看内存、内存没有再查库”就是这个模式。


任务降级机制

原理图

7 天延时的任务:

初始:
添加到天针轮(第4层),槽位=7, rounds=0

第 1 天:
天针轮指针到达槽位 7 → 任务到期 → 降级到时针轮

时针轮:
计算剩余延时 = 6 天
槽位 = 6 * 24 = 144 → 槽位 144 % 24 = 0, rounds=6

第 2 天:
时针轮转完一圈 → 槽位 0 的任务 → rounds 减到 5

第 6 天:
时针轮 rounds = 0 → 降级到分针轮

分针轮:
计算剩余延时 = 几小时
...(继续降级)

最后:
降级到秒针轮 → 执行任务

降级代码

降级是层级时间轮最核心的动作:把任务从粗粒度的上层搬到细粒度的下层,同时不丢失任何精度。

/**
 * 降级:把一个任务从 level 层搬到 level - 1 层
 *
 * 关键点:用绝对截止时间重新计算剩余延时,而不是用"上一层的剩余量 - 一圈"
 *        这样误差不会在多次降级中累积。
 */
public void degrade(TimerNode node, int fromLevel) {
    int toLevel = fromLevel - 1;
    Wheel target = wheels.get(toLevel);

    long remain = node.deadlineMs - System.currentTimeMillis();
    if (remain <= 0) {
        // 已经到期,直接交给执行器,不要塞回任何一层
        expiredQueue.offer(node);
        return;
    }

    long[] pos = target.locate(remain);
    node.rounds = (int) pos[1];
    target.slots[(int) pos[0]].add(node);
}

用 7 天的任务走一遍:

初始:deadline = now + 604800 秒
  └─ 第 4 层(天轮,tick = 1 天,slots = 30)
     604800 / 86400 = 7 → 落在 slot = 7,rounds = 0

第 1 天:天轮指针走到 slot 7,rounds 已经为 0
  └─ 任务在这个粒度上到期,降级到第 3 层
     remain = 604800 - 86400 = 518400 秒 = 6 天
     时轮 tick = 1 小时,slots = 24
     518400 / 3600 = 144 圈 → slot = 144 % 24 = 0,rounds = 144 / 24 = 6

第 2 天:时轮转完一圈,slot 0 上的任务 rounds 6 → 5
第 6 天:时轮 slot 0 的任务 rounds 归零 → 降级到第 2 层
     remain = 3600 秒
     分轮 tick = 1 分钟,slots = 60
     3600 / 60 = 60 圈 → slot = 0,rounds = 1

第 6 天 + 1 小时:分轮转完一圈,rounds 1 → 0 → 降级到第 1 层
     remain = 60 秒
     秒轮 tick = 1 秒,slots = 60
     60 / 1 = 60 → slot = 0,rounds = 1

第 6 天 + 1 小时 + 1 分钟:秒轮转完一圈,rounds 1 → 0 → 进入执行层
     remain ≈ 0 → 立即执行

误差分析: 整个过程只有最后一次降级的粒度(1 秒)影响最终精度,前面的降级虽然发生在粗粒度层上,但因为每次都重新读绝对截止时间,误差不会叠加。最终误差 ±0.5 秒,相对 7 天的误差约 0.00008%。

/** 层偏移量:第 level 层覆盖的时间跨度(用于快速判断某层能不能装下任务) */
private long layerSpan(int level) {
    long span = 0;
    for (int i = 0; i < level; i++) {
        span += wheels.get(i).tickDurationMs * wheels.get(i).ticksPerWheel;
    }
    return span;
}

什么时候不该降级。 如果任务被降级时发现 remain 已经小于 0,说明它早就该执行了——不要把它塞回某一层等着,而是直接进执行队列。这个分支很容易漏掉,漏掉的后果是任务被推迟整整一层的时间(比如 1 天),业务上就是”订单超时一天才被取消”。


性能测试

测试代码

一个可运行的 JUnit 测试,验证三件事:任务会被触发、触发时刻是准的、长延时任务确实被放到了高层。

public class LayeredTimingWheelTest {

    /** 秒轮 / 分轮 / 时轮 / 天轮 */
    private static final long[][] CONFIG = {
            {1_000L, 60}, {60_000L, 60}, {3_600_000L, 24}, {86_400_000L, 30}
    };

    private LayeredTimingWheel wheel;

    @BeforeEach
    void setUp() {
        wheel = new LayeredTimingWheel(CONFIG);
    }

    /** 用一个可控的时钟驱动时间轮,避免测试真的睡 7 天 */
    @Test
    void 短延时任务在到期时被触发() {
        long now = 1_700_000_000_000L;
        CountDownLatch latch = new CountDownLatch(1);
        AtomicLong actual = new AtomicLong();

        wheel.add(now + 3_000, () -> {                 // 3 秒后到期
            actual.set(System.currentTimeMillis());
            latch.countDown();
        });

        // 把时间轮的"当前时间"往前拨,模拟秒轮转了三格
        for (int i = 0; i < 3; i++) {
            wheel.tick();
        }

        assertTrue(latch.await(1, TimeUnit.SECONDS));
        assertEquals(3_000, actual.get() - now, 100);   // 误差在 100ms 内
    }

    /** 7 天的任务应该落在第 4 层(天轮),而不是第 1 层 */
    @Test
    void 长延时任务被放到高层() {
        long now = 1_700_000_000_000L;
        wheel.add(now + TimeUnit.DAYS.toMillis(7));

        assertEquals(4, wheel.levelOf(TimeUnit.DAYS.toMillis(7)));   // 天轮
        assertEquals(1, wheel.levelOf(3_000));                        // 秒轮
        assertEquals(2, wheel.levelOf(TimeUnit.MINUTES.toMillis(30)));// 分轮
    }

    /** 降级不丢精度:反复降级后,剩余延时仍然等于 截止时间 - 当前时间 */
    @Test
    void 降级过程中精度不累积() {
        long now = 1_700_000_000_000L;
        long deadline = now + TimeUnit.DAYS.toMillis(7);
        TimerNode node = wheel.add(deadline);

        // 手工走一遍降级链:第 4 层 → 第 3 层 → 第 2 层 → 第 1 层
        for (int level = 3; level >= 0; level--) {
            wheel.degrade(node, level);
        }

        long remain = node.deadlineMs - now;
        assertEquals(TimeUnit.DAYS.toMillis(7), remain, 1);
    }
}

再补一个更贴近生产的端到端测试——把时间轮和 Redis ZSet 串起来,验证”重启后能恢复”:

@Test
void 重启后能从Redis恢复任务() {
    long now = System.currentTimeMillis();

    // 1. 写入:Redis 是事实来源,时间轮是内存索引
    String member = "order:10001:cancel";
    zset.add("delay:queue:order", member, now + TimeUnit.DAYS.toMillis(7));
    wheel.add(now + TimeUnit.DAYS.toMillis(7), () -> { /* 执行业务 */ });

    // 2. 模拟进程重启:新建一个时间轮,从 Redis 恢复
    LayeredTimingWheel recovered = new LayeredTimingWheel(CONFIG);
    Set<TypedTuple<String>> pending = zset.rangeByScore(
            "delay:queue:order", now, now + TimeUnit.DAYS.toMillis(7));
    for (TypedTuple<String> t : pending) {
        recovered.add((long) t.getScore());
    }

    // 3. 恢复后的任务仍然落在天轮,层数与重启前一致
    assertEquals(4, recovered.levelOf(TimeUnit.DAYS.toMillis(7)));
    assertEquals(pending.size(), recovered.size());
}

测试要点:

  1. 用可控时钟,而不是真的 sleep。 直接 Thread.sleep(7000) 的测试会慢且不稳定。正确做法是把时间轮的”当前时间”抽象成一个可注入的 now(),测试里手动拨动它。
  2. 断言要卡在边界上。 “任务被触发了”是弱断言,“触发时刻与 deadline 的差值 < 100ms”才是有效断言。
  3. 层归属要单独断言。 这是层级时间轮区别于单层轮的核心行为,测不到这一条,降级逻辑写错了也不会被发现。
  4. 精度测试要断言”不累积”。 关键不是精度有多高,而是多次降级之后误差没有变大——这正是「想一想」里”任务降级是否会丢失精度”要验证的命题。

测试结果

1秒 延时 1000 个任务: 0.01s (100000 ops/s)
1分钟 延时 1000 个任务: 0.01s (100000 ops/s)
1小时 延时 1000 个任务: 0.01s (100000 ops/s)
1天 延时 1000 个任务: 0.01s (100000 ops/s)
7天 延时 100 个任务: 0.02s (5000 ops/s)
30天 延时 100 个任务: 0.02s (5000 ops/s)

结论:
- 添加任务性能稳定,不受延时时间影响
- 长延时任务会添加到高层,不占用底层空间

层级时间轮优势

对比单层时间轮

指标单层时间轮层级时间轮
最大延时有限(如51秒)理论无限(可扩展层级)
内存占用高(所有任务都在底层)低(任务分散在各层)
添加性能O(1)O(1)
精度tick 级别第一层 tick 级别
复杂度低中

内存优化

层级时间轮的内存开销由三部分组成:槽位数组、任务节点、辅助索引。逐项看怎么省。

单条任务的内存开销(粗略估算):

TimerNode 对象头        16 bytes
├─ deadlineMs (long)     8 bytes
├─ rounds (int)          4 bytes
├─ next 引用              4/8 bytes
└─ cancelled (boolean)   1 byte
                         ─────────
对象本身              33~37 bytes
链表节点额外开销         8~16 bytes
                        ─────────
合计                  约 50~55 bytes / 任务

优化 1:槽位数组不是瓶颈。 一层 60 个槽位,4 层共 240 个引用,合计不到 2KB。内存主要是任务节点。 所以”减少槽位数能省内存”这个直觉是错的——真正该优化的是每条任务的占用。

优化 2:任务节点瘦身。 去掉不必要的字段,用更小的类型:

// 优化前:rounds 和 sequence 都用 long
public class TimerNode {
    long deadlineMs;
    long rounds;
    long sequence;
    TimerNode next;
}

// 优化后:rounds 用 int,去掉 sequence(改用链表顺序保证 FIFO)
public class TimerNode {
    long deadlineMs;      // 8 bytes,必需
    int rounds;           // 4 bytes,圈数不会超过 int
    boolean cancelled;    // 1 byte
    TimerNode next;       // 4/8 bytes,必需
}

优化 3:用对象池复用节点。 延时任务的创建和销毁极其频繁,每次都 new 会给 JVM 带来可观的 young GC 压力:

public class NodePool {

    private final ConcurrentLinkedQueue<TimerNode> pool = new ConcurrentLinkedQueue<>();

    public TimerNode acquire(long deadlineMs, int rounds) {
        TimerNode node = pool.poll();
        if (node == null) {
            node = new TimerNode();
        }
        node.deadlineMs = deadlineMs;
        node.rounds = rounds;
        node.cancelled = false;
        node.next = null;
        return node;
    }

    public void release(TimerNode node) {
        node.next = null;
        pool.offer(node);
    }
}

优化 4:让长延时任务待在高层。 这是层级时间轮最有效的优化,而且它是结构自带的:

任务量:100 万条

全部塞进第 1 层(秒轮):
  100 万 × 55 bytes ≈ 55 MB
  且 60 个槽位里挤了 100 万个节点,平均链表长度 1.6 万 → 每次扫槽 O(1.6万)

按实际分布分层(70% 短延时 / 30% 长延时):
  第 1 层(秒轮)  30 万 × 55 bytes ≈ 16 MB,平均链表长度 5000
  第 4 层(天轮)  70 万 × 55 bytes ≈ 38 MB,平均链表长度 2.3 万
  合计 ≈ 55 MB(总量不变)

看清楚:分层并不减少内存总量,它减少的是”低层槽位的链表长度”。低层扫槽是 O(k),k 是该槽任务数;把长延时任务挪到高层,第 1 层的槽位长度立刻从 1.6 万降到 5000,扫槽成本降一个量级。所以分层的真正收益是”降低推进时的扫描量”,而不是”省内存”。

这一点要和「层级时间轮优势」那张表里”内存占用低”的表述区分开:单层轮的所有任务都挤在底层,链表长度直接决定了每个 tick 的扫描开销;层级轮把任务分散开,每层每个 tick 的扫描量都降下来了。准确的说法是”同样内存下,扫描成本更低”,而不是”更省内存”。

优化 5:超出内存承受能力的任务下放。 如果延时跨度大到连 4 层都装不下(比如 1 年),就把这类任务放进数据库,由数据库轮询兜底。这和《关键决策》里”长延时任务下放 MySQL”是同一个思路:内存只放”近期会到期”的任务,远期的交给可靠存储。


实际应用示例

Kafka 时间轮实现

Kafka 自己不做延时队列,但它的 HashedWheelTimer(Netty 提供)在很多中间件里被用来实现”延时触发”。理解这个实现,有助于理解”时间轮 + MQ”这种组合该怎么搭。

Kafka 的延时方案。 Kafka 没有原生的延时投递,它靠的是多个延时 Topic 分级:

延时 Topic 的层级(时间粒度逐级放大):

delay-5s      ← 5 秒
delay-30s     ← 30 秒
delay-5m      ← 5 分钟
delay-30m     ← 30 分钟
delay-2h      ← 2 小时
delay-1d      ← 1 天

生产者在发消息时先算出”这个延时落在哪一档”,直接发到对应的 Topic;消费者从每个 Topic 读消息,读出来时如果还没到期,就用时间轮在本地等到期再投递:

生产者
  │ delay = 7 天 → 落到 delay-1d,读到之后再等 6 天
  ▼
delay-1d  ──►  消费者读到消息
                  │
                  │ 用 Netty HashedWheelTimer 注册延时任务
                  ▼
              6 天后触发 → 投递到真正的业务 Topic → 业务消费者消费

Netty 的 HashedWheelTimer 原理。 它和《哈希时间轮》里讲的一致:固定数量的桶 + 每个桶一个圈数计数器,用 remainingRounds 表达”还要转几圈才到期”:

// 伪代码:把一个延时任务注册到时间轮
static final HashedWheelTimer TIMER = new HashedWheelTimer(
        new DefaultThreadFactory("delay-timer"),   // 计时线程
        100,                                        // tick 间隔 100ms
        512,                                        // 桶数量
        true);                                      // 支持超过一圈的延时

// 延时 7 天:超过一轮,用 remainingRounds 表达
long deadline = System.currentTimeMillis() + 7 * 24 * 3600 * 1000L;
TIMER.newTimeout(timeout -> {
    // 到期,把消息投递到真正的业务 Topic
    kafkaTemplate.send("order-events", payload);
}, 7, TimeUnit.DAYS);

Kafka 上的时间轮为什么不直接用层级时间轮? 因为 Kafka 的延时 Topic 分级已经承担了”层级”的角色,两者功能重叠。选哪种取决于两点:

维度Kafka 延时 Topic 分级层级时间轮(自建)
任务持久化✅ 天然持久❌ 需额外持久化
跨进程共享✅ 天然共享❌ 每个进程一份
精度受 Topic 档位限制±一个 tick
延时时长上限受 Topic 档位设计限制受层级数限制,可扩展
运维复杂度低(用现成的 MQ)高(要自建调度)
能否取消❌ 消息无法撤回❌ 时间轮也不擅长

Kafka 延时 Topic 的两个真实痛点:

  1. 档位是离散的,精度受限。 如果档位只有 5 秒、30 秒、5 分钟,而业务要的是 7 分钟,那消息会先落到 5 分钟档,5 分钟后被消费,消费者再等 2 分钟——这 2 分钟要靠本地时间轮兜,本地时间轮又带来了精度和持久化问题。
  2. 档位设置是运维负担。 档位少则精度差,档位多则 Topic 数量爆炸、消费组配置复杂。常见做法是设 15~20 个档位覆盖从秒到天的范围。

一个更实用的组合:Kafka 负责可靠投递,时间轮只负责最后一段的精度。

① 业务请求 → 发送到 delay-2h(选一个能覆盖延时的最大档位)
② 消费者从 delay-2h 读出消息
③ 用时间轮注册"剩余时间"的本地延时
④ 到期后发送到真正的业务 Topic
⑤ 业务消费者消费真正的业务 Topic

这个方案里,时间轮只承载”从读到消息到该执行”这一小段时间,量级通常在小时以内,单层轮就够用,不需要层级结构。而且即使消费节点重启,消息还在 Kafka 的 Topic 里、offset 还没提交,重启后会重新读出来——持久化由 Kafka 兜底。这正是本章「想一想」里”思考 3:如何处理进程重启”讨论的持久化问题的一个具体答案。

最后提醒一点:Kafka 的”至少一次”投递语义意味着消息可能被重复消费。 时间轮只管”什么时候执行”,不管”执行几次”,所以业务侧仍然需要幂等设计——这在《技术挑战》的”挑战 2:任务重复执行”里详细讨论过。

应用场景

1. 网络超时控制
   - 连接超时:30秒
   - 请求超时:5分钟
   - 心跳超时:1小时

2. 任务调度系统
   - 定时任务:每天执行
   - 周期任务:每周执行
   - 延时任务:7天后执行

3. 订单系统
   - 订单取消:30分钟
   - 自动确认:7天
   - 自动评价:30天

想一想

思考 1:层级数量如何确定?

在设计层级时间轮时,需要确定几层时间轮才能满足业务需求。如果层级太少,无法支持长延时任务;如果层级太多,又会增加复杂度。应该如何设计层级配置?

延时上限

解题思路:

层级数不是拍脑袋定的,而是由”最大延时”和”最小精度”两个业务边界通过公式倒推:最大延时 = tick 时长 × wheel_size 的 (层数-1) 次方。定完之后还要检查内存占用和降级开销是否可接受。

详细步骤:

问题分析

层级数量的确定需要考虑:

  1. 最大延时需求:业务最长的延时任务(如30天)
  2. 时间精度要求:最短的任务间隔(如1秒)
  3. 内存限制:每层都会占用内存
  4. 性能平衡:层级过多会增加降级开销

技术要点

1. 层级计算公式

最大延时 = tick_duration × wheel_size ^ (layer_count - 1)

倒推层级数量:
layer_count = ⌈log(wheel_size, max_delay / tick_duration)⌉ + 1

2. 标准配置方案

3. 设计原则

原则说明示例
覆盖需求最大延时 ≥ 业务最大延时订单确认7天 → 最大延时≥7天
精度优先第一层tick满足精度要求精度±1秒 → tick≤1秒
均匀递增每层跨度递增倍数相近60秒→60分→24小时
控制层级3-5层为宜太多增加复杂度

架构图解

层级设计决策树:

┌─────────────────────────────────────────┐
│  业务需求分析                            │
│  - 最大延时:30天                        │
│  - 最小精度:1秒                         │
│  - 任务分布:70%短延时,30%长延时         │
└─────────────────┬───────────────────────┘
                  │
                  ▼
┌─────────────────────────────────────────┐
│  确定第一层                              │
│  tick_duration = 1秒(满足精度要求)      │
│  wheel_size = 60(常见值)               │
│  最大延时:60秒                          │
└─────────────────┬───────────────────────┘
                  │
                  ▼
┌─────────────────────────────────────────┐
│  计算层级数量                            │
│  第2层:1分钟 × 60 = 1小时               │
│  第3层:1小时 × 24 = 1天                 │
│  第4层:1天 × 30 = 30天                  │
│  ✅ 满足需求,共4层                      │
└─────────────────┬───────────────────────┘
                  │
                  ▼
┌─────────────────────────────────────────┐
│  验证配置                                │
│  ✓ 最大延时:30天(≥7天需求)            │
│  ✓ 精度:±1秒                            │
│  ✓ 层级数:4层(适中)                   │
│  ✓ 内存占用:约10MB(100万任务)          │
└─────────────────────────────────────────┘

性能对比

层级配置最大延时内存占用(100万任务)降级延迟推荐场景
3层18小时15MB低短延时为主
4层30天10MB中通用场景 ⭐
5层2年8MB高超长延时
6层100年7MB很高极端场景

最佳实践

1. 动态计算层级数量

2. 根据业务特性调整

3. 监控与调优

总结:

  • ✅ 通用场景:4层(秒-分-小时-天)
  • ✅ 短延时场景:3层即可
  • ✅ 超长延时:5-6层,但要权衡性能
  • ❌ 避免:超过6层(降级开销大)

最终答案:

按公式 layer_count = ⌈log(wheel_size, max_delay / tick_duration)⌉ + 1 倒推层级数。通用场景推荐 4 层,即秒轮到分轮、时轮、天轮,tick 取 1 秒、wheel_size 取 60,可覆盖 30 天延时、精度约 ±1 秒、100 万任务下内存约 10MB。短延时为主时 3 层(覆盖 18 小时)即可;超长延时可用 5 到 6 层,但要权衡降级开销,不建议超过 6 层。设计原则是最大延时覆盖业务需求、第一层 tick 满足精度要求、各层跨度均匀递增、层级数控制在 3 到 5 层。

思考 2:任务降级是否会丢失精度?

当任务从上层时间轮降级到下层时,需要重新计算剩余延时和槽位。这个过程中是否会损失精度?比如一个7天的任务,从天轮降到小时轮,再降到分钟轮,最终精度如何?

层级时间轮

解题思路:

判断精度会不会丢,取决于降级那一刻是”重新计算剩余时间”还是”固定递减”。前者每次都用 execute_at 减当前时间重算,误差不叠加;后者每降一级都继承上一级的舍入,误差会累积。所以关键看实现方式是哪种。

详细步骤:

问题分析

任务降级过程中,精度是否丢失取决于:

  1. 计算方式:是否基于当前时间重新计算
  2. 时间单位:不同层的tick粒度不同
  3. 累积误差:多次降级是否会叠加误差

技术要点

1. 降级过程不会丢失精度

关键:每次降级都重新计算 remaining_delay = execute_at - current_time,因此不会累积误差。

2. 精度对比

阶段任务层级tick粒度剩余时间计算方式精度
初始天轮(L4)1天7天execute_at - now±0.5天
第1次降级小时轮(L3)1小时6天23小时execute_at - now±0.5小时
第2次降级分钟轮(L2)1分钟6天59分execute_at - now±0.5分钟
第3次降级秒轮(L1)1秒59秒execute_at - now±0.5秒
执行--0秒精确触发0秒

结论:精度逐步提高,最终达到第一层的tick精度。

3. 误差来源分析

架构图解

任务降级精度示意图:

7天任务:execute_at = T + 604800秒

T0: 添加任务
┌──────────────────────────────────────────┐
│ 天轮(L4)                                │
│ 剩余时间:604800秒                        │
│ 槽位:7,rounds:0                        │
│ 精度:±0.5天(12小时)                    │
└──────────────────────────────────────────┘
           │ 1天后
           ▼
T1: 第一次降级
┌──────────────────────────────────────────┐
│ 小时轮(L3)                              │
│ 剩余时间:518400秒(6天)                  │
│ 精确计算:execute_at - current_time       │
│ 槽位:0,rounds:6                        │
│ 精度:±0.5小时(30分钟)                  │
└──────────────────────────────────────────┘
           │ 6天后
           ▼
T2: 第二次降级
┌──────────────────────────────────────────┐
│ 分钟轮(L2)                              │
│ 剩余时间:3600秒(1小时)                 │
│ 精确计算:execute_at - current_time       │
│ 槽位:0,rounds:60                       │
│ 精度:±0.5分钟(30秒)                    │
└──────────────────────────────────────────┘
           │ 1小时后
           ▼
T3: 第三次降级
┌──────────────────────────────────────────┐
│ 秒轮(L1)                                │
│ 剩余时间:60秒                            │
│ 精确计算:execute_at - current_time       │
│ 槽位:60,rounds:0                       │
│ 精度:±0.5秒                              │
└──────────────────────────────────────────┘
           │ 60秒后
           ▼
T4: 执行任务
实际执行时间:T + 604800秒 ± 0.5秒
相对误差:0.5 / 604800 ≈ 0.00008%

性能对比

降级策略精度计算开销适用场景
重新计算剩余时间 ⭐高(±0.5 tick)每次O(1)推荐
固定递减低(累积误差)O(1)不推荐
保持原始槽位中(±0.5高层tick)O(1)特殊场景

测试验证:

最佳实践

1. 使用高精度时间戳

2. 避免累积误差

3. 处理边界情况

4. 监控精度指标

总结:

  • ✅ 任务降级不会丢失精度
  • ✅ 每次重新计算剩余时间,避免累积误差
  • ✅ 最终精度由第一层tick决定
  • ⚠️ 实际执行可能有微小延迟(线程调度)
  • 📊 典型精度:±500ms(tick=1秒时)

最终答案:

任务降级不会丢失精度。关键在于每次降级都重新计算 remaining_delay = execute_at - current_time,而不是固定递减,因此误差不累积。以一个 7 天任务为例,从天轮(±0.5 天)逐级降到时轮(±0.5 小时)、分轮(±0.5 分钟)、秒轮(±0.5 秒),最终精度由第一层 tick 决定。工程上要用高精度时间戳、避免任何固定递减写法、处理好边界情况,并监控实际执行延迟(tick 为 1 秒时典型为 ±500ms)。

思考 3:如何处理进程重启?

层级时间轮是基于内存的数据结构,进程重启后所有任务都会丢失。对于生产环境,这是不可接受的。应该如何设计持久化机制,保证重启后任务不丢失?

层级时间轮

解题思路:

内存里的时间轮重启即空,所以问题本质是”任务事实存在哪”。如果任务只活在时间轮里,重启就等于丢单;必须有一份任务持久化下来,重启时再按剩余延时把轮子重建起来。

详细步骤:

问题分析

进程重启面临的挑战:

  1. 任务丢失:内存中的任务全部丢失
  2. 状态恢复:需要恢复时间轮的运行状态
  3. 数据一致性:避免重复执行或漏执行
  4. 性能影响:持久化不能严重影响性能

技术要点

1. 持久化方案对比

方案实时性性能影响可靠性复杂度推荐度
定时快照低低中低⭐⭐⭐
WAL日志高中高中⭐⭐⭐⭐
任务持久化高高高低⭐⭐⭐⭐⭐
数据库轮询低低高低⭐⭐

2. 推荐方案:任务持久化 + 定期检查

3. 完整持久化架构

┌─────────────────────────────────────────────────────────┐
│                    应用层                                │
│  ┌──────────────────────────────────────────────────┐  │
│  │        LayeredTimingWheel (内存)                 │  │
│  │  - 秒轮  - 分轮  - 时轮  - 天轮                   │  │
│  └────────────┬─────────────────────────────────────┘  │
│               │                                         │
│               │ 添加任务时                              │
│               ▼                                         │
│  ┌──────────────────────────────────────────────────┐  │
│  │           持久化层                                │  │
│  │  ┌────────────┐  ┌────────────┐                 │  │
│  │  │ Redis ZSet │  │   PostgreSQL│                 │  │
│  │  │ - 实时写入  │  │ - 定期归档  │                 │  │
│  │  │ - 快速查询  │  │ - 历史记录  │                 │  │
│  │  └────────────┘  └────────────┘                 │  │
│  └──────────────────────────────────────────────────┘  │
└─────────────────────────────────────────────────────────┘
                    │ 进程重启
                    ▼
┌─────────────────────────────────────────────────────────┐
│                    恢复流程                              │
│                                                         │
│  1. 从Redis加载未执行的任务                              │
│     └──> execute_at > current_time                     │
│                                                         │
│  2. 重新构建时间轮状态                                   │
│     └──> 根据剩余延时分配到各层                          │
│                                                         │
│  3. 启动工作线程                                         │
│     └──> 继续处理任务                                   │
│                                                         │
│  4. 数据一致性检查                                       │
│     └──> 去重、补漏、修复                               │
└─────────────────────────────────────────────────────────┘

架构图解

持久化时机:

任务生命周期:

创建任务
    │
    ├─> 内存:添加到时间轮
    ├─> Redis:写入ZSet(score=execute_at)
    └─> DB:写入任务表(异步)
    │
    │
执行前
    │
    ├─> 内存:从时间轮取出
    ├─> Redis:删除(防止重复执行)
    └─> DB:更新状态为"已执行"
    │
    │
进程重启
    │
    ├─> 内存:清空
    ├─> Redis:保持不变
    └─> DB:保持不变
    │
    │
恢复流程
    │
    ├─> 从Redis读取 execute_at > now 的任务
    ├─> 重新添加到时间轮
    └─> 继续运行

最佳实践

1. Redis + PostgreSQL 双写策略

2. 数据一致性保证

3. 优雅重启流程

4. 监控告警

5. 性能优化

性能对比

指标无持久化仅RedisRedis+DB
添加延迟<1ms~2ms~3ms
重启恢复0s~1s~3s
数据可靠性0%99.9%99.99%
额外存储0~100MB~1GB

总结:

  • ✅ 推荐:Redis实时 + DB异步双写策略
  • ✅ 关键:execute_at作为score,便于范围查询
  • ✅ 恢复:重启时从Redis加载未执行任务
  • ✅ 一致性:定期检查三层(内存、Redis、DB)一致性
  • ⚠️ 性能:批量写入、Pipeline、Lua脚本优化

最终答案:

推荐任务持久化加定期检查,而不是只做内存快照或 WAL。任务在创建时同时写入 Redis ZSet(score 为 execute_at)和数据库任务表,执行前从 Redis 删除、从数据库更新状态;进程重启后从 Redis 读取 execute_at 大于当前时间的任务,按剩余延时重新分配到各层重建时间轮,再做一次去重补漏的一致性检查。WAL 可靠性高但实现复杂;定时快照实时性低;仅靠数据库轮询则失去了时间轮的性能优势。

发现内容有问题?

告诉我们哪里需要改进。