首页 / 资讯中心 / 文章详情

Spring Boot定时任务从单机到分布式:并发、异常与运维实战解析

Spring Boot定时任务从单机到分布式:并发、异常与运维实战解析 ★ FEATURED ARTICLE
说个真事儿。我接手过一个跑了两年的老项目定时任务全用Scheduled写的表面上岁月静好。结果某天运营跑过来说凌晨的报表数据不对一看日志那个任务在三天前就已经开始抛异常了——因为Scheduled的默认行为是任务异常后直接结束完全不重试也不告警日志淹没在几千行无关输出里。没人察觉数据就错了三天。这种事故在Spring Boot项目里太典型了。定时任务这个东西单看实现一个注解加一个方法就完事但它跑在生产环境里涉及并发、阻塞、异常、分布式锁、甚至调度平台的选型坑是一个接一个。我这篇就把Spring Boot定时任务从单机到分布式、从写代码到线上运维一层一层掰开讲把我实际踩过的坑和验证过的方案都放进去供你参考。1. 先弄明白Scheduled背后的执行逻辑很多人用了几年Scheduled只知其然不知其所以然。我建议你先搞清楚一个问题这段代码是谁在什么时机把它调度起来的1.1 EnableScheduling到底做了什么EnableScheduling是入口。Spring容器启动时会通过ScheduledAnnotationBeanPostProcessor扫描容器里所有Bean的方法找出带有Scheduled注解的方法把它们注册成一个调度任务表交给ScheduledTaskRegistrar管理。这一步很容易被忽略Spring Boot不是单独启动一个全新的调度框架而是在应用上下文里注册了一个TaskScheduler线程池所有的定时方法最终都是丢给这个调度器运行的。如果你没有做任何配置这个调度器的默认线程池大小是1。这是整篇文章最重要的一个知识点后面几乎所有诡异的现象都和它有关。1.2 触发时间是怎么计算出来的Scheduled支持三种触发方式fixedRate固定频率每次任务开始后隔N毫秒再执行下一次fixedDelay固定延迟上次任务执行完毕后隔N毫秒再执行cron按cron表达式在特定时间点触发这里有个特别容易混淆的点。fixedRate是到点就触发它不关心上一个任务是否执行完。如果任务耗时超过了间隔时间就会出现一个任务还没跑完下一个任务又进来的情况线程池被占满之后任务堆积。而fixedDelay是上一轮锅盖掀起来才开始计时天然不会重叠。拿我实际见过的一个数据同步任务举例业务方要求每5分钟同步一次订单开发随手写了fixedRate 300000结果有一次同步接口响应慢到8分钟两个同步任务并发跑起来数据库连接池都被打满了。换成fixedDelay之后同一类问题再没出现过。cron表达式的计算逻辑走的是CronExpression解析器Spring的cron比Quartz的简单一些不支持下弦月那种花哨语法但日常用足够。几个常用的表达式可以直接抄需求cron表达式每天凌晨2点执行0 0 2 * * ?每5分钟执行一次0 0/5 * * * ?每周一到周五9点执行0 0 9 ? * MON-FRI每月1号早上6点30分0 30 6 1 * ?1.3 一个工具类把上线时间提前算清楚很多故障其实不是程序写错是cron表达式写错了。我一般会写一个小的main方法把即将上线的表达式提前打印出未来10次触发时间确认无误后再部署。nextTriggerTime是CronTrigger提供的方法直接拿来用public static void main(String[] args) { CronTrigger trigger new CronTrigger(0 0 2 * * ?); for (int i 0; i 10; i) { Date next trigger.nextTriggerTime(new Date()); System.out.println(next); // 这里注意要更新基准时间否则永远是同一个结果 } }敲定上线时间之前花两分钟把未来触发时间列出来配合检查一次时区能省掉后续所有咦怎么没跑的排查时间。2. 定时任务最容易被坑的三件事并发、阻塞和异常这句话你可以标记一下定时任务真正的复杂度不在写而在运行时。我下面说的问题几乎每个维护过Spring Boot定时任务的人都会至少中一个。2.1 默认只有一个线程任务全是排队执行前面说了默认调度线程池大小是1。这意味着你写了5个定时任务它们不是同时跑而是一个接一个排队。假如任务A每天早上8点开始耗时3分钟任务B设定8点02分执行它并不会准点启动而是要等任务A跑完。这种问题在项目初期压根看不出来——任务量少、耗时不长排队的间隔几乎感知不到。等任务多起来、数据量大了你就发现某些低优先级的任务莫名晚点十几分钟甚至半小时查代码还查不出毛病。解法很简单在配置里显式调大线程池spring: task: scheduling: pool: size: 10这里我要提一个看似无关但非常影响排查的点如果你的项目用了spring.task.scheduling.pool.size却感觉没生效检查一下启动类或者配置类里有没有手动new过ThreadPoolTaskScheduler。如果你手动声明了一个TaskScheduler的BeanSpring Boot的自动配置就不会再生效了。这个我踩过配置写在yaml里但代码里有人注册了一个默认参数的调度器Bean把自动配置冲掉了。2.2 任务内部异常会让调度器失明Scheduled标注的方法默认情况下不做任何异常处理和重试。方法一抛异常这次执行直接结束等下一个触发点再来。如果你没有打印日志的习惯异常就像掉进下水道连个响声都没有。我之前排查过一个诡异的场景定时任务执行失败后第二天又自动恢复了正常。后来看到日志才明白——失败是因为数据库连接池被短时打满第三天连接池恢复任务又成功执行了。人不在现场完全看不出来它中间死过两次。所以两条建议必须落地定时任务方法内部要对异常做兜底捕获至少确保异常能被打印并且能被监控系统感知到。如果任务涉及数据一致性强烈建议把失败记录单独存一张task_error_log表内容包括任务名、失败时间、异常堆栈、处理状态方便后续补跑。代码风格可以参考这种Scheduled(cron 0 0 2 * * ?) public void syncData() { try { // 业务逻辑 } catch (Exception e) { log.error(syncData execute failed, e); saveErrorLog(syncData, e); // 可以再加企业微信/钉钉机器人通知 } }2.3 时区与自调用导致的隐蔽问题时区问题最经典。服务器如果部署在美国西海岸时区是UTC-8你cron写法里没有指定时区那凌晨2点其实是北京时间的下午……这个坑的隐蔽程度极高因为测试环境的AST时间通常和本机一致你自测永远发现不了。Spring Boot 2.2以上版本可以在Scheduled上直接指定zoneScheduled(cron 0 0 2 * * ?, zone Asia/Shanghai) public void dailyTask() { // ... }另一个隐蔽问题是事务在定时任务里失效。如果你在Scheduled方法里调用了同类里的另一个Transactional方法这个事务是起不来的——因为Spring事务是基于AOP代理实现的同类内部调用不走代理注解自然被绕过了。我之前就见过定时任务里批量修改订单状态方法跑完后有一部分改成功、一部分没改还死活查不到事务日志。解法是要么把需要事务的逻辑抽到一个独立的Service类里调用要么在定时任务方法内部通过TransactionTemplate手动控制事务边界。我个人更推荐后者的变体因为定时任务往往是一个长流程手动事务边界更清晰Autowired private TransactionTemplate transactionTemplate; Scheduled(cron 0 0 2 * * ?) public void process() { transactionTemplate.executeWithoutResult(status - { // 需要事务保护的DB操作 }); }2.4 给定时任务线程池做一次体检如果你怀疑自己的定时任务有隐形排队最快的验证方式是给调度器加上一个简单的监控注册一个TaskScheduler的Bean把ThreadPoolTaskScheduler暴露出来获取当前活跃线程数、线程池大小、阻塞队列长度定时打印到日志里。Bean public ThreadPoolTaskScheduler threadPoolTaskScheduler() { ThreadPoolTaskScheduler scheduler new ThreadPoolTaskScheduler(); scheduler.setPoolSize(10); scheduler.setThreadNamePrefix(scheduler-); scheduler.setWaitForTasksToCompleteOnShutdown(true); scheduler.setAwaitTerminationSeconds(30); scheduler.initialize(); return scheduler; }注意最后三行setWaitForTasksToCompleteOnShutdown(true)和setAwaitTerminationSeconds(30)的意思是应用关闭时给正在执行的任务最多30秒缓冲时间而不是粗暴地杀掉线程。这个细节在做优雅停机的时候很有用不然每次发布都可能中断一个跑了一半的任务。3. 改造成动态定时任务不用重启服务就能调整执行计划业务侧的需求永远在变。今天说每天凌晨跑明天说改成每两小时跑一次后天又要跳过周末。如果我们每次都改注解、改配置、重新发布运维和开发的沟通成本会高到离谱。所以动态定时任务在真实项目里基本是标配cron表达式存进配置表任务本身动态注册。3.1 先设计一张调度配置表动态配置先要有地方存。最朴素的表结构就三个核心字段不要一开始就设计得很复杂字段类型说明job_namevarchar任务标识对应代码里的任务名cron_expressionvarchar动态cron表达式statustinyint1启用 0停用updated_by / updated_atvarchar / datetime操作审计我建议把updated_by和updated_at加上不是为了花哨而是出问题的时候能追溯是谁在什么时候改的。我经历过的几次定时任务事故最后都靠这两个字段查到了改动记录。3.2 SchedulingConfigurer怎么用Spring官方就预留了动态注册的入口实现SchedulingConfigurer接口重写configureTasks。Configuration EnableScheduling public class DynamicSchedulingConfig implements SchedulingConfigurer { Autowired private JobConfigMapper jobConfigMapper; Override public void configureTasks(ScheduledTaskRegistrar taskRegistrar) { // 必须设置线程池否则还是单线程 taskRegistrar.setScheduler(Executors.newScheduledThreadPool(5)); // 注册任务A taskRegistrar.addTriggerTask( () - executeJobA(), triggerContext - { String cron jobConfigMapper.getCron(jobA); if (StringUtils.isBlank(cron) || disabled.equals(cron)) { return null; // 返回null 不触发 } CronTrigger trigger new CronTrigger(cron); return trigger.nextTriggerTime(triggerContext); } ); // 注册任务B同理 } private void executeJobA() { // 实际业务逻辑 } }这里有个反直觉的点addTriggerTask的第二个参数会在每次任务执行前被调用去计算下一次触发时间。也就是说你改了数据库里的cron表达式不用重启服务下一次执行就会自动按新表达式走。这是这个方案最方便的地方。注意看上面那个返回null的写法当配置里的cron被置为disabled时返回null表示不触发这就实现了停用任务的效果不需要你手动去删除注册过的任务。3.3 多任务情况下的动态调整很多人看完上面的例子会问如果我有20个任务难道要写20个addTriggerTask吗这当然不优雅。更量产的做法是把任务名和业务方法建立映射关系用Java 8的Consumer列表做注册Override public void configureTasks(ScheduledTaskRegistrar taskRegistrar) { taskRegistrar.setScheduler(Executors.newScheduledThreadPool(10)); jobHandlerMap.forEach((jobName, handler) - { taskRegistrar.addTriggerTask( () - handler.execute(), triggerContext - buildTriggerContext(jobName, triggerContext) ); }); }jobHandlerMap在应用启动时构建key是任务名value是封装好的业务执行器。这种方式适合任务数量多、需要统一管理的场景也方便后期接入管理平台。3.4 动态任务必须关注的边界动态化并不是银弹有几个边界情况你得提前想清楚修改只对下一次触发生效如果当前任务已经执行到一半改cron不会中断它。停用≠删除。返回null只是不做后续触发但已注册的ScheduledFuture仍然存在。如果你确实要彻底移除需要维护一个ScheduledTask注册表手动cancel()。配置加载失败时的兜底。数据库连接出问题时getCron可能抛异常进而导致整个调度器初始化失败。一定要在getCron外层加缓存或默认值比如内存里保留一份上一次成功的值。我遇到过最典型的坑是数据库里某条cron字段被手动改成了非法表达式0 0 * *结果任务是直接不跑了。排查半天才定位到是表达式校验没做。现在我会在getCron之后用CronExpression.parse校验一次非法表达式就落到默认值并且打一条警告日志。4. 多机部署时的定时任务困境与分布式方案选型只要服务上了K8s或者多机部署Scheduled的问题就藏不住了同一个任务在每台机器上都会执行一遍。如果你的任务是幂等写库那种还好顶多多跑几次如果涉及发短信、发邮件、拉取外部接口对账恭喜你事故预订了。4.1 为什么集群里任务会重复执行因为Scheduled的机制就是进程内的每个进程实例各自持有调度器互不感知。你扩容到3个Pod就相当于同时有3个闹钟在凌晨2点响起全部执行同一份任务。解决思路无非两条路一是让任务只在一台机器上执行leader选举二是让任务每个节点都可以执行但执行前抢一个分布式锁只有抢到锁的节点真正干活。4.2 方案一Redis分布式锁的轻量实现如果你的基础设施里有Redis这是成本最低的方案。核心代码就一段Scheduled(cron 0 0 2 * * ?) public void syncJob() { String lockKey job:lock:syncData; String requestId UUID.randomUUID().toString(); // 尝试获取锁5分钟内有效 Boolean locked redisTemplate.opsForValue() .setIfAbsent(lockKey, requestId, Duration.ofMinutes(5)); if (!Boolean.TRUE.equals(locked)) { log.info(syncJob skipped, lock not acquired, node: {}, nodeId); return; // 其他节点已有锁本轮跳过 } try { doSync(); } finally { // 释放锁之前判断requestId是否匹配防止误删别人的锁 if (requestId.equals(redisTemplate.opsForValue().get(lockKey))) { redisTemplate.delete(lockKey); } } }这段代码有两处细节容易被新手忽略有效期Duration.ofMinutes(5)不是随便给的。它必须大于任务预计最大执行时长否则任务还没跑完锁就过期了另一个节点趁虚而入。你需要根据自己的任务耗时去评估。如果任务经常超过5分钟就把值调大但也不要大得过分离谱因为有效期越大节点宕机后锁被占用的时间就越长。删除锁前要校验requestId。因为锁可能已经过期被另一个节点重新加锁你直接delete会把别人的锁删掉。加了requestId比对才能避免这种误操作。这套方案的优点是改动小、侵入性低你的定时任务还是Scheduled注解驱动只是方法内部加了个锁判断。缺点是它不是一个调度平台没有调度记录、失败重试、可视化查看之类的功能。任务多起来之后你依然会需要看板。4.3 方案二xxl-job的接入思路如果你的团队愿意引入开源组件xxl-job应该是目前国内接入成本最低的分布式任务调度平台。它之所以流行是因为设计理念很务实调度中心负责触发执行器负责干活调度中心不关心业务代码执行器通过HTTP方式回调注册。接入Spring Boot的步骤大概是这样部署调度中心xxl-job-admin配置执行器管理引入依赖配置文件里配上调度中心地址、执行器appName、端口在代码里通过XxlJob(jobHandlerName)标注任务方法Component public class SyncJobHandler { XxlJob(syncDataHandler) public void syncDataHandler() throws Exception { XxlJobHelper.log(syncDataHandler start); // 业务逻辑 XxlJobHelper.log(syncDataHandler end); // 返回成功/失败状态 } }任务配置、cron表达式、失败重试次数、告警邮件、运维操作都放在xxl-job-admin的管理界面上。它在线手动执行一次任务、查看调度日志、查询执行历史这些能力比自研Redis锁方案强太多。我见过不少团队把xxl-job当作分布式版的Scheduled来理解其实不准确。它真正的价值是让你把任务变成平台可管理的一种资源而不是散落在代码里的几行注解。另外如果你们用的是Spring Cloud Alibaba或者K8s生态也有更偏云原生的方案可选比如ElasticJob结合Quartz集群之类的。但从我的经验看中小团队首选xxl-job没毛病理由有三文档中文、部署简单、有UI。4.4 分布式场景下依然要守住的两个底线第一个底线是幂等性。分布式锁只是一个预防措施锁本身可能有边界情况比如网络抖动导致锁获取失败或者旧节点持有锁但已经僵死。真正可靠的定时任务不能指望只执行一次而是要保证重复执行也不出问题——这就需要你的业务处理本身是幂等的。写库前查重、发消息前检查状态、外部接口调用前做幂等键这些设计比任何锁都更安全。第二个底线是告警要及时。多机部署之后任务失败的影响面比单机更大。如果锁被某台机器长期持有但不干活其他节点永远跳过那这个任务就无声死亡了。所以分布式方案必须配套一个心跳/看门狗机制至少做到锁的持有时间超过maxExecuteTime时主动告警提醒人工介入。5. 定时任务上线后的监控与运维问题我见过太多团队定时任务上线靠跑了就行来验收运维阶段完全放手。其实定时任务是最需要监控的一类功能因为它不像接口那样有主动调用方失败和没跑从业务侧看起来是一样的没数据。5.1 看不到执行情况就是盲跑盲跑意味着你只能在出问题时被动感知往往还是业务方先发现问题。我自己的原则是任何定时任务上线至少要能看到三个指标——是否触发、是否成功、耗时多少。这三个基本要素都没有就谈不上运维。5.2 AOP织入一个任务耗时统计不用改任何业务代码用AOP给所有Scheduled方法做统一打点。用annotation表达式直接拦截注解方法即可Aspect Component public class ScheduledMetricAspect { private static final Logger log LoggerFactory.getLogger(ScheduledMetricAspect.class); Around(annotation(org.springframework.scheduling.annotation.Scheduled)) public Object aroundScheduled(ProceedingJoinPoint joinPoint) throws Throwable { long start System.currentTimeMillis(); String taskName joinPoint.getSignature().getName(); try { Object result joinPoint.proceed(); log.info(scheduled task [{}] finished, cost {} ms, taskName, System.currentTimeMillis() - start); return result; } catch (Throwable e) { log.error(scheduled task [{}] failed, cost {} ms, error: {}, taskName, System.currentTimeMillis() - start, e.getMessage(), e); throw e; } } }有了这段统一拦截配合Prometheus或者Spring Boot Admin你就能在监控面板上实时看到每个任务的执行耗时和失败次数。这两个指标一出现很多问题根本不需要等到业务方反馈你自己一眼就能发现异常。提示如果你用了xxl-job它自带的调度日志和告警已经覆盖了这部分AOP打点可以做得更轻一点甚至不需要。5.3 停止线上任务比启动它更需要谨慎想停一个线上任务的时候别直接改cron或者停容器。尤其是那些跑一半会产生脏数据的任务直接杀进程可能导致数据不一致。正确做法是设计一个关闭开关。比如在配置表里加一个status字段任务每次执行前先检查状态状态为停用就直接跳过。这种软停用的好处是当前正在执行的那一次会正常跑完后续不再触发。如果你用的是ScheduledFuture自管理的方式注意释放时机的顺序——先取消未来触发再等待当前执行完成顺序反了可能导致取消动作被忽略。5.4 迁移到任务调度平台的时机判断最后一件事聊聊什么时候应该从注解定时任务迁到xxl-job这类平台。我个人的判断标准很简单满足任意一条就建议迁服务部署实例数大于1定时任务数量超过10个任务之间存在依赖关系比如A跑完才能跑B业务方对任务执行时间有SLA要求这四个条件只要中了一个说明你已经离不开调度记录执行视图失败重试这些能力了继续用Scheduled硬扛后面一定会越来越难受。在我维护的项目里有一类任务是先在单机用Scheduled跑通的但上线验证稳定后我会立刻把它们迁移到调度平台因为这种任务一旦成交量上来出错成本远超迁移的成本。早迁早安心等出过事故再迁就得边救火边改手术了。最后分享一个不一定对但对我很有效的习惯我接手任何项目第一件事永远是花半小时把所有定时任务列成一张清单标明任务名、cron、执行节点、失败后果。这张清单看起来原始但在排查问题的时候它比任何代码注释都更有用。定时任务的建设从来不是写一个注解那么简单后面这一整套运行机制、监控手段、运维流程才真正决定它是否可靠。
阅读完成 · 觉得有帮助?
咨询建站