ARTICLE DETAIL

资讯详情

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

Spring Boot定时任务从单体到分布式:@Scheduled坑点与ShedLock实操

Spring Boot定时任务从单体到分布式:@Scheduled坑点与ShedLock实操 我们在实际开发里定时任务是个永远绕不开的话题。不管你是搞电商要定时关单还是做报表要凌晨跑批又或者只是每天清一下临时数据第一个想到的基本都是 Spring 自带的Scheduled。这个注解确实好用几行代码就能跑起来但真到了多实例部署的时候麻烦就来了——同一个任务在每台机器上都执行一遍数据重复处理、订单重复关闭各种幺蛾子都冒出来。这篇文章我会从一个实际做项目的角度把Scheduled的用法、坑点以及常见的分布式定时方案完整梳理一遍。没有太多教科书式的大道理更多的是我这几年代码里踩过的坑和总结出来的实操经验。无论你是刚接触 Spring Boot 定时任务的新人还是正在做分布式改造、纠结怎么选型的老手这篇文章都能给你一个比较清晰的参考。1. 定时任务开发的整体设计与思路拆解1.1 什么时候用 Scheduled什么时候该换方案先聊一个基本判断什么场景下直接用Scheduled就够了什么场景下必须上分布式方案。我在很多项目里看到一种现象团队一上来就引入一套分布式调度平台结果业务量根本达不到那个量级。其实定时任务选型和你的部署架构是强绑定的。如果你的服务是单体部署只跑在一台机器上或者即使有多实例但任务可以接受重复执行比如只是往一个表里幂等写入那Scheduled完全够用。它的优势非常明显零额外依赖、配置简单、Spring 容器内开发效率极高。但一旦你的服务用多实例部署并且任务会操作共享数据、调用外部接口、发送消息通知那么重复执行就是不可接受的。举个例子一个每天凌晨两点关闭超时订单的任务如果在两台机器上各执行一遍就有可能导致重复关单、重复退款。这时候你就必须考虑分布式锁或独立的调度平台了。还有一种中间状态就是多实例部署但业务可以短期容忍极少量的重复执行。这种场景我一般建议先用分布式锁兜底而不是直接上全套调度平台。毕竟引入中间件是有成本的维护成本也是成本。1.2 选型背后的核心考量我自己在做方案选型时基本上会从三个维度去判断任务量、一致性要求、团队维护能力。任务量很好理解。一天几十次、每次跑几秒的任务和每天几千个分片任务、每个跑十几分钟完全不是一个量级。前者用数据库锁或者 Redis 锁就能搞定后者可能要考虑 ElasticJob 这种自带分片能力的调度框架。一致性要求指的是任务重复执行带来的影响。如果任务本身有幂等性保护比如写了唯一索引、状态机校验那重复执行也没太大问题。但如果没有幂等保护外部渠道的接口你又控制不了那就要在调度层面做好互斥。我见过最典型的例子是定时向第三方支付平台发起查单由于没有做分布式互斥多实例同时查询结果在支付平台上产生了大量重复流水最后对账的时候才发现问题。团队维护能力则更现实。引入 XXL-JOB 意味着你需要部署调度中心、配置执行器、维护后台账号权限体系这些都是持续成本。如果团队只有两三个人我反而不太建议一上来就上全套平台先用 ShedLock 加 Redis 锁顶一阵子等业务量真的上来了再做迁移也不迟。2. Scheduled 核心细节与实操要点2.1 三条基本注解用法搞清楚Scheduled在 Spring Boot 里用起来很简单第一步在启动类或者任意的配置类上加EnableScheduling然后在你需要定时执行的方法上打Scheduled注解就行。但三个核心参数要分清楚很多人就栽在这里。第一个是fixedRate固定速率执行。比如Scheduled(fixedRate 5000)意思是从任务开始执行的时间点算起每 5 秒执行一次。注意这里有个隐藏逻辑如果上一个任务执行时间超过了 5 秒那下一个任务会等待上一个执行完成后立即执行而不是严格按 5 秒间隔来前提是默认的单线程调度器。第二个是fixedDelay固定延迟执行。Scheduled(fixedDelay 5000)表示上一次任务执行完毕后再等 5 秒执行下一次。这个参数特别适合任务本身执行时间不固定但你又希望任务之间不要重叠的场景。第三个是cron通过表达式控制执行时间。Scheduled(cron 0 0 2 * * ?)表示每天凌晨两点执行。它的表达能力最强但也最容易写错后面我会单独讲。还有一个initialDelay参数配合上面三个使用。它表示应用启动后延迟多久再触发第一次执行。这一点很实用尤其是项目刚启动时如果容器还没完全初始化好Bean 依赖还没组装完任务就开始跑了容易报错。我一般习惯设置initialDelay 10000给应用一个缓冲期。另外提醒一个细节Scheduled注解的方法不能有参数返回值也最好用 void。少数场景下你可以返回ScheduledFuture其实不行Spring 的注解方式不支持返回 Future 来动态取消任务。如果需要动态控制任务的启停得自己实现SchedulingConfigurer接口用TaskScheduler注册Runnable然后在运行时调用scheduler.schedule()获得ScheduledFuture再通过cancel()来停止任务。这个属于进阶用法但确实有人会遇到。2.2 线程池与异步的坑这绝对是我认为Scheduled最容易被忽视的地方。Spring 的Scheduled默认使用一个单线程的调度器也就是说如果你定义了多个定时任务默认情况下它们都在同一个线程里排队执行。问题来了。假如你有 A、B 两个任务A 每 10 秒执行一次但某次 A 执行卡住了跑了一个小时那 B 任务哪怕配置了每秒执行一次这一个小时内也完全不会执行因为它只能等 A 用完线程。这可能不是你想要的。解决方案有两种。第一种是在配置类里自定义一个TaskSchedulerBean指定线程池大小。推荐用ThreadPoolTaskScheduler设置poolSize和线程名前缀方便日志排查。第二种是给任务方法加Async让任务本身异步执行避免阻塞调度线程。这种方式需要同时在配置类上启用EnableAsync。不过要注意Async生效依赖 Spring 的代理机制如果方法是同类内部调用、或者类没有被 Spring 管理Async是不生效的。我个人的习惯是两个结合用定制一个带线程池的TaskScheduler作为定时任务调度的底座同时对耗时的任务方法再加Async让任务逻辑丢到业务线程池里执行调度线程只负责触发。这样才能避免一个任务阻塞导致所有定时任务“瘫痪”的悲剧。2.3 cron 表达式最容易写错的地方Scheduled(cron ...)里写的 cron 表达式和 Linux 里的 crontab 不完全一样。Spring 的 cron 表达式默认是 6 个字段秒 分 时 日 月 周年份是可选的第 7 字段。很多人拿网上 Linux 的0 2 * * *5 位表达式直接贴进来结果发现根本启动不了原因就是缺少了最前面的“秒”字段。一个标准的 Spring cron 表达式长这样0 0 2 * * ?。它的含义是秒为 0分钟为 0小时为 2每天执行一次。注意星号*和问号?的区别*表示任意值而?只在“日”和“周”两个字段中使用表示不指定具体值。因为日和周互相矛盾不能同时指定精确值你如果说“每月 15 号和每周五”调度器会无所适从。再举几个实际常用的例子来说明0 */5 * * * ?每 5 分钟执行一次。0 30 3 ? * MON每周一凌晨 3 点半执行。0 0 2 L * ?每个月最后一天凌晨 2 点执行。0 0 2 * * ? *每年每天凌晨 2 点执行最后一位代表年可以省略。还要注意支持逗号、斜杠、连字符这些特殊符号结合起来能表达非常复杂的调度需求不过实际开发中超过两三层嵌套的 cron 表达式可读性就很差了我建议尽量拆成多个简单的任务而不是炫技写一个巨复杂的表达式。提示Spring 在解析 cron 表达式出错时项目启动并不会直接报错而是等到了定时任务真正触发时才抛异常。这种隐性失败特别坑建议在测试环境里专门写一个测试用例去解析和触发一遍 cron 表达式确保它符合预期。2.4 异常处理、时区与动态配置定时任务方法内部的异常处理是个值得认真对待的问题。默认情况下Scheduled方法一旦抛出未捕获异常当前那一次执行会被中断但调度器不会杀掉整个任务后面的调度仍然会继续。真正让人头疼的是Spring 默认的日志里只会简单打印一下异常堆栈没有任务级别的告警和失败记录。所以我自己在做定时任务时一定会给方法内部包一层 try-catch手动记录任务名、执行时间、异常信息并且通过邮件或钉钉机器人做告警。时区问题也常见。Scheduled的 cron 表达式默认基于服务器默认时区解析。如果你的服务器设的是 UTC而业务希望按北京时间凌晨两点执行那表达式要写成0 0 18 * * ?才能对上。更稳妥的办法是在注解里显式指定时区Scheduled(cron 0 0 2 * * ?, zone Asia/Shanghai)这样无论服务器部署在哪个区域执行时间都能保持一致。还有动态配置。很多场景下我们希望运行时不重启服务就能调整任务的执行频率比如大促期间把关单时间从凌晨 2 点改成凌晨 1 点。比较常规的做法是把 cron 表达式写在配置中心或数据库里然后自己实现SchedulingConfigurer接口在configureTasks方法里动态读取配置并注册任务。这样每次配置变化后调用scheduler.schedule()重新注册即可。3. 从单体到分布式常见分布式定时方案对比3.1 为什么单机方案撑不住先说个直白的结论不是Scheduled不好而是单机部署本身扛不住高可用和高性能的要求。互联网应用基本都是多实例部署的常见的部署架构是 Nginx 在前后面挂两三个应用实例保证其中一个挂了还有别的主机在跑。这种情况下一个Scheduled注解的任务会在每个实例上都执行一遍。如果你只有读取数据的任务还好但定时任务的本质大多是“写操作”比如批量更新数据库、调用外部系统、发消息。重复执行带来的后果轻则脏数据重则生产事故。另外单机方案还有一个问题就是任务执行耗时和机器故障绑定。如果任务跑在 A 实例上A 实例在某次任务执行到一半时宕机了这个任务既没有执行完也没有其他人去补刀最终结果就是这次调度“凭空消失”。单机方案没有任何故障转移和补偿机制。所以一旦你的服务真的上了多实例分布式定时方案不是要不要做的问题而是必须做的问题。3.2 轻量分布式方案数据库锁、Redis 锁、ShedLock轻量级方案的核心思路是给任务加一把“分布式锁”保证同一时间只有一个实例能执行任务。最原始的方式是数据库表锁。创建一张任务锁表字段包括任务名、持有者、锁过期时间等。任务执行前尝试往表里插入一条任务名数据如果插入成功说明拿到锁任务结束后删除这条记录。这种方式简单直接但对数据库有一定压力而且要注意锁中断和删除的原子性。更规范一点的做法是在事务里使用SELECT ... FOR UPDATE对任务记录加行锁不过对数据库连接占用时间长高并发下要慎用。Redis 分布式锁是更常见的方案。利用 Redis 的SET NX EX原子命令多个实例抢同一个 key抢到的人才能执行任务。主流的 Redisson 框架已经封装了非常成熟的分布式锁还支持看门狗自动续期避免任务没执行完锁就过期的问题。不过引入 Redis 作为中间件本身就是架构层面的变化如果项目里还没有 Redis为了一个定时任务单独引入是否值得需要权衡。ShedLock 是专门为Scheduled设计的分布式锁实现。它的思路是拦截带SchedulerLock注解的方法在方法执行前尝试写入一条锁记录执行成功后在锁记录上更新状态锁过期后其他实例才能接管。它支持基于数据库表、Redis、ZooKeeper 等多种 LockProvider。对于已经在用Scheduled的老项目来说ShedLock 是侵入性最小、改造成本最低的方案后面我会专门讲它的实操。3.3 成熟调度平台Quartz 集群、XXL-JOB、ElasticJob如果你的定时任务已经复杂到一定程度单纯靠锁可能就不够了。比如需要任务编排、失败重试、分片并行、运维监控这时候就该考虑成熟的调度平台。Quartz 是老牌的调度框架支持集群模式。集群模式下多个 Quartz 节点共享同一个数据库通过数据库锁来保证任务分发唯一性。它的优点是成熟稳定、功能全面缺点是配置复杂而且它本身只是一个调度器没有自带运维界面任务的状态管理和日志都要自己二次开发。XXL-JOB 是国内社区非常活跃的分布式调度平台设计上分为调度中心和执行器两部分。调度中心负责任务的配置、触发、监控执行器负责实际执行业务逻辑。它有可视化的管理界面支持动态修改 cron 表达式、手动触发、失败重试、告警通知而且部署不算复杂所以在国内中小团队中使用率非常高。ElasticJob 是当当开源的分布式调度框架最突出的能力是分片。它会把一份任务拆分成多个分片分别派发给不同的执行器并行处理。对于处理大批量数据、比如每天要把几百万用户的数据重新计算一遍的任务分片带来的性能提升非常明显。相比 XXL-JOBElasticJob 更偏向嵌入式的 API 方式和 Spring 生态的整合也做得不错。3.4 选型对照为了让大家更直观地做选择我把上面几种方案整理成了对照表在后面选择时可以拿着表去和团队对需求。方案依赖组件分布式互斥运维界面分片能力改造成本适用场景Scheduled无无无无最低单体或可容忍重复数据库锁数据库有无无低多实例低频任务Redis 锁 / RedissonRedis有无无低多实例有 RedisShedLock数据库 / Redis / ZK有无无低Scheduled 改造迁移Quartz 集群数据库有需开发无中复杂调度、历史系统XXL-JOBMySQL有有弱中中小团队、可视化运维ElasticJobZK / 配置中心有有强中高大批量数据分片处理选型没有绝对的好坏核心看你的业务阶段和团队能力。我自己比较务实的建议是如果项目目前只有Scheduled且部署超过一个实例先上 ShedLock 兜底把重复执行的问题解决掉这是性价比最高的路径。等到后续对可视化运维、任务编排有真实需求的时候再平滑迁移到 XXL-JOB 这类平台。4. 实操基于 ShedLock 的分布式定时任务落地4.1 ShedLock 的运行原理ShedLock 解决的核心问题是让多个应用实例上的同一个Scheduled任务在同一时间只有一个实例能真正执行。它不是独立的调度平台也不是要替代Scheduled而是给你现有的Scheduled任务加一把分布式的“锁”。它的运行机制大概是这样应用实例在执行被SchedulerLock注解标记的方法前会先去 LockProvider 里尝试获取一把锁比如往数据库表里插入一条记录或者写入一个 Redis key。如果获取成功当前实例就正常执行业务逻辑如果获取失败说明已经有其他实例拿到锁了当前实例直接跳过这次任务。关键参数有两个一个是lockAtMostFor表示锁最长持有时间。为了防止任务执行到一半实例宕机导致锁永远不释放超过这个时间锁会自动过期其他实例就可以接管。另一个是lockAtLeastFor表示锁最短持有时间。它的作用是防止任务执行速度太快导致在一个调度周期内连续抢到多次锁。比如一个每分钟执行一次的任务如果实际执行只要 1 毫秒锁刚释放又到了下一次触发时间同一个实例就把相邻两次都执行了造成任务频率比预期高一倍。所以这里建议把lockAtLeastFor设置为略小于调度周期的一个值。ShedLock 拦截方法执行用的是 Spring AOP 的机制因此要求方法必须是 public且不能是同类内部调用否则代理不生效锁也就形同虚设。4.2 接入步骤与核心代码以 Spring Boot 项目为例接入 ShedLock 大概分四步。第一步引入依赖。如果你的工程是 Maven 管理在pom.xml里加上dependency groupIdnet.javacrumbs.shedlock/groupId artifactIdshedlock-spring/artifactId version5.13.0/version /dependency然后根据你选择的锁存储介质再引入对应的 provider。数据库场景用 JDBCRedis 场景用 Redis。这里我以 JDBC 为例dependency groupIdnet.javacrumbs.shedlock/groupId artifactIdshedlock-provider-jdbc-template/artifactId version5.13.0/version /dependency第二步创建存储锁的表结构。ShedLock 官方提供的建表 SQL 大致如下CREATE TABLE shedlock ( name VARCHAR(64) NOT NULL, lock_until TIMESTAMP(3) NOT NULL, locked_at TIMESTAMP(3) NOT NULL, locked_by VARCHAR(255) NOT NULL, PRIMARY KEY (name) );name字段是锁的名称也就是任务名。lock_until是锁的到期时间locked_at是加锁时间locked_by是实例标识通常会自动填上实例的 IP 和端口。第三步在配置类里注册 LockProvider。需要在 Spring 容器里声明一个LockProviderBean告诉 ShedLock 锁存到哪里Configuration EnableScheduling public class SchedulerConfig { Bean public LockProvider lockProvider(DataSource dataSource) { return new JdbcTemplateLockProvider( JdbcTemplateLockProvider.Configuration.builder() .withJdbcTemplate(new JdbcTemplate(dataSource)) .withTableName(shedlock) .build() ); } }注意这里的EnableScheduling必须显式声明确认调度总开关是打开的。第四步在你的定时任务方法上同时加Scheduled和SchedulerLockComponent public class OrderCloseTask { private static final Logger log LoggerFactory.getLogger(OrderCloseTask.class); Scheduled(cron 0 0 2 * * ?) SchedulerLock(name closeExpiredOrder, lockAtMostFor 10m, lockAtLeastFor 1m) public void closeExpiredOrder() { log.info(开始关闭超时订单); // 业务逻辑 } }name必须全局唯一多个实例之间就是靠这个名字来互斥的。lockAtMostFor设置好最长持有时间当任务意外卡死时这个参数能确保锁最终一定能被释放。如果使用 Redis 作为锁存储LockProvider的写法稍有不同Bean public LockProvider lockProvider(RedisTemplate redisTemplate) { return new RedisLockProvider(redisTemplate.getConnectionFactory()); }注意RedisLockProvider的构造参数是RedisConnectionFactory不是 RedisTemplate这点容易搞错。4.3 验证与效果代码写完之后怎么确认分布式锁真的生效了呢我一般会做这样的验证本地或者测试环境同时启动两个应用实例在定时任务方法第一行打印当前实例的 IP然后观察日志。正常情况下同一个时间点只有一个实例打印出“开始执行”的日志另一个实例会静默跳过。如果你发现两台机器同时打印了日志那就要检查一下SchedulerLock的 AOP 代理是否生效了最常见的原因就是方法被同类内部调用了。ShedLock 还有一个值得关注的地方就是它不会给任务排队。如果某个实例因为锁被其他实例持有而跳过那这一次调度就真的跳过了等下一次 cron 触发时继续竞争。因此它只适合那种“调度频率较高、偶尔跳过影响不大”的任务。如果你的业务是“每天一次、错过就要等第二天”的关键任务我建议在方法逻辑里做好补偿或者直接用 XXL-JOB 这种具备失败重试能力的平台。另外ShedLock 时钟依赖也比较重要。它的锁记录依赖于数据库或 Redis 的当前时间如果各个实例的系统时钟发生漂移可能导致锁的竞争行为异常。泄露一个我在生产环境遇到过的现象某台机器时间比另一台快了几分钟它老是抢到锁另一台几乎一直处于跳过状态。后来校准了所有服务器的 NTP 时间同步才恢复正常。5. 常见问题与排查技巧实录5.1 定时任务“没执行”的排查清单定时任务没执行是出现频率最高的疑问。很多人第一反应就是代码有问题但实际上很多坑集中在 Spring 配置这一层。我整理了一个排查清单按照顺序走一遍基本能定位问题。第一确认EnableScheduling有没有加。有的项目配置类比较多这个注解丢在一个没有被扫描的包里任务自然不触发。第二确认类有没有被 Spring 管理。定时任务所在的类必须标注Component或者通过其他方式注册为 Spring Bean。类的访问级别也有讲究方法最好是 public否则代理可能会有问题。第三检查是不是被线程池堵住了。这就是之前说的默认单线程调度器问题其他任务卡住会导致当前任务永远排不上。如果项目里同时存在多个定时任务我建议第一时间检查是否自定义了线程池。第四确认 cron 表达式本身是否合法。尤其是位数不对的问题少了第一位“秒”启动时不会报错但到触发时间也不会执行。第五查看有没有同类内部调用或者条件判断提前 return 了。有些方法开头就if (flag) return;看起来“没执行”其实是自己把逻辑短路了。5.2 任务重复执行的常见场景重复执行比不执行还让人心慌因为它的危害是静悄悄发生的。最常见的场景就是多实例部署但没加分布式锁每一台机器都在执行同一个任务。这种重复执行的监听手段也很简单在任务里把执行日志打成 JSON记录实例 IP 和任务名称然后看同一时刻是否有多条记录。第二种容易被忽略的场景是定时任务本身没跑完但下一次调度时间又到了。如果调度线程池比较小后面触发的任务会在队列里积压一旦积压的任务被“补跑”就会出现同一任务并行执行多个实例。这种情况产生的重复执行跟分布式锁都没关系纯粹是调度能力和任务耗时之间的匹配问题。建议把fixedRate改成fixedDelay从根上避免任务重叠或者用lockAtLeastFor保证一个调度周期内只执行一次。还有一种是应用重启导致的重复执行。比如任务在实例 A 上执行到一半A 被重启分布式锁的lockAtMostFor还没过期实例 B 只能等到锁过期后才能接管这段时间任务就是“空窗期”。但如果lockAtMostFor设置得太短A 还没重启完锁就释放了B 又会执行一遍等于新旧实例各执行一次。所以lockAtMostFor要设置成大于预估最长任务耗时的值这个值需要根据实际业务去压测评估。5.3 问题速查表我把日常被问得最多的几个问题整理成了一张速查表你可以直接截图或者收藏遇到问题先对号入座。问题现象可能原因解决方案任务完全不执行缺少 EnableScheduling启动类或配置类加上注解任务完全不执行类未被 Spring 管理加 Component 或注册 Bean任务完全不执行cron 表达式缺秒字段改为 6 位标准表达式多个任务只有一个执行默认单线程调度器自定义 ThreadPoolTaskScheduler同一个任务多实例重复执行缺少分布式锁接入 ShedLock 或 Redis 锁任务执行频率比预期高lockAtLeastFor 设置太短调大 lockAtLeastFor任务偶发跳过锁被其他实例持有确认锁配置和实例时钟同步任务方法里抛异常后后续不执行异常未捕获导致调度中断try-catch 包住业务逻辑时区不对导致执行时间偏移服务器时区和业务时区不一致cron 中显式指定 zone同类内部调用导致锁失效Spring 代理不生效拆到不同类或使用自注入代理5.4 排障时的两个小技巧最后分享两个我自己排障时很受用的技巧。一个是给每一个定时任务增加任务级的日志埋点和耗时统计。不要只打一条“任务开始”的日志而是打完开始时间后记一个System.currentTimeMillis()任务结束再打一条包含耗时的日志并把任务名称带上。这样哪个任务跑得久、哪个任务执行失败从日志里一眼就能看出来。我甚至见过有团队在定时任务上做 Metrics 打点把耗时和异常数量直接送到监控平台日报告警和 SLA 都能顺带解决。另一个是尽量捕获异常而不是抛出异常。调度线程池里的异常处理终归是有限的一旦任务方法抛异常线程池可能会清理掉这个线程虽然 Spring 会自动重建但重建过程中任务调度会产生一次额外的延迟。更有价值的是在 catch 块里记录完整的上下文信息任务名、实例 IP、参数、异常堆栈、当时的环境变量这些信息在定位线上问题时价值巨大。也可以顺手发一条告警到钉钉或者邮件把 MTTR 从小时级压缩到分钟级。我在实际项目里有过一次比较惨痛的教训一个凌晨跑的报表任务因为数据库连接池临时被其他业务打满连接获取超时抛了异常结果整个任务被中断第二天业务方才发现数据没生成。当时如果只是看错误日志很难意识到这个任务已经连续失败了三天。后来我在每个定时任务里都加了失败告警并且把每次执行的成功与否都写入一张任务执行记录表再做一个小查询页面才把这种“隐性失败”彻底暴露出来。定时任务这种在后台自动跑的东西最怕的就是失败了没人知道所以监控和告警一定不能省。定时任务的开发本身不难难点在于对各种异常情况有充分的准备。Scheduled给了我们一个低门槛的入口分布式锁和调度平台则负责解决多实例下的可靠性问题。从单体到分布式的选择没有统一的答案关键是根据自己项目的实际情况在成本和收益之间找到一个平衡点。希望我踩过的一些坑能帮你省点时间。
返回列表