图 1:本文封面,定时任务选型的二维坐标系。
这个系列另有一篇真题解析专门拆解 Netty 时间轮的内部机制,本篇换个视角:把「定时与延迟任务」这个大类的主流方案放到一张坐标系里横向对比,回答工程上更常遇到的问题——手里这批定时任务,到底该用哪个轮子。先给坐标轴:横轴是部署范围(单机到分布式),纵轴是任务特征(低精度海量任务到高精度强规则)。六个方案各就各位,看完机制再看场景。
Timer:教科书里的第一个,也是生产里第一个被淘汰的。
java.util.Timer 是 JDK 最早的定时设施:内部一个二叉堆管理任务,调度一次 O(log n),但所有任务共用一个后台线程串行执行。JDK 文档对它的两个致命伤写得很清楚:一个任务执行太慢会霸占线程、后续任务全部挤压排队;任务抛出未捕获异常,线程终止,整个 Timer 上所有任务跟着陪葬。单线程加无容错,决定了它只配出现在教学示例里。
ScheduledExecutorService:单机定时任务的默认答案。
JDK 文档把 ScheduledThreadPoolExecutor 直接称为「Timer 的更全能替代品」:线程池隔离让单个任务的异常不会拖垮整个调度器(只是该周期任务自身的后续执行会被抑制),schedule 支持任意时间单位,scheduleAtFixedRate 与 scheduleWithFixedDelay 区分了「按固定频率追赶」和「按固定间隔顺延」两种语义。绝大多数单机定时与周期任务——心跳上报、指标采集、定期清理——用它就够了,这是坐标系里覆盖面最广的一个点。
DelayQueue:当你要的不是「定时」而是「延迟后投递」。
DelayQueue 是一个无界阻塞队列:元素实现 Delayed 接口自带到期时刻,堆顶永远是到期最早的那个,take() 会阻塞到有元素到期才返回。JDK 文档的定义很精确——队头是「延迟已过期最久」的元素。它和 ScheduledExecutor 的区别在于语义重心:后者是「到点执行动作」,前者是「到点让数据可被消费」,天然适配订单超时关闭、缓存过期、延迟重试这类「数据等待一个期限」的场景。写个实测看它乱序插入、按到期顺序出队(JDK 21,javac DelayQueueDemo.java && java DelayQueueDemo):
插入顺序: 500ms, 200ms, 800ms
t= 210ms 出队: 缓存过期清理
t= 510ms 出队: 订单超时关闭
t= 808ms 出队: 重试投递
500 ms 的任务先插入却第二个出队,队列语义完全由到期时刻决定。规模上的限制也直观:所有任务驻留在本机内存的堆里,任务量冲到十万级以上时,堆的维护成本和内存占用都开始扎眼。
时间轮:海量、低精度超时场景的专用引擎。
把任务的增删从堆的 O(log n) 压到近似 O(1),代价是精度粗糙到一格 tick——机制细节这篇不展开,真题解析那篇讲得完整。选型上记住它的位置就够了:单机、海量任务、精度要求宽松,典型如网络框架里的连接空闲检测与握手超时,以及 Agent 系统里大批推理请求的过期清理。它不适合搬到「到点必须精确触发」的场景。
Quartz:当触发规则本身变复杂。
前面四个方案的触发条件都是「延迟多久」或「每隔多久」,而「每月最后一个工作日早上九点出账单」这种日历规则它们表达不了。Quartz 的 CronTrigger 用 6 到 7 段的 Cron 表达式描述任意日历规则,官方教程里的例子包括「每月最后一个周五」「每月 15 号最近的工作日」这类精确到历法的触发;再加上持久化 JobStore(任务定义入库,重启不丢)与集群支持,它把定时任务从「代码里的工具」升级成「可运维的调度系统」。代价是重量级:引入框架、建表、维护集群,为几个简单任务上 Quartz 属于杀鸡用牛刀。
分布式调度中心:当任务跨机器、要有人管。
任务量再大一个量级、执行方变成一群服务时,问题从「怎么触发」变成「谁来统一指挥」:中心化调度平台负责分片下发、失败重试、执行日志与可视化运维,执行器注册上来领任务。这是坐标系最右上角的方案,解决的是跨服务大规模批处理的组织问题,而不是单机效率问题。
图 2:六方案对照表与三问决策链。
决策链:先问三个问题。
任务量级多大?精度多苛刻?规则多复杂?三问答完,方案基本自己浮出来:单机普通定时用 ScheduledExecutorService;要「延迟后投递」语义用 DelayQueue;海量超时与心跳上时间轮;复杂日历规则交给 Quartz;跨机器统一管理才轮到分布式调度中心。注意这条链是「够用即停」——每一档往右都在用复杂度换能力,跳档之前先确认上一档真的撑不住。你项目里的定时任务现在跑在哪一档?有没有过「杀鸡用牛刀」或者反过来被简陋方案坑到的经历?欢迎聊聊。