层级时间轮
你的延时队列需要处理长延时任务:
场景:物流自动确认收货
需求:
- 延时时间: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("延时超出最大支持范围");
}
}
三个设计点值得单独说:
- 节点里存绝对截止时间,不存”剩余延时”。 降级时用
deadlineMs - now重新算,每次降级都不累积误差——这正是「想一想」里”任务降级是否会丢失精度”的答案。 - 槽内用尾插保证 FIFO。 同一个槽位里的任务应当按入队顺序执行,所以维护了尾指针;如果业务不关心同刻到期任务的先后,头插(后进先出)更省事,Kafka 的
HashedWheelTimer就是头插。 - 每层的 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());
}
测试要点:
- 用可控时钟,而不是真的 sleep。 直接
Thread.sleep(7000)的测试会慢且不稳定。正确做法是把时间轮的”当前时间”抽象成一个可注入的now(),测试里手动拨动它。 - 断言要卡在边界上。 “任务被触发了”是弱断言,“触发时刻与 deadline 的差值 < 100ms”才是有效断言。
- 层归属要单独断言。 这是层级时间轮区别于单层轮的核心行为,测不到这一条,降级逻辑写错了也不会被发现。
- 精度测试要断言”不累积”。 关键不是精度有多高,而是多次降级之后误差没有变大——这正是「想一想」里”任务降级是否会丢失精度”要验证的命题。
测试结果
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 的两个真实痛点:
- 档位是离散的,精度受限。 如果档位只有 5 秒、30 秒、5 分钟,而业务要的是 7 分钟,那消息会先落到 5 分钟档,5 分钟后被消费,消费者再等 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) 次方。定完之后还要检查内存占用和降级开销是否可接受。
详细步骤:
问题分析
层级数量的确定需要考虑:
- 最大延时需求:业务最长的延时任务(如30天)
- 时间精度要求:最短的任务间隔(如1秒)
- 内存限制:每层都会占用内存
- 性能平衡:层级过多会增加降级开销
技术要点
1. 层级计算公式
最大延时 = tick_duration × wheel_size ^ (layer_count - 1)
倒推层级数量:
layer_count = ⌈log(wheel_size, max_delay / tick_duration)⌉ + 12. 标准配置方案
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 减当前时间重算,误差不叠加;后者每降一级都继承上一级的舍入,误差会累积。所以关键看实现方式是哪种。
详细步骤:
问题分析
任务降级过程中,精度是否丢失取决于:
- 计算方式:是否基于当前时间重新计算
- 时间单位:不同层的tick粒度不同
- 累积误差:多次降级是否会叠加误差
技术要点
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. 持久化方案对比
| 方案 | 实时性 | 性能影响 | 可靠性 | 复杂度 | 推荐度 |
|---|---|---|---|---|---|
| 定时快照 | 低 | 低 | 中 | 低 | ⭐⭐⭐ |
| 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. 性能优化
性能对比
| 指标 | 无持久化 | 仅Redis | Redis+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 可靠性高但实现复杂;定时快照实时性低;仅靠数据库轮询则失去了时间轮的性能优势。
发现内容有问题?
告诉我们哪里需要改进。
