ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Spring @Scheduled定时任务全解析:从注解到线程池的实践指南

Spring @Scheduled定时任务全解析:从注解到线程池的实践指南 定时任务这东西几乎是每个业务系统都躲不开的硬需求。数据同步、报表生成、缓存刷新、订单超时关单、对账跑批全是定时任务的活。我刚入行那会儿写定时任务最常用的是JDK自带的Timer和ScheduledExecutorService后来接触Quartz再后来在Spring Boot项目里开始用Scheduled用下来最大的感受就是声明式定时任务确实是Spring给业务开发的一份厚礼——你不需要继承任何基类不需要配置XML不需要关心调度线程是怎么创建的只要在一个方法上加一行注解任务就跑起来了。这篇文章我就围绕Scheduled注解把它的原理、参数、线程模型、异常处理、踩坑经验完整梳理一遍。不管你是在写订单关单、数据同步还是做定时报表看完都能直接上手也能避开那些文档里不会写的坑。1. 定时任务方案选型为什么Scheduled值得优先考虑1.1 项目里实现定时任务的几条常见路线先说结论定时任务的实现方式基本可以分成四大类每一类都有它适合的场景。第一类是JDK原生方案Timer和ScheduledExecutorService。Timer说实话现在很少有人直接用了单线程串行执行、任务异常会中断整个调度器、基于绝对时间调度容易受系统时间调整影响这些坑足够让人放弃它。ScheduledExecutorService好一些基于线程池支持scheduleAtFixedRate和scheduleWithFixedDelay但用起来还是偏底层你要自己管理任务定义、自己处理线程池的生命周期在业务系统里这么写代码会显得很裸。第二类是Spring的TaskScheduler抽象配合XML或Configuration注册ScheduledTaskRegistrar。这套东西能力上是够的但说实话配置样板代码偏多适合对调度有复杂定制需求的场景。大多数普通业务项目用不上这么重。第三类是Quartz。功能非常完整支持Job持久化、 misfire策略、集群部署健壮性很好。但代价是概念多、配置多JobDetail、Trigger、Scheduler三个维度你要理清楚而且引入之后整个项目的定时任务体系会明显变重。小团队维护起来是有负担的。第四类就是大型分布式任务调度平台比如XXL-Job、ElasticJob。它们解决的是分布式环境下任务重复执行、分片处理、任务运维可视化的问题功能确实强但需要额外部署调度中心、接入客户端工程成本不小。那Scheduled处在什么位置它是Spring Framework内置的轻量级方案走的是注解驱动的路线也就是标题里强调的声明式定时任务。你只需要两步在配置类上标注EnableScheduling开启调度支持在具体方法上标注Scheduled声明执行规则。中间的事情——调度线程、任务注册、触发逻辑——统统交给Spring容器处理。1.2 声明式的价值调度逻辑从业务代码里剥离理解Scheduled为什么优雅关键是理解声明式三个字意味着什么。在你的业务代码里每天凌晨3点同步一次数据每5分钟检查一次超时订单这些需求本质上由两个部分组成业务逻辑本身同步什么、检查什么和调度策略什么时候执行、多久执行一次。传统写法里这两者经常纠缠在一起。用ScheduledExecutorService的时候你得在一个方法里同时写业务代码和调度代码时间一变就要改代码而Scheduled通过注解把调度策略声明在方法之上业务代码里完全没有调度痕迹。我举个例子。你要写一个商品库存预警任务用传统方式大概是public void startInventoryWarningTask() { scheduledExecutorService.scheduleAtFixedRate(() - { // 查询库存 // 比较阈值 // 发送预警 }, 0, 5, TimeUnit.MINUTES); }而用Scheduled你的业务方法就是纯粹的业务方法Scheduled(cron 0 0 */1 * * ?) public void checkInventoryWarning() { // 查询库存 // 比较阈值 // 发送预警 }两者的区别不只是代码好看一点而是架构层面的业务方法不依赖任何调度机制想让它按分钟跑加个注解想让它按小时跑改一行注解甚至你可以不启用调度方法本身还是一个可以被其他业务主动调用的普通方法。这就是声明式风格最大的好处——关注点分离AOP思想的典型应用这也是Spring的注解体系能统一处理事务、缓存、异步、调度的底层逻辑。1.3 什么时候别用Scheduled把Scheduled夸了一大通但必须泼一盆冷水它不是万能的有些场景确实不该用它。首先是集群环境。假设你在生产环境部署了3个服务实例同一个Scheduled任务会在3个实例上各执行一次。对于幂等的任务比如重新计算一个缓存、定时清理临时文件重复执行问题不大但对于非幂等的任务比如生成批次号、扣减账户余额、发送短信通知重复执行就是事故。解决办法要么引入分布式锁后面第六章细讲要么直接上分布式调度平台统一分配任务执行权。其次是任务量很大的场景。当你有几十上百个定时任务并且需要跟踪每次执行的成功失败、耗时、执行历史甚至要在运行中手动触发、动态修改执行频率——Scheduled就力不从心了。因为它是声明完就不管了的模型没有自带的任务管理后台和监控面板。所以我的判断标准很简单单机部署、任务量可控、不需要可视化运维优先用Scheduled分布式环境、任务复杂、需要运维能力直接考虑专业的分布式调度平台。别盲目追求轻量也别一上来就上重型武器适合自己的就是最好的。2. Scheduled核心参数拆解cron、fixedRate、fixedDelay到底怎么选2.1 别忘了开启总开关EnableScheduling很多新手第一次用Scheduled把注解写在方法上启动服务后发现任务根本没执行找半天原因最后发现是漏了EnableScheduling。这个注解是Spring定时任务的总开关。它的作用可以理解成在Spring容器启动阶段扫描容器内所有Bean的方法上是否有Scheduled注解如果有就为这些方法创建任务并注册到调度器里等待触发。不加它Scheduled注解形同虚设Spring根本不会去解析它。用法很简单一般放在启动类或者某个配置类上Configuration EnableScheduling public class SchedulingConfig { }在Spring Boot项目里你也可以直接写在启动类上SpringBootApplication EnableScheduling public class Application { public static void main(String[] args) { SpringApplication.run(Application.class, args); } }有一点需要注意EnableScheduling是Import(SchedulingConfiguration.class)的元注解它本身会注册一个ScheduledAnnotationBeanPostProcessor这个后置处理器才是真正扫描Scheduled注解、把方法包装成ScheduledMethodRunnable并注册进调度器的幕后工人。理解了这一点后面排查为什么任务没执行时就有方向了——先检查总开关再检查方法签名和Bean是否被Spring管理。2.2 四个属性的区别与使用场景Scheduled注解最常用的属性有四个cron、fixedRate、fixedDelay、initialDelay。不少人是靠记忆硬背的但背完就混。其实只要抓住一个核心区别——以什么时间点为基准计算下次执行时间——就不会混了。先看fixedRate。它翻译过来是固定频率含义是按固定的时间间隔执行时间基准是上一次任务的开始时刻。比如Scheduled(fixedRate 5000)表示上一次任务开始执行5秒后下一次就会触发。注意它是从上一次开始时间算所以如果任务本身执行了3秒那实际间隔只有2秒如果任务执行了6秒超过了设定的5秒那下一次执行会被延迟因为单线程调度器不会在上一次还没跑完的情况下强行启动下一次。再看fixedDelay。它翻译过来是固定延迟含义是上一次任务结束时刻之后再隔固定的时间执行下一次。比如Scheduled(fixedDelay 5000)是上一次任务执行完成后等5秒再跑下一次。它和fixedRate的最大区别在于fixedDelay天然保证了任务不会重叠执行——如果任务需要10秒跑完那实际执行间隔就是10秒加5秒。initialDelay就简单了它是首次延迟单独使用没有意义要搭配fixedRate或fixedDelay一起用表示Spring容器启动后延迟多久执行第一次任务。为什么需要它因为有时候任务依赖一些初始化资源比如数据库连接池预热、缓存加载如果启动后立刻执行任务可能资源还没准备好加上一个initialDelay可以优雅地错开这个时间窗口。cron则是表达式调度最强大也最灵活支持秒级精确指定。每天凌晨3点执行“每月1号早上8点执行”“工作日每半小时执行一次”都可以用cron表达。标准格式是6位或7位秒 分 时 日 月 周 [年]。我用一个表格总结这四个属性的选择逻辑属性时间基准典型场景注意事项cron表达式匹配具体时间点每天定点跑批、报表生成支持秒级需要注意时区fixedRate上一次开始时间定时轮询状态、数据刷新任务执行时间不能超过间隔否则会延迟或重叠fixedDelay上一次结束时间消息消费后处理、链式任务天然防重叠适合必须跑完才跑下一次的场景initialDelay容器启动后预热任务、延迟上线必须和上面三者搭配使用2.3 参数选型时最容易踩的三个坑第一个坑是fixedRate和fixedDelay选错。我见过一个真实案例同事想每天同步一次数据到报表库担心任务执行超时特意用fixedRate 60000每分钟结果同步任务本身要跑两三分钟导致调度器里这个任务永远排队后面的任务也受影响。后来改成fixedDelay 60000问题立刻解决——同步跑完等一分钟跑下一次永远不重叠。第二个坑是cron表达式写错。Spring的cron格式和Linux的crontab不一样的Spring是支持秒的第一位是秒而不是分。很多人写过cron 0 0 12 * * ?这样的表达式在Linux里意思是每天12点整在Spring里这么写是在“第0秒、第0分钟、第12小时”执行——没错这个表达式在Spring里确实是“每天12点整”的意思但如果你写cron 0 12 * * * ?你以为的“每天12点”实际是“每小时的第12分钟的第0秒”。所以一眼看过去很容易搞混。另外cron表达式里日和周是互斥的不能同时指定具体的值否则报错。比如你想表达每月15号且是周一是不行的这时你需要用?占位其中一个字段表示不指定让Spring去匹配另一个字段。第三个坑是时间单位记错。fixedRate和fixedDelay的默认单位是毫秒不是秒。Scheduled(fixedRate 60)的意思是每60毫秒执行一次而不是60秒。这个坑极其隐蔽一旦写错你的任务几乎在疯狂执行数据库会被打爆。建议要么在属性上直接写明fixedRate 60_000用下划线分隔数字Java 7之后就支持可读性很好要么用TimeUnit常量配合计算比如fixedRate 60 * 1000。2.4 时区与动态配置两个进阶参数Scheduled还有一个zone属性用来指定时区。如果系统部署在多时区环境或者你希望某个任务是按北京时间的凌晨3点执行但这个服务器用的是UTC时区就需要显式指定Scheduled(cron 0 0 3 * * ?, zone Asia/Shanghai) public void generateDailyReport() { // 按北京时间生成报表 }指定了zone之后cron表达式计算的基准就是Asia/Shanghai时区即使服务器系统时区是UTC也会在北京时间凌晨3点准时执行。另一个实用技巧是利用SpEL表达式动态读取配置。Scheduled注解的各个属性都支持SpEL也就是说我们可以把执行周期放到配置文件里改配置不动代码schedule: order-check-cron: 0 */5 * * * ?Scheduled(cron ${schedule.order-check-cron}) public void checkTimeoutOrders() { // 检查超时订单 }这样做的价值在于不同环境开发、测试、生产可以用不同的调度周期生产环境改了配置还可以通过配置中心热刷新不需要重新发布代码。这也是我在项目里比较推荐的做法——定时任务的时间属于配置不属于代码。3. 从零落地Spring Boot集成Scheduled完整实操3.1 环境准备与依赖之所以用Spring Boot演示是因为它真的省心。要准备的东西其实非常少JDK 8及以上Maven或GradleSpring Boot项目spring-boot-starter基础依赖就足够不需要额外引入任何定时任务相关的依赖注意和Spring Security、JPA这类模块不同Scheduled是Spring Framework的spring-context模块自带的只要你的项目是Spring Boot项目会传递引入spring-context就天然支持不需要像集成WebSocket那样在pom里加独立的starter。3.2 第一个定时任务订单超时关单我以电商业务里最常见的订单超时自动关单为例演示完整的落地过程。场景定义下单后30分钟未支付系统自动把订单状态改成已取消并释放库存。第一步写一个定时任务组件不要把它写成普通方法要让它成为Spring容器中的BeanComponent public class OrderTimeoutTask { private static final Logger log LoggerFactory.getLogger(OrderTimeoutTask.class); Scheduled(cron 0 */1 * * * ?) public void closeTimeoutOrders() { log.info(开始处理超时订单任务); // 简化逻辑查询创建时间早于30分钟前、状态为待支付的订单 ListOrder orders orderMapper.selectTimeoutOrders(new Date(System.currentTimeMillis() - 30 * 60 * 1000)); for (Order order : orders) { order.setStatus(OrderStatus.CANCELED); orderMapper.updateById(order); // 释放库存等后续操作 stockService.releaseStock(order.getSkuId(), order.getQuantity()); } log.info(超时订单处理完成共处理{}条, orders.size()); } }这一步有两个关键点。第一类上必须有Component或类似注解确保这个类被Spring容器管理。如果这个任务类没有成为BeanScheduled在它上面就是无效的——因为Spring的调度器只会扫描容器里的Bean。第二Scheduled标注的方法必须是无参数方法签名上不能带形参否则Spring无法调用它。3.3 不要让时间硬编码配置驱动的写法上面代码里cron是硬编码的虽然能用但不够好。更规范的做法是放到配置文件里。在application.yml中添加task: order-timeout-cron: 0 */1 * * * ? order-timeout-delay-minutes: 30任务类改成Component public class OrderTimeoutTask { Value(${task.order-timeout-delay-minutes}) private Integer timeoutMinutes; Scheduled(cron ${task.order-timeout-cron}) public void closeTimeoutOrders() { // 业务逻辑查询 timeoutMinutes 分钟前未支付订单 } }这样测试环境想改成每5分钟跑一次直接改配置文件生产环境的关单周期也能在配置中心调整不用重新发版。我自己的习惯是所有定时任务的执行周期都走配置代码里不出现任何魔法数字。3.4 演示一个多任务的场景实际项目中定时任务往往不是一个而是好几个。多个任务共享同一个调度器它们的触发顺序和并发关系取决于线程配置下一章细讲。这里先看一个多任务的例子Component public class ReportTasks { // 每天凌晨生成前一天的销售报表 Scheduled(cron 0 5 0 * * ?) public void generateDailySalesReport() { // ... } // 每个整点刷新热门商品缓存 Scheduled(fixedRate 3600 * 1000, initialDelay 10 * 1000) public void refreshHotProductCache() { // ... } // 每小时清理一次过期日志每次跑完隔10秒跑下一轮 Scheduled(fixedDelay 10 * 1000, initialDelay 60 * 1000) public void cleanExpiredLogs() { // ... } }注意这里generateDailySalesReport用了cron是每天0点5分执行一次refreshHotProductCache用了fixedRate加initialDelay是每小时刷新一次启动后10秒先跑一次cleanExpiredLogs用的fixedDelay加initialDelay是启动后60秒开始每次清理完隔10秒再清理下一次。三个任务用了三种不同的调度方式分别对应指定时间点执行“按固定频率执行”“串行不重叠执行”三种需求。这也是实际项目里最常用的三种组合模式。4. 线程模型单线程的坑与线程池配置4.1 默认调度器只有一个线程后果是什么这是Scheduled使用中最容易被忽视、却又最容易出生产事故的一个点。Spring默认的调度器是单线程的。单线程意味着什么所有Scheduled任务都在同一个线程里排队执行。如果任务A执行需要10秒任务B原定在第5秒触发对不起B得等A执行完才能启动。更糟糕的是如果A是一个死循环或者一个被卡死的阻塞操作那么B、C、D全部停摆整个项目的定时任务体系瞬间瘫痪。我自己就踩过这个坑。有次线上一个定时同步任务因为调用的第三方接口超时每次执行要几分钟结果另一个负责订单超时关单的任务一直被延后执行导致大量超时订单没有及时关闭用户投诉刷屏。事后定位根因就是默认单线程调度器长任务把其他任务的“时槽”全部占掉了。所以我在项目里拿到任何带Scheduled的代码第一件事就是看有没有配置线程池。没有配置的话生产环境下迟早出事。4.2 线程池配置的两种姿势配置方式一直接注入一个TaskScheduler类型的Bean到Spring容器Spring检测到之后会用它作为默认调度器。Configuration public class SchedulingConfig { Bean public TaskScheduler taskScheduler() { ThreadPoolTaskScheduler scheduler new ThreadPoolTaskScheduler(); scheduler.setPoolSize(10); scheduler.setThreadNamePrefix(scheduled-task-); scheduler.setWaitForTasksToCompleteOnShutdown(true); scheduler.setAwaitTerminationSeconds(30); scheduler.initialize(); return scheduler; } }配置方式二实现SchedulingConfigurer接口通过ScheduledTaskRegistrar设置调度器。Configuration public class SchedulingConfig implements SchedulingConfigurer { Override public void configureTasks(ScheduledTaskRegistrar taskRegistrar) { ThreadPoolTaskScheduler scheduler new ThreadPoolTaskScheduler(); scheduler.setPoolSize(10); scheduler.initialize(); taskRegistrar.setTaskScheduler(scheduler); } }两种方式都行我习惯用第一种。有几个配置参数值得讲讲经验setPoolSize线程池大小。不是线程数越多越好也不是固定一个值。如果你的任务有20个但绝大多数任务都是几十毫秒跑完的轻任务10就够用了如果有几个任务会比较耗时长可以适当调大到16或32。经验法则是线程数略大于同时可能运行的长任务数没必要几十个线程在这里空转。setWaitForTasksToCompleteOnShutdown(true)应用关闭时等待正在执行的任务完成避免强制中断导致数据不一致。这个一定要配上否则每次发版重启正在跑的任务可能被直接杀掉。setThreadNamePrefix给调度线程命名。千万别小看这个参数线上排查问题时线程堆栈里你能看到的是scheduled-task-1还是pool-1-thread-1完全是两种排查体验。4.3 任务重叠执行怎么防止上一次还没跑完下一次又开跑即便配置了线程池还有一个问题单个任务本身的并发执行。默认情况下同一个Scheduled方法如果执行时间很长下一次触发时间到了调度器会再次调用这个方法——这意味着同一个业务方法可能同时被两个线程执行。在绝大多数业务场景里这是不能接受的。比如超时关单任务如果两个线程同时跑就可能出现两个线程同时查出同一个超时订单、同时去更新它的状态虽然最终状态可能一致但中间可能引发重复释放库存、重复发送通知等次生问题。防止重叠执行的方案有三种按从简单到复杂排列。方案一利用fixedDelay天然防重叠。前面说过fixedDelay是上一次结束再等固定时间本身就不会重叠。如果你的任务不需要精确的每小时执行一次选它最省心。方案二加一个执行状态标志。利用AtomicBoolean保证线程安全Component public class OrderTimeoutTask { private AtomicBoolean running new AtomicBoolean(false); Scheduled(fixedDelay 1000) public void closeTimeoutOrders() { if (!running.compareAndSet(false, true)) { // 上一次还没执行完本次直接放弃 return; } try { // 业务逻辑 } finally { running.set(false); } } }注意这里用AtomicBoolean而不是普通的boolean因为compareAndSet是原子操作能保证在高并发下只有一个线程能抢到执行权。方案三分布式环境下用分布式锁。如果应用是集群部署的上面两种方案都只能防住同一台机器上的重叠防不住多台机器同时执行。这时候要么用数据库悲观锁/乐观锁要么引入Redis分布式锁或者Redisson的可重入锁。我项目里最常用的是Redisson的tryLockScheduled(cron ${task.order-timeout-cron}) public void closeTimeoutOrders() { RLock lock redissonClient.getLock(lock:order-timeout); try { if (lock.tryLock(0, 10, TimeUnit.SECONDS)) { // 业务逻辑只有抢到锁的节点才执行 } } catch (InterruptedException e) { Thread.currentThread().interrupt(); } finally { if (lock.isHeldByCurrentThread()) { lock.unlock(); } } }这个模式非常实用既解决了单机重叠问题又解决了集群重复执行问题。但要注意分布式锁的过期时间上面的10秒要根据任务最大执行时间谨慎设置宁可大一点也不要锁提前释放导致两个节点同时进入临界区。5. 异常处理与任务状态感知避免静默失败5.1 定时任务里的异常千万别让它沉默Scheduled方法如果抛出异常默认行为是异常被调度器捕获并记录到日志里但任务的调度不会停止下一次触发时间到了照样执行。这个设计本身是中性的但问题在于如果你不在方法里处理异常也不配置全局异常处理器很多任务失败后的信息只是在日志文件里孤零零地躺着一行ERROR没有任何告警任务失败了你可能毫无知觉。更恼人的一种情况是某些异常被吞掉之后任务看起来正常执行了实际什么都没干。比如你用try-catch包住业务逻辑但catch块里只打了个printStackTrace生产环境日志一滚动这个异常就彻底消失了。我的经验是定时任务的异常处理要分策略对于可重试且重试有意义的任务异常应该在任务内部捕获并手动触发补偿机制。比如同步任务调第三方接口失败可以记录失败数据到一张重试表让下一次调度去处理未完成的数据。对于不可重试或重试无意义的任务比如生成日报表失败数据源挂了重跑也是失败那就应该让异常向上抛同时配合告警系统通知值班人员。一个比较稳的写法是Scheduled(cron ${task.sync-cron}) public void syncData() { long start System.currentTimeMillis(); try { // 业务逻辑 log.info(任务执行成功耗时{}ms, System.currentTimeMillis() - start); } catch (Exception e) { log.error(任务执行失败已进入人工补偿队列, e); // 调用告警接口或者写入失败补偿表 alertService.sendAlert(同步任务失败, e.getMessage()); } }5.2 用AOP切面统一感知任务状态如果定时任务很多每个任务里都手写try-catch和耗时统计代码会很冗余。这时候可以用AOP切面统一处理。思路是定义一个切面拦截所有标注了Scheduled的方法在方法执行前后记录状态、处理异常。Aspect Component public class ScheduledTaskAspect { Around(annotation(org.springframework.scheduling.annotation.Scheduled)) public Object aroundScheduledTask(ProceedingJoinPoint pjp) throws Throwable { String taskName pjp.getSignature().toShortString(); long start System.currentTimeMillis(); try { Object result pjp.proceed(); log.info(定时任务[{}]执行成功耗时{}ms, taskName, System.currentTimeMillis() - start); return result; } catch (Exception e) { log.error(定时任务[{}]执行失败耗时{}ms, taskName, System.currentTimeMillis() - start, e); // 统一告警 alertService.sendAlert(定时任务执行失败, taskName); throw e; } } }有了这个切面之后新增一个定时任务时只要在方法上标注Scheduled就自动获得了日志记录、失败告警、耗时统计的能力业务代码完全不需要关心这些横切关注点。这也是Spring注解体系的经典用法和Transactional、自定义注解的AOP实现思路完全一致。5.3 Transactional与Scheduled搭配时的一个大坑定时任务里经常要操作数据库很多人会直接在Scheduled方法上加Transactional然后就以为事务万无一失。实际上有一个需要注意的点如果Scheduled方法在内部调用了同类中的另一个Transactional方法事务是不生效的——因为this调用不经过Spring的代理对象事务注解不被解析。这就是经典的事务自调用失效问题。Component public class TaskService { Scheduled(cron 0 0 1 * * ?) public void mainTask() { // 这里的this.process()不会触发Transactional this.process(); } Transactional(rollbackFor Exception.class) public void process() { // 业务逻辑 } }解决方案有两个。一是把Transactional标注在Scheduled方法上让整个任务跑在一个事务里二是把事务方法拆到另一个独立的Service类中通过注入的Bean来调用这样才会经过代理对象。考虑到你可能想让任务的一部分逻辑独立事务比如成功一条提交一条失败不影响其他人的处理一般是拆Service更灵活。6. 真实踩坑记录常见问题与排查技巧实录6.1 任务不执行、执行两次、时间不准先把我在实际项目里遇到过的经典问题列成速查表按排查优先级排个序现象可能原因排查方向任务完全不执行没加EnableScheduling总开关检查配置类是否有这个注解任务完全不执行任务类没有被Spring管理检查是否有Component等Bean注解任务完全不执行方法不是无参数方法Scheduled方法不能有参数否则注册失败任务完全不执行cron表达式写错用在线工具验证表达式任务执行时间不对时区问题检查zone属性确认服务器时区任务执行两次应用部署了多个实例检查集群部署情况需要分布式锁任务执行两次类或方法被重复扫描检查ComponentScan是否扫了重复包任务被无限延后默认单线程调度被长任务阻塞配置线程池检查调度器线程状态任务执行频率过快/过慢fixedRate单位写错毫秒/秒混淆核对数值建议用60_000这种写法这里我想多说一句任务执行两次的排查。有一次我在测试环境发现定时任务在每分钟执行时出现了两个不同的上下文日志第一反应是EnableScheduling是不是写了两次。后来才发现是因为项目里用了ComponentScan扫描了多个包同一个任务类被两个不同的组件扫描路径分别注册了一份。所以如果你确认只部署了一个实例任务却双跑先检查是不是类被重复扫描、或者同一个任务类在父子容器里各注册了一回。6.2 集群环境下如何避免任务重复执行集群环境下的重复执行问题我在前文已经提到了分布式锁的思路。这里展开讲一种我在生产环境实践过的轻量方案。假设订单超时关单任务部署在3个节点上需求是每天凌晨1点执行一次超时关单扫描。如果不加任何防护3个节点会同时在凌晨1点扫同一批订单造成重复处理。用Redis分布式锁实现的思路是所有节点在任务启动时先竞争一把锁抢到的节点才执行任务其他节点直接放弃。用Spring Boot集成Redisson非常简单Component public class ClosingTask { Autowired private RedissonClient redissonClient; Scheduled(cron 0 0 1 * * ?) public void closeOrders() { RLock lock redissonClient.getLock(task:close-orders); try { if (lock.tryLock(0, 60, TimeUnit.SECONDS)) { doCloseOrders(); } } catch (InterruptedException e) { Thread.currentThread().interrupt(); } finally { if (lock.isHeldByCurrentThread()) { lock.unlock(); } } } }锁的leaseTime持锁时间要设置得足够长保证任务最慢也能跑完。60秒是兜底时间如果任务真的超过60秒还没跑完锁提前释放另一个节点就可能同时进来所以在tryLock抢锁成功的那一刻可以顺便把持锁时间设成比任务预估最大耗时长一些的值。如果项目里还没引入Redis也可以用数据库乐观锁实现任务启动时先执行一条UPDATE语句给任务记录加一个running标记只有UPDATE影响行数为1的节点才执行任务执行完再释放标记。本质思路是一样的——分布式场景下一定要保证同一时刻只有一个节点在干活。6.3 排查工具与定位经验线上定时任务出了问题给你几个我常用的排查动作看线程名。如果你给调度器配了setThreadNamePrefix(scheduled-task-)直接用jstack抓线程快照然后搜scheduled-task开头的线程就能看到所有定时任务线程在干什么。如果看到某个线程状态是WAITING或BLOCKED说明它被阻塞了如果是RUNNABLE且长时间没有变化要警惕是不是死循环。看调度日志。在ScheduledTaskAspect里统一打印任务执行时间和结果这是定位任务延后执行执行失败最直接的手段。没有切面也没关系Spring的调度器本身的日志级别调整到DEBUG后会打印每个任务的触发和提交信息临时排查很管用。看应用启动日志。Spring Boot启动时如果Scheduled方法被成功注册在日志里搜scheduled关键字通常能看到类似Scheduled task ... registered的痕迹。如果是类没有被Spring管理或者方法签名不对启动过程中往往会有WARN级别的日志提示别忽略。最后提供一个小技巧Scheduled的cron表达式如果写得不放心可以在单元测试里直接调用调度器的方法去验证。很多时候问题不是出在注解而是出在表达式本身。写一个SpringBootTest把表达式打印出来模拟一下下次执行时间成本极低能省很多上线后才发现问题的尴尬。我在实际项目中用了两三年Scheduled最大的体会是它真的让定时任务的开发成本降到了极低但你必须在要不要用它和怎么配好它这两个问题上想清楚。单机、轻量、少量任务直接上注解没毛病多实例、任务重要、需要追踪那就先把分布式锁和告警机制准备好再上。配置线程池和使用fixedDelay的习惯是我经历过线上事故之后才彻底长记性的。希望这篇梳理能帮你绕过这些弯路让Scheduled真正成为顺手且可靠的工具而不是下一次生产事故的起点。
返回列表