延时任务方案对比:从 Timer 到时间轮
摘要:订单超时关闭、验证码过期、失败重试、连接空闲检测,延时任务在后端到处都是。候选方案从 JDK 自带的 Timer、ScheduledThreadPoolExecutor,到 DelayQueue 自研调度,再到时间轮,插入取消的复杂度、精度、可恢复性各不相同。这篇逐个过一遍实现要点和坑,最后给选型结论:什么时候堆结构就够,什么时候才值得上时间轮,以及长任务为什么根本不该进内存定时器。
一、延时任务的需求长什么样
先列场景,感受一下需求:
- 订单/支付超时自动关闭:中等时长(分钟级),中途要能取消(用户提前支付了);
- 验证码、会话、临时凭证过期:短延时,高频创建;
- 失败重试、退避重连:短延时,且经常创建后立刻又取消(重试提前成功);
- 连接空闲超时、心跳检测:海量任务,短延时,高频增删;
- 会员到期、预约提醒:几小时到几天,服务重启后必须恢复;
- 每日对账、定时报表:这其实是 cron 语义,不建议混进延时任务系统,单独用周期调度处理。
总结延时任务的典型特点:量大、要能取消、要能恢复、精度要求低(秒级足够)。方案选型就是围绕这四点做的。
二、四个方案逐一过
2.1 Timer:直接排除
JDK 最早的定时器,两大硬伤:
- 所有任务串行在一个线程里执行,一个任务慢,全部延时;
- 一个任务抛出未捕获异常,整个 Timer 线程死亡,后面所有任务静默失效——线上事故的常客。
新项目没有任何理由用 Timer,这里只是为了对比完整性。
2.2 ScheduledThreadPoolExecutor:多数场景的默认答案
Timer的多线程继任者,内部是小顶堆(DelayedWorkQueue):
- 插入/取消:O(log n);
- 任务异常只终止该任务所在的周期调度,不影响线程池;
- 多线程执行(回调线程的注意事项见 4.3 节)。
两个实战要点:
ScheduledThreadPoolExecutorexecutor=newScheduledThreadPoolExecutor(1,// 常用单线程,保证回调有序newThreadPoolExecutor.DiscardPolicy());// 按需// 要点1:取消的任务立即从堆中移除,否则大量已取消任务会滞留内存(JDK7+ 提供的开关)executor.setRemoveOnCancelPolicy(true);// 要点2:要支持取消,必须留住返回的 FutureScheduledFuture<?>future=executor.schedule(()->closeOrder(orderId),30,TimeUnit.MINUTES);// 用户提前支付时future.cancel(false);局限:几十万任务的规模下,O(log n) 的堆操作本身不是问题(log(100000) ≈ 17 次比较),真正的问题是"已取消任务滞留堆"和海量Future的管理成本——前提是你记得开removeOnCancelPolicy。
2.3 DelayQueue + 消费线程:最常见的自研形态
ScheduledThreadPoolExecutor是"到了时间在池线程里执行";很多系统更常见的模型是"到点后把回调投递到业务逻辑线程"(网关把会话清理投给 session 管理线程、交易系统投给撮合线程,都是这个形状)。用DelayQueue自己写个几十行就够:
publicclassDelayScheduler{staticfinalclassTaskimplementsDelayed{finallongexpireAt;// 绝对时间戳,见 4.2 节finalRunnableaction;volatilebooleancancelled;Task(longexpireAt,Runnableaction){this.expireAt=expireAt;this.action=action;}@OverridepubliclonggetDelay(TimeUnitunit){returnunit.convert(expireAt-System.currentTimeMillis(),TimeUnit.MILLISECONDS);}@OverridepublicintcompareTo(Delayedother){returnLong.compare(expireAt,((Task)other).expireAt);}}privatefinalDelayQueue<Task>queue=newDelayQueue<>();privatefinalExecutorlogicExecutor;// 业务逻辑线程publicDelayScheduler(ExecutorlogicExecutor){this.logicExecutor=logicExecutor;Threadworker=newThread(()->{while(true){try{Tasktask=queue.take();// 阻塞到最近任务到期if(task.cancelled)continue;// 惰性删除logicExecutor.execute(task.action);// 投递给业务线程,不在本线程执行}catch(InterruptedExceptione){Thread.currentThread().interrupt();return;}catch(Throwablet){// 兜底:一个任务的异常绝不能杀死调度线程(Timer 的教训)}}},"delay-scheduler");worker.setDaemon(true);worker.start();}/** 返回任务句柄,用于取消 */publicTasksubmit(longdelayMs,Runnableaction){Tasktask=newTask(System.currentTimeMillis()+delayMs,action);queue.offer(task);returntask;}publicvoidcancel(Tasktask){task.cancelled=true;// 惰性删除:不摘堆,到期取出时跳过}}不到 60 行,拥有取消、异常隔离、业务线程投递三个关键能力。
取消的成本注意:这里是惰性删除——取消只是打标记,节点仍留在堆里,到期被取出时跳过。大量"创建又立刻取消"的任务(比如每个请求都重置一次的空闲超时)会让堆里堆满僵尸节点,直到到期才被清理。这个量级如果大到可观,就该上下一个方案了。
2.4 时间轮:为海量增删而生
堆结构增删都是 O(log n);时间轮把它降到O(1),代价是精度降为 tick 粒度。
单层时间轮结构:
- 一个环形数组(比如 60 个槽),指针每
tickMs走一格; - 每个槽挂一条双向链表,存落到这个槽的任务;
- 任务带
rounds计数:轮到这个槽时,rounds > 0先减一(还没到执行的圈数),rounds == 0才执行; - 插入 = 算槽位 + 链表头插;取消 = 双向链表摘节点——都是 O(1)。
实现(可直接跑的骨架):
publicclassTimingWheel{staticfinalclassTaskNode{finalRunnableaction;finallongexpireTick;// 绝对到期 tick 序号introunds;// 还要跳过几次指针到达TaskNodeprev,next;Bucketbucket;TaskNode(Runnableaction,longexpireTick){this.action=action;this.expireTick=expireTick;}voidunlink(){if(bucket==null)return;if(prev!=null)prev.next=next;elsebucket.head=next;if(next!=null)next.prev=prev;elsebucket.tail=prev;prev=next=null;bucket.size--;bucket=null;}}staticfinalclassBucket{TaskNodehead,tail;intsize;voidadd(TaskNodenode){if(tail==null)head=tail=node;else{tail.next=node;node.prev=tail;tail=node;}node.bucket=this;size++;}}privatefinallongtickMs;privatefinalBucket[]wheel;// 环形数组privatefinalExecutorexecutor;privatelongcurrentTick=0;// 绝对 tick 计数(不走回头路)publicTimingWheel(intwheelSize,longtickMs,Executorexecutor){this.tickMs=tickMs;this.executor=executor;this.wheel=newBucket[wheelSize];for(inti=0;i<wheelSize;i++)wheel[i]=newBucket();}publicTaskNodesubmit(longdelayMs,Runnableaction){// 到期绝对 tick:向上取整,至少下一格longexpireTick=currentTick+Math.max(1,(delayMs+tickMs-1)/tickMs);TaskNodenode=newTaskNode(action,expireTick);intindex=(int)(expireTick%wheel.length);// 从现在到到期,指针会路过这个槽 (rounds+1) 次,前 rounds 次只减计数node.rounds=(int)((expireTick-currentTick-1)/wheel.length);wheel[index].add(node);returnnode;}publicvoidcancel(TaskNodenode){node.unlink();// O(1)}publicvoidstart(){longnextTickTime=System.currentTimeMillis();while(true){longnow=System.currentTimeMillis();while(nextTickTime<=now){// 补上落下的 tick(可能sleep过头)currentTick++;expire((int)(currentTick%wheel.length));nextTickTime+=tickMs;}longsleep=nextTickTime-System.currentTimeMillis();if(sleep>0){try{Thread.sleep(sleep);}catch(InterruptedExceptione){return;}}}}privatevoidexpire(intindex){Bucketbucket=wheel[index];TaskNodenode=bucket.head;while(node!=null){TaskNodenext=node.next;if(node.rounds>0){node.rounds--;}else{node.unlink();try{executor.execute(node.action);}catch(Throwableignored){}}node=next;}}}几个实现要点:
- 用绝对 tick 计数定位槽位和圈数,避免"相对偏移取模"在
delayMs恰好整除环长时的差一错误(rounds = (expireTick - currentTick - 1) / wheelLength这一行可以直接背下来); - 超过一圈的任务靠
rounds留在原槽里等下一圈,单层轮的环长决定了一个任务最多"睡"多少格——环长 60、tick 100ms,最远任务也就 6 秒,更长的任务要么加圈数,要么用层级时间轮; - 层级时间轮(Kafka / Netty 的做法):秒轮、分轮、时轮三层,任务先落高层轮,时间临近逐层降级,用几十 KB 内存表达任意时长的 O(1) 调度。多数业务用不上这个量级,知道思路即可。
三、方案对比与选型结论
| 方案 | 插入 | 取消 | 精度 | 适合规模 | 评价 |
|---|---|---|---|---|---|
| Timer | O(log n) | O(log n) | ms | 小 | 排除,异常处理是硬伤 |
| ScheduledThreadPoolExecutor | O(log n) | O(log n)(记得开 removeOnCancel) | ms | 万级 | 默认答案,简单可靠 |
| DelayQueue + 消费线程 | O(log n) | 惰性 O(1) | 自定 | 万~十万 | 最常用自研形态,投递业务线程天然契合 |
| 单层时间轮 | O(1) | O(1) | tick | 十万+,高频增删 | 海量短任务(连接超时类)首选 |
| 定时扫表 | 写 O(1) | 更新 O(1) | 秒 | 不限 +可持久化 | 长任务的正解 |
选型其实就三个问题:
- 量级:几千上万个任务 → STPE / DelayQueue 随便选,别过度设计;十万级且每秒大量增删取消 → 时间轮;
- 时长:分钟以内的进内存定时器;小时天级的(会员到期、预约类)根本不该进定时器——存 DB 记
expire_at时间戳,定时扫表 + 访问时惰性结算,重启天然可恢复; - 要不要和业务线程解耦:要 → DelayQueue + 投递模型;不需要 → STPE 一行搞定。
一个诚实的提醒:时间轮在文章里出镜率很高,但它的 O(log n) → O(1) 差距,要在每秒几万次增删的量级才真正兑现(Netty 拿它管理海量连接超时、Kafka 管理海量延时请求,都是这个场景)。普通业务的延时任务量用堆结构绰绰有余,别为了简历好看上时间轮。
四、工程上绕不开的三个问题
4.1 取消是刚需
用户提前支付、会话主动登出、重试提前成功——延时任务的取消频率可能远高于执行频率。设计时就要想好:
- API 必须返回任务句柄(Future / Task / TaskNode),业务侧持有它;
- DelayQueue 的惰性删除 vs 时间轮的链表摘除,取舍见上文;
- 高频"创建即取消"的场景(每次请求都重置的超时类),优先时间轮。
4.2 重启恢复:永远存绝对时间戳
持久化延时任务时,存expireAt绝对时间戳,不要存剩余时长:
- 存剩余时长:停机 2 小时,重启后任务原样"暂停过"了 2 小时——该关闭的订单多挂 2 小时,该过期的凭证多活 2 小时;
- 存绝对时间戳:重启后
expireAt < now的任务立即结算,其余照常入队。停机多久都正确。
重新入队也别忘了:业务状态数据和延时任务是两份数据,恢复时以业务状态为准重建任务,删掉孤儿任务。
4.3 回调必须在业务线程执行
单线程串行的业务模型在后端并不少见:一个模块的数据归一个线程管,用队列把并发操作串行化(网关的 session 管理、交易撮合、Disruptor 消费者都是)。延时器到点时如果在调度线程里直接改业务数据,就是对串行模型的破坏——数据竞争、偶现死锁、查不出原因的 NPE 都从这来。
正确姿势:调度线程只做"到点通知",把回调投递到目标业务线程的队列(2.3 的代码就是这个模型)。
五、总结
- 默认选
ScheduledThreadPoolExecutor - 十万级高频增删再上时间轮,注意圈数计算的差一陷阱;
- 长任务存库扫表,不进内存定时器;
- 三条军规:句柄可取消、绝对时间戳可恢复、回调进业务线程。