ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

Java定时任务全解析:从ScheduledExecutorService到分布式调度选型

Java定时任务全解析:从ScheduledExecutorService到分布式调度选型 做Java开发这些年经常遇到一类需求每隔几分钟同步一次数据、每天凌晨清理过期的token、每月1号生成上个月的报表这类“按固定规则重复触发”的动作就是典型的定期事件。很多新手第一反应是new一个Thread然后while(true)循环里sleep跑测试的时候看着没问题一上线就各种崩溃、重复执行、不执行。这篇文章就围绕“用Java怎么把定期事件管好”这件事把自带的调度器、Spring注解、重型框架、分布式场景逐层讲清楚并结合我实际踩过的坑给出一套可以直接落地的选型思路。1. 先搞清楚“定期事件”到底有几种类型否则后面全是坑1.1 三种触发模式固定速率、固定延迟、日历驱动在选技术方案之前先要把业务需求翻译成调度语义。我见过太多人把三种模式混为一谈导致任务行为完全偏离预期。第一种是固定速率fixed rate意思是“每隔5分钟触发一次”它只关心两次开始时间之间的间隔。比如你8点00分启动那么8点05、8点10、8点15都会各触发一次不管上一次任务跑了多久。适合用在“必须按时产生结果”的场景比如监控心跳上报。第二种是固定延迟fixed delay意思是“上次任务结束后过5分钟再跑下一次”。如果任务执行了3分钟下一次触发就在第8分钟如果任务执行了10分钟下一次就在第15分钟。它保证任务不会并发叠加适合批处理、数据同步这种“不能同时跑两份”的场景。第三种是日历驱动calendar-based典型就是Cron表达式“每天凌晨2点”“每周一9点”“每月1号”。它跟“每隔多久”无关只跟日历上的时间点有关。报表生成、日志清理基本都是这类。这三者的核心区别在于固定速率是按照墙上时钟切分的固定延迟是按照任务结束时间推进的日历驱动是按照日历规则匹配的。如果你把固定速率的任务做成固定延迟高峰期可能出现任务积压反过来如果业务要求每次都重新计时你用了固定速率任务执行时间一长下一次就可能和上一次重叠。1.2 一个“时间管理”需求对应的Java生态演进从Java本身的演进看定期事件的处理经历了三代变化最早是Timer/TimerTask然后是ScheduledExecutorService再到Spring的Scheduled和Quartz这类框架。三代方案不是替代关系而是适用边界不同。理解这条演进线你就能判断什么场景该用哪个而不是一头扎进某个框架里。后面几个章节我会逐个拆解你会发现一个问题所有语言层面的调度方案都是基于“单机、进程存活、时间准”这三个假设的。一旦你的应用跑多实例或者任务执行时间超过周期或者机器重启方案就要升级。这一点是做技术选型时最容易忽略的。2. Java自带的调度器能用但别随便用到生产环境2.1 Timer/TimerTask教科书里的方案生产中要绕开Timer是Java 1.3就有的调度器用法很简单Timer timer new Timer(clean-expired-token); timer.schedule(new TimerTask() { Override public void run() { // 清理过期token的业务逻辑 } }, 0, TimeUnit.MINUTES.toMillis(30));这段代码的意思就是延迟0毫秒启动之后每30分钟执行一次。如果你要做倒计时延迟执行一次timer.schedule(task, delay)就够了。但Timer有两个非常致命的设计缺陷。第一它内部只有一个工作线程所有注册到同一个Timer上的任务都是排队执行的只要有一个任务执行时间过长后面的任务全部延期。第二任何一个任务抛出未捕获异常整个工作线程会直接终止这个Timer就废了其他任务也永远不跑了。我见过一个真实事故有人用Timer去做多个定时任务其中一个任务偶发数据库连接超时异常一抛整个Timer线程死掉剩下的任务全部静默停止业务方过了三天才发现日报没生成。所以用Timer写生产代码等于把一个定时炸弹放在代码里。2.2 ScheduledExecutorService线程池化之后的正确姿势JUC包里的ScheduledExecutorService解决了Timer的单线程问题。它本质上是ThreadPoolExecutor的扩展核心调度逻辑是基于延迟队列实现的注册的任务会被封装成ScheduledFutureTask按触发时间排序后进队列工作线程到了时间就取出来执行。因为支持多线程即使一个任务执行超时或抛异常其他任务也不受影响。基础用法ScheduledExecutorService scheduler Executors.newScheduledThreadPool(2); // 固定速率每5分钟执行一次按启动时刻计算 scheduler.scheduleAtFixedRate(() - { collectMetrics(); }, 0, 5, TimeUnit.MINUTES); // 固定延迟上次执行结束后再过10分钟执行 scheduler.scheduleWithFixedDelay(() - { syncOrderData(); }, 0, 10, TimeUnit.MINUTES);这两个方法的差异非常重要直接对应1.1节说的两种模式。scheduleAtFixedRate是按照理想开始时间安排下一次任务如果执行时间超过了周期线程池里的线程会被占满新任务立刻开始就出现重叠scheduleWithFixedDelay则严格等上一次完成后再计时天然避免重叠。还有一个隐藏点ScheduledExecutorService的任务异常不会被打印出来。scheduleAtFixedRate执行的任务如果抛了异常这个任务的后续执行会被取消而且异常被吞掉你只能在日志里看到任务突然没了。所以业务代码里必须自己做好try-catch最好把异常包装成可控日志输出。2.3 自带方案的边界缺Cron、缺持久化、缺分布式协调ScheduledExecutorService已经是语言层面最好的方案但它有几个能力盲区。第一没有Cron表达式只支持固定速率和固定延迟做不到“每月1号凌晨3点”这种日历规则你得自己算下一次执行时间算起来很繁琐。第二没有持久化进程一重启内存队列里的调度计划全部丢失任务少可能无所谓任务一多就必须考虑重启补偿。第三没有分布式协调多实例部署时每个节点都会执行同一份任务重复执行的问题就暴露出来了。所以我的判断是如果你只在单机环境跑一个简单的固定间隔任务用ScheduledExecutorService完全够一旦需求里出现“按日历规则触发”“任务不能重复执行”“需要记录执行历史”就该往框架层走了。3. Spring Scheduled业务开发里出镜率最高的方案3.1 三个基础属性怎么选如果你的项目已经用了Spring Boot绝大多数定期事件根本不用自己启动线程池直接在方法上挂Scheduled注解即可。启动类或配置类上加EnableScheduling开启调度能力Configuration EnableScheduling public class ScheduleConfig { }然后业务方法Component public class TokenCleanTask { private static final Logger log LoggerFactory.getLogger(TokenCleanTask.class); // 每天凌晨2点执行 Scheduled(cron 0 0 2 * * ?) public void cleanExpiredToken() { log.info(begin clean expired token); // 业务逻辑 log.info(finish clean expired token); } // 启动后延迟5秒执行之后每30分钟一次 Scheduled(initialDelay 5000, fixedRate 1800000) public void syncConfig() { } // 上次执行结束后1小时再执行 Scheduled(fixedDelayString 3600000) public void refreshCache() { } }fixedRate和fixedDelay的语义和2.2节一致initialDelay用于应用启动后先等一段时间再开始第一个周期避免启动时和业务初始化冲突。Cron表达式则是日历驱动的核心。3.2 Cron表达式一句话说透网上关于Cron的教程很多但绝大多数把七个字段背一遍完事没有讲关键。Spring的Cron是六个字段秒、分、时、日、月、周顺序是固定的秒是第一个。举几个看得懂的例子表达式含义0 0 2 * * ?每天凌晨2点整0 */5 * * * ?每5分钟执行一次从0分开始0 0 9-18 * * MON-FRI工作日9点到18点每小时执行一次0 0 0 1 * ?每月1号0点执行这里有两个极易踩的坑。第一个是“日”和“周”两个字段不能同时设具体值大多数场景把其中一个写成?表示“不指定”。第二个是*和?的区别*表示匹配任意值?表示“此处不关心、由另一个字段决定”。如果两个字段都写具体值Spring会直接抛异常很多人第一次写Cron就是卡在这里。提示Cron表达式的解析是基于应用所在JVM的默认时区不是你代码里写的TimeZone.getDefault()在哪儿问题就藏在服务器时区和业务时区不一致上后面第5章专门讲。3.3 线程模型与并发控制Spring的Scheduled任务默认是单线程串行执行的。什么意思就是所有注解了Scheduled的方法在同一个调度线程里排队执行如果A任务跑了10分钟B任务即使到点了也得等着。想改成多线程很简单实现SchedulingConfigurer并自定义TaskSchedulerConfiguration public class ScheduleConfig implements SchedulingConfigurer { Override public void configureTasks(ScheduledTaskRegistrar taskRegistrar) { ThreadPoolTaskScheduler scheduler new ThreadPoolTaskScheduler(); scheduler.setPoolSize(8); scheduler.setThreadNamePrefix(scheduled-); scheduler.initialize(); taskRegistrar.setTaskScheduler(scheduler); } }线程池调多大不是随便填的。一个粗略的经验核心线程数 所有定时任务里最长执行时间的任务数 2保证长任务不会占完所有线程导致短任务饿死。这一步很多人不配置等任务一多才发现执行时间漂移。Spring方案的另一个能力缺口是本实例内并发控制。同一个任务上一次没执行完下一次又触发了Scheduled并不会自动帮你挡住。常见的兜底方案是分布式锁或者用ReentrantLock配合tryLock但分布式环境下必须用跨进程的锁这个在第4章展开。3.4 什么时候该放弃Spring自带的这套触发条件其实很清楚你需要任务的持久化应用重启后任务不丢失、失败重试机制、执行历史查询、集群环境下的精确调度这四样里只要占了两样Scheduled就不够用了。它本质上是一个轻量级、无状态、不落库的调度方案适合固定在代码里、周期相对稳定、不需要动态调整的任务。4. 分布式场景单机定时任务在集群里最容易翻车4.1 问题本质每个实例都会执行一遍假设你的订单同步任务用Scheduled(cron 0 */5 * * * ?)实现部署了两个实例那么每分钟两个实例都会各自执行一次同步逻辑数据被同步两遍轻则浪费资源重则主键冲突、重复扣减、账不平。这不是调度器的bug而是分布式系统的常态。要解决“同一个时间点只允许一个实例执行”核心思想就是“抢锁”。抢到锁的节点执行没抢到的直接跳过本轮。4.2 轻量解法Redis分布式锁 ShedLock最朴素的分布式锁解法是Redis的SETNX加过期时间Boolean locked redisTemplate.opsForValue() .setIfAbsent(lock:order-sync, 1, 5, TimeUnit.MINUTES); if (Boolean.TRUE.equals(locked)) { try { syncOrder(); } finally { redisTemplate.delete(lock:order-sync); } }这段代码有两个要注意的地方。第一个是必须在拿到锁后设置过期时间防止持有锁的进程宕机导致死锁。第二个是锁过期时间必须大于任务最长执行时间否则任务还没跑完锁就自动释放其他节点就会重新执行出现并发。自己写锁很容易出细节问题所以更推荐直接用ShedLock这个专门做“分布式定时任务锁”的库。它的设计思路是调度依然由Scheduled触发但真正执行前先往数据库或Redis里写一条锁记录成功获锁才执行执行完再释放。用起来大致是这样Component public class OrderSyncTask { Scheduled(cron 0 */5 * * * ?) SchedulerLock(name orderSync, lockAtMostFor 4m, lockAtLeastFor 30s) public void syncOrder() { // 业务逻辑 } }这里lockAtMostFor表示锁最长持有时间必须比任务预估最大执行时间略长lockAtLeastFor表示锁最少持有时间用来防止两个实例的时钟不同步导致短时间窗口内都被获锁。ShedLock支持数据库和Redis两种存储单机小规模集群用数据库表就够了。4.3 成熟框架XXL-Job的调度中心与执行器模型如果你连“调度触发、任务管理、执行日志、失败告警、动态修改执行时间”都想做那就要上真正的分布式调度平台了。目前国内Java生态用得比较多的是XXL-Job它的模型是调度中心Admin负责任务的调度和分配执行器Executor部署在业务服务里接收指令并执行。这个模型的好处在于调度职责和执行职责分离你可以动态调整某个任务的Cron而不用改代码重启服务也可以在调度中心看到每次执行的成功失败情况、耗时、日志。我用XXL-Job做过一个数据补偿服务几百个任务靠管理后台统一管理比全用Scheduled维护起来省心太多。4.4 选型指南什么规模用什么方案综合前面所有分析我给一个可以直接用的判断标准部署形态任务特征推荐方案单实例固定间隔、不落库、任务少ScheduledExecutorService单实例需要Cron、需要Spring管理Scheduled多实例少量任务、允许简单锁Scheduled ShedLock多实例任务多、要管理后台、要告警XXL-Job / ElasticJob这一层选型决定了你后面是轻松还是痛苦。我见过不少团队上来就上XXL-Job结果就两三个任务还得额外部署一个Admin运维成本反而比收益高。反过来任务超过几十个还在用Scheduled光排查执行历史就能让人崩溃。技术选型只看适配度不看好不好听。5. 我踩过的坑与最终建议五个比代码本身更重要的细节5.1 时区不一致任务在白天跑了有次我负责一个定时推送任务Cron写的是凌晨2点执行结果每天早上8点才推。定位半天发现服务器系统时区是UTC而应用一直用默认时区解析Cron凌晨2点(UTC)推到了北京时间上午10点。排查方法很简单在Java进程启动参数里强制指定Asia/Shanghai或代码里显式设置TimeZone.setDefault(TimeZone.getTimeZone(Asia/Shanghai))更规范的方式是在Scheduled处不用默认时区而是通过定制TaskScheduler的setTimeZone方法来固定Cron解析时区。不要依赖服务器系统时区这是生产环境的定时任务第一条铁律。5.2 任务重叠固定速率遇上慢执行任务上一次还没执行完下一次又开始了结果两个线程同时操作同一批数据出现重复。我用的解决办法分三步第一步评估任务的合理最大执行时间把调度模式从fixedRate改成fixedDelay或scheduleWithFixedDelay确保不重叠第二步在任务方法入口加分布式锁保险兜底第三步把内部业务改成幂等写入从数据层面保证重复执行不产生坏结果。三层叠加后重叠问题基本绝迹。5.3 异常要吞得明白不能静默消失前面提到过ScheduledExecutorService的定时任务抛异常会取消后续调度Scheduled也一样任务异常会导致调度线程异常终止。更麻烦的是异常不一定打印出来排查时日志里什么都没有。我现在的习惯是所有定时任务方法体只做三件事——try-catch抓异常、打error日志、记录失败标记。不要在任何定时任务里直接让异常抛出去因为调度器不是业务方它不会对你的异常做任何处理。Scheduled(cron 0 0 2 * * ?) public void safeTask() { try { doBusiness(); } catch (Exception e) { log.error(定时任务执行失败, e); // 可选写入任务失败表供补偿 } }5.4 应用重启与漏执行别指望调度器帮你补偿无论你用哪种调度方案JVM重启期间的定时任务都不会自动补执行。如果业务允许重启前做好“时间窗口补偿”设计任务每次执行时先查一下上次执行时间如果发现超过了预期间隔则先补一次执行。这个思路在很多数据同步场景里非常实用因为漏跑一次就等于丢了一批数据。我的做法是把“上次成功执行时间”存到数据库任务执行时先判断是否超过周期阈值是就先跑一次补偿再跑正常调度逻辑。这样即使运维半夜重启服务器数据也不会断档。5.5 最后的个人经验总结结合我这些年的实操经验帮你把决策成本降到最低能用Scheduled解决的就别引入框架能用一个库解决的就别自研所有定时任务都要显式处理异常和执行历史。我目前维护的项目里简单任务用Scheduled(cron)需要防止并发的加ShedLock任务量大、管理要求高的服务单独部署XXL-Job执行器三层各司其职线上运行一年多没再出过调度事故。定期事件本身不难难的是把它放在分布式、多实例、异常频发的真实环境里还能稳定按点跑起来。希望这篇文章能帮你少走几条弯路。
返回列表