☰
延时任务方案对比:从 Timer 到时间轮
2026/10/22 13:14:34 网站建设 项目流程

延时任务方案对比:从 Timer 到时间轮

摘要:订单超时关闭、验证码过期、失败重试、连接空闲检测,延时任务在后端到处都是。候选方案从 JDK 自带的 Timer、ScheduledThreadPoolExecutor,到 DelayQueue 自研调度,再到时间轮,插入取消的复杂度、精度、可恢复性各不相同。这篇逐个过一遍实现要点和坑,最后给选型结论:什么时候堆结构就够,什么时候才值得上时间轮,以及长任务为什么根本不该进内存定时器。

一、延时任务的需求长什么样

先列场景,感受一下需求:

  • 订单/支付超时自动关闭:中等时长(分钟级),中途要能取消(用户提前支付了);
  • 验证码、会话、临时凭证过期:短延时,高频创建;
  • 失败重试、退避重连:短延时,且经常创建后立刻又取消(重试提前成功);
  • 连接空闲超时、心跳检测:海量任务,短延时,高频增删;
  • 会员到期、预约提醒:几小时到几天,服务重启后必须恢复;
  • 每日对账、定时报表:这其实是 cron 语义,不建议混进延时任务系统,单独用周期调度处理。

总结延时任务的典型特点:量大、要能取消、要能恢复、精度要求低(秒级足够)。方案选型就是围绕这四点做的。

二、四个方案逐一过

2.1 Timer:直接排除

JDK 最早的定时器,两大硬伤:

  1. 所有任务串行在一个线程里执行,一个任务慢,全部延时;
  2. 一个任务抛出未捕获异常,整个 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) 调度。多数业务用不上这个量级,知道思路即可。

三、方案对比与选型结论

方案插入取消精度适合规模评价
TimerO(log n)O(log n)ms小排除,异常处理是硬伤
ScheduledThreadPoolExecutorO(log n)O(log n)(记得开 removeOnCancel)ms万级默认答案,简单可靠
DelayQueue + 消费线程O(log n)惰性 O(1)自定万~十万最常用自研形态,投递业务线程天然契合
单层时间轮O(1)O(1)tick十万+,高频增删海量短任务(连接超时类)首选
定时扫表写 O(1)更新 O(1)秒不限 +可持久化长任务的正解

选型其实就三个问题:

  1. 量级:几千上万个任务 → STPE / DelayQueue 随便选,别过度设计;十万级且每秒大量增删取消 → 时间轮;
  2. 时长:分钟以内的进内存定时器;小时天级的(会员到期、预约类)根本不该进定时器——存 DB 记expire_at时间戳,定时扫表 + 访问时惰性结算,重启天然可恢复;
  3. 要不要和业务线程解耦:要 → 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
  • 十万级高频增删再上时间轮,注意圈数计算的差一陷阱;
  • 长任务存库扫表,不进内存定时器;
  • 三条军规:句柄可取消、绝对时间戳可恢复、回调进业务线程。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询