
延时任务方案对比从 Timer 到时间轮摘要订单超时关闭、验证码过期、失败重试、连接空闲检测延时任务在后端到处都是。候选方案从 JDK 自带的 Timer、ScheduledThreadPoolExecutor到 DelayQueue 自研调度再到时间轮插入取消的复杂度、精度、可恢复性各不相同。这篇逐个过一遍实现要点和坑最后给选型结论什么时候堆结构就够什么时候才值得上时间轮以及长任务为什么根本不该进内存定时器。一、延时任务的需求长什么样先列场景感受一下需求订单/支付超时自动关闭中等时长分钟级中途要能取消用户提前支付了验证码、会话、临时凭证过期短延时高频创建失败重试、退避重连短延时且经常创建后立刻又取消重试提前成功连接空闲超时、心跳检测海量任务短延时高频增删会员到期、预约提醒几小时到几天服务重启后必须恢复每日对账、定时报表这其实是 cron 语义不建议混进延时任务系统单独用周期调度处理。总结延时任务的典型特点量大、要能取消、要能恢复、精度要求低秒级足够。方案选型就是围绕这四点做的。二、四个方案逐一过2.1 Timer直接排除JDK 最早的定时器两大硬伤所有任务串行在一个线程里执行一个任务慢全部延时一个任务抛出未捕获异常整个 Timer 线程死亡后面所有任务静默失效——线上事故的常客。新项目没有任何理由用 Timer这里只是为了对比完整性。2.2 ScheduledThreadPoolExecutor多数场景的默认答案Timer的多线程继任者内部是小顶堆DelayedWorkQueue插入/取消O(log n)任务异常只终止该任务所在的周期调度不影响线程池多线程执行回调线程的注意事项见 4.3 节。两个实战要点ScheduledThreadPoolExecutorexecutornewScheduledThreadPoolExecutor(1,// 常用单线程保证回调有序newThreadPoolExecutor.DiscardPolicy());// 按需// 要点1取消的任务立即从堆中移除否则大量已取消任务会滞留内存JDK7 提供的开关executor.setRemoveOnCancelPolicy(true);// 要点2要支持取消必须留住返回的 FutureScheduledFuture?futureexecutor.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.expireAtexpireAt;this.actionaction;}OverridepubliclonggetDelay(TimeUnitunit){returnunit.convert(expireAt-System.currentTimeMillis(),TimeUnit.MILLISECONDS);}OverridepublicintcompareTo(Delayedother){returnLong.compare(expireAt,((Task)other).expireAt);}}privatefinalDelayQueueTaskqueuenewDelayQueue();privatefinalExecutorlogicExecutor;// 业务逻辑线程publicDelayScheduler(ExecutorlogicExecutor){this.logicExecutorlogicExecutor;ThreadworkernewThread(()-{while(true){try{Tasktaskqueue.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){TasktasknewTask(System.currentTimeMillis()delayMs,action);queue.offer(task);returntask;}publicvoidcancel(Tasktask){task.cancelledtrue;// 惰性删除不摘堆到期取出时跳过}}不到 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.actionaction;this.expireTickexpireTick;}voidunlink(){if(bucketnull)return;if(prev!null)prev.nextnext;elsebucket.headnext;if(next!null)next.prevprev;elsebucket.tailprev;prevnextnull;bucket.size--;bucketnull;}}staticfinalclassBucket{TaskNodehead,tail;intsize;voidadd(TaskNodenode){if(tailnull)headtailnode;else{tail.nextnode;node.prevtail;tailnode;}node.bucketthis;size;}}privatefinallongtickMs;privatefinalBucket[]wheel;// 环形数组privatefinalExecutorexecutor;privatelongcurrentTick0;// 绝对 tick 计数不走回头路publicTimingWheel(intwheelSize,longtickMs,Executorexecutor){this.tickMstickMs;this.executorexecutor;this.wheelnewBucket[wheelSize];for(inti0;iwheelSize;i)wheel[i]newBucket();}publicTaskNodesubmit(longdelayMs,Runnableaction){// 到期绝对 tick向上取整至少下一格longexpireTickcurrentTickMath.max(1,(delayMstickMs-1)/tickMs);TaskNodenodenewTaskNode(action,expireTick);intindex(int)(expireTick%wheel.length);// 从现在到到期指针会路过这个槽 (rounds1) 次前 rounds 次只减计数node.rounds(int)((expireTick-currentTick-1)/wheel.length);wheel[index].add(node);returnnode;}publicvoidcancel(TaskNodenode){node.unlink();// O(1)}publicvoidstart(){longnextTickTimeSystem.currentTimeMillis();while(true){longnowSystem.currentTimeMillis();while(nextTickTimenow){// 补上落下的 tick可能sleep过头currentTick;expire((int)(currentTick%wheel.length));nextTickTimetickMs;}longsleepnextTickTime-System.currentTimeMillis();if(sleep0){try{Thread.sleep(sleep);}catch(InterruptedExceptione){return;}}}}privatevoidexpire(intindex){Bucketbucketwheel[index];TaskNodenodebucket.head;while(node!null){TaskNodenextnode.next;if(node.rounds0){node.rounds--;}else{node.unlink();try{executor.execute(node.action);}catch(Throwableignored){}}nodenext;}}}几个实现要点用绝对 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)记得开 removeOnCancelms万级默认答案简单可靠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十万级高频增删再上时间轮注意圈数计算的差一陷阱长任务存库扫表不进内存定时器三条军规句柄可取消、绝对时间戳可恢复、回调进业务线程。