ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

SpringBoot分布式定时任务防重复执行:ShedLock实战指南

SpringBoot分布式定时任务防重复执行:ShedLock实战指南

1. 为什么分布式定时任务需要一把“锁”——从重复执行的深夜告警说起

去年冬天一个凌晨三点,某跨平台系统监控后台突然弹出十几条红色告警:订单对账服务在30秒内被触发了7次。值班的某开发者一边灌下第三杯速溶咖啡,一边翻看日志——所有实例都显示“开始执行对账逻辑”,数据库里同一笔批次号被反复插入、更新、又回滚。这不是代码bug,也不是数据库死锁,而是典型的分布式定时任务重复执行。系统部署在三台容器上,每台都配置了@Scheduled(cron = "0 0 * * * ?"),凌晨整点一到,三台机器像约好了一样同时开干。没人告诉它们:“这个活儿,只能一个人干。”

这就是ShedLock存在的根本理由:它不解决“怎么跑任务”,而是解决“谁来跑任务”。SpringBoot原生的@Scheduled是单机模型,就像给每台机器发了一张闹钟表,但没人负责协调谁先起床、谁后刷牙。当服务横向扩展到多节点时,这张表就变成了“每人一张,各自为政”的混乱现场。ShedLock做的,是悄悄在所有机器之间架起一座桥,在桥中央放一把带编号的挂锁——每次任务启动前,所有节点都来桥上试一下这把锁:能挂上?行,你来干;挂不上?那就歇会儿,等锁空出来再说。它不侵入你的业务逻辑,不修改你的Cron表达式,甚至不需要你重写一行定时方法,只用加个注解、配个数据源,就把“多机争抢”变成了“有序轮值”。

关键词里虽然没填,但核心就三个字:分布式锁。注意,不是Redis锁、不是ZooKeeper锁、不是自己手撸的数据库for update锁——它是专为定时任务场景定制的轻量级协调机制。它不追求强一致性(比如秒级内的绝对互斥),也不承担通用锁服务的高并发压力,它的设计哲学很务实:只要保证“同一时刻,最多一个节点在执行该任务”,且失败时能快速释放、不阻塞后续调度。这种“够用就好”的定位,让它比通用分布式锁方案更简单、更稳定、也更容易集成进SpringBoot生态。如果你正面临“定时任务在集群里疯跑”“数据库被重复写入”“下游接口被高频调用触发限流”这类问题,那ShedLock不是可选项,而是必选项。它适合所有使用SpringBoot + 定时任务 + 多节点部署的团队,无论你是刚起步的创业项目,还是已运行三年的成熟系统——只要你的定时任务开始“不听话”,就是它该出场的时候。

2. ShedLock不是魔法,是四步落地的工程实践——从依赖引入到锁生效

很多人第一次集成ShedLock,卡在“加了注解却没效果”上。不是框架不灵,而是漏掉了关键环节。ShedLock的生效链条非常清晰:依赖引入 → 存储介质配置 → 注解启用 → 调度器适配。四个环节缺一不可,环环相扣。我见过太多团队在第三步加完@SchedulerLock就以为大功告成,结果日志里依然看到“Started execution of task”,却没看到“Acquired lock for…”——那说明锁根本没参与进来。下面我把每一步拆开,告诉你为什么必须这么做,以及每一步背后藏着什么容易被忽略的细节。

2.1 依赖引入:选对版本,避开Spring Boot 3.x的兼容陷阱

ShedLock官方提供了多种存储后端支持:JDBC、Redis、MongoDB、ZooKeeper等。对于绝大多数SpringBoot项目,首选JDBC模式,原因很简单:你已经有数据库连接池,不用额外维护Redis集群或ZK节点,运维成本最低。Maven依赖如下:

<dependency> <groupId>net.javacrumbs.shedlock</groupId> <artifactId>shedlock-spring</artifactId> <version>5.12.0</version> </dependency> <dependency> <groupId>net.javacrumbs.shedlock</groupId> <artifactId>shedlock-provider-jdbc-template</artifactId> <version>5.12.0</version> </dependency>

注意版本号:5.12.0是当前(2024年)对Spring Boot 2.7.x和3.1.x均兼容的稳定版本。如果你用的是Spring Boot 3.x(基于Jakarta EE 9+),千万别用5.10.0及以下版本,否则会报java.lang.NoClassDefFoundError: javax/annotation/PostConstruct——因为老版本还依赖Java EE的javax.*包,而Spring Boot 3.x已全面切换到jakarta.*。这个问题在社区里被问烂了,但新手依然会踩。解决方案只有两个:要么升级ShedLock到5.12.0+,要么降级Spring Boot(不推荐)。另外,shedlock-spring是核心模块,提供注解和AOP切面;shedlock-provider-jdbc-template是JDBC实现,二者必须同时引入,缺一不可。只引前者,编译能过,运行时报No lock provider found;只引后者,注解根本无法被识别。

2.2 存储介质配置:一张表,两个字段,别让锁表成性能瓶颈

ShedLock的JDBC模式依赖一张数据库表来记录锁状态。官方SQL脚本在GitHub仓库里有各数据库版本,但实际使用中,我建议手动建表,并做三点优化:

CREATE TABLE shedlock ( name VARCHAR(64) NOT NULL PRIMARY KEY, lock_until TIMESTAMP(3) NULL, locked_at TIMESTAMP(3) NULL, locked_by VARCHAR(255) NULL ); CREATE INDEX idx_shedlock_until ON shedlock(lock_until);

第一,主键name用VARCHAR(64)而非TEXT,因为锁名是任务全限定名(如com.example.job.OrderSyncJob.execute),长度可控,用TEXT反而影响索引效率;第二,lock_until字段加索引,这是查询“哪些锁已过期可释放”的关键字段,没有它,每次获取锁都要全表扫描;第三,TIMESTAMP(3)带毫秒精度,避免因数据库时间精度不足导致“锁已过期但未被清理”的假死现象。这张表不需要任何业务关联,纯粹是ShedLock的元数据表,所以放在主库还是从库?答案是:必须放在主库,且不能走读写分离代理。因为ShedLock的加锁操作是INSERT ... ON DUPLICATE KEY UPDATE(MySQL)或MERGE(PostgreSQL),本质是写操作。如果路由到从库,直接失败。我们曾在一个用MyCat做分库分表的项目里栽过跟头:ShedLock配置指向了逻辑库名,而MyCat把写请求错误地转发到了只读节点,结果所有锁获取都返回null,任务照常重复执行。排查了两天才发现是中间件路由规则没配对。所以,最稳妥的做法是:单独配一个直连主库的数据源,专供ShedLock使用,和业务数据源完全隔离。

2.3 注解启用:@SchedulerLock不是装饰品,参数含义决定锁行为

加注解是最简单的一步,但也是最容易误解的一步。看这段典型代码:

@Component public class OrderSyncJob { @Scheduled(cron = "0 0/5 * * * ?") // 每5分钟执行一次 @SchedulerLock(name = "orderSyncJob", lockAtMostFor = "10m", lockAtLeastFor = "5m") public void execute() { // 同步订单逻辑 syncOrders(); } }

这里三个参数必须理解透:

  • name = "orderSyncJob":锁的唯一标识。它不一定要和方法名一致,但必须全局唯一。如果多个方法共用同一业务逻辑(比如一个同步入口,一个补偿入口),它们应该用同一个name,否则锁就形同虚设。我们曾有个项目,syncOrders()和retrySyncOrders()用了不同name,结果补偿任务总在主任务执行中途插队,造成数据错乱。

  • lockAtMostFor = "10m":锁的最大持有时间。这是安全兜底机制。假设任务正常执行只需3分钟,但某次因网络抖动卡在下游接口,跑了12分钟。如果没有这个参数,锁会一直挂着,其他节点永远拿不到,任务就彻底停摆。设为10分钟,意味着:一旦持有超时,ShedLock会强制认为该锁已失效,允许其他节点抢占。这个值必须大于任务99%情况下的执行耗时,但不能过大(比如设成1小时),否则故障恢复太慢。

  • lockAtLeastFor = "5m":锁的最小持有时间。这是防“惊群效应”的关键。假设任务执行很快,20秒就完了,但Cron是每5分钟触发一次。如果没有这个参数,A节点执行完立刻释放锁,B节点马上抢到,C节点再抢……三台机器轮流执行,看似没重复,实则把“5分钟一次”的节奏打乱成了“20秒一次”,下游系统可能被压垮。设为5分钟,意味着:即使任务20秒结束,锁也会强制挂满5分钟,确保下次调度前,其他节点根本没机会抢。这个值应等于或略大于Cron间隔。

提示:lockAtLeastFor和lockAtMostFor的单位支持ms、s、m、h,字符串格式如"30s"、"2m"。不要写成30000(毫秒数),ShedLock只认字符串解析。

2.4 调度器适配:TaskScheduler必须由ShedLock包装,否则注解无效

这是最隐蔽的坑。很多项目加完注解、配好表、跑起来发现没效果,日志里既没有“Acquired lock”,也没有“Failed to acquire lock”,仿佛注解被完全忽略了。原因只有一个:Spring的TaskSchedulerBean没有被ShedLock代理。ShedLock不是靠字节码增强,而是通过Spring AOP,在TaskScheduler执行任务前,动态织入锁获取逻辑。如果你用的是默认的ThreadPoolTaskScheduler,必须显式创建一个被LockableTaskScheduler包装的Bean:

@Configuration @EnableScheduling public class ShedLockConfig { @Bean public LockProvider lockProvider(DataSource dataSource) { return new JdbcTemplateLockProvider( JdbcTemplateLockProvider.Configuration.builder() .withJdbcTemplate(new JdbcTemplate(dataSource)) .usingDbTime() // 使用数据库服务器时间,避免各节点时钟偏差 .build() ); } @Bean public TaskScheduler taskScheduler(LockProvider lockProvider) { ThreadPoolTaskScheduler scheduler = new ThreadPoolTaskScheduler(); scheduler.setPoolSize(5); scheduler.setThreadNamePrefix("shedlock-scheduler-"); scheduler.initialize(); // 关键:用ShedLock包装原始scheduler return new LockableTaskScheduler(scheduler, lockProvider); } }

注意两点:第一,@EnableScheduling必须加上,否则@Scheduled本身就不生效;第二,LockableTaskScheduler构造时传入的TaskScheduler必须是你自己new出来的,不能直接@AutowiredSpring默认的TaskScheduler,因为那个Bean可能已被其他配置覆盖或代理。我们曾在一个老项目里,ThreadPoolTaskScheduler被另一个配置类提前初始化并注册为Bean,结果LockableTaskScheduler包装的是一个空壳,锁自然失效。解决方案是:所有TaskSchedulerBean定义,统一收口到ShedLockConfig中,确保可控。

3. 锁的生命周期与状态诊断——从日志里读懂ShedLock在想什么

ShedLock的日志是调试的黄金线索。它不提供Web UI,也不暴露Metrics端点(除非你手动集成Micrometer),一切状态都藏在日志里。但它的日志级别设计得很聪明:INFO级别只输出关键决策,DEBUG级别才展开技术细节。要想真正掌控它,必须学会“读日志”。下面我以一次典型的任务执行周期为例,逐行解析日志背后的含义,并指出哪些日志出现代表成功,哪些出现预示风险。

3.1 正常流程日志链:一次成功的锁获取与释放

假设当前时间是2024-06-15 10:00:00,Cron触发,节点A开始执行:

INFO c.e.j.s.LockableTaskScheduler - Starting task 'orderSyncJob' INFO c.e.j.s.LockableTaskScheduler - Acquired lock for 'orderSyncJob' INFO c.e.j.s.OrderSyncJob - Starting order synchronization... INFO c.e.j.s.OrderSyncJob - Sync completed, 128 orders processed. INFO c.e.j.s.LockableTaskScheduler - Released lock for 'orderSyncJob'

这六行日志,构成了ShedLock健康运行的完整证据链:

  • 第一行Starting task:ShedLock拦截到调度器要执行任务,开始介入;
  • 第二行Acquired lock:最关键的SUCCESS信号。它表示节点A成功在数据库表中插入/更新了shedlock记录,lock_until被设为now() + lockAtMostFor(比如10:10:00),locked_at是当前时间,locked_by是本机IP+进程ID;
  • 第三、四行:你的业务日志,证明任务确实在锁保护下执行;
  • 第五行Released lock:任务执行完毕,ShedLock主动将lock_until置为NULL,释放锁。

注意:Released lock不是必须的。如果任务执行中抛出未捕获异常,ShedLock会尝试回滚事务并释放锁,但如果异常发生在JDBC连接关闭后,这条日志可能不会打印。所以判断锁是否释放,最可靠的依据是查数据库表:SELECT * FROM shedlock WHERE name = 'orderSyncJob',如果lock_until为NULL,说明已释放;如果不为NULL且小于当前时间,说明锁已过期,可被抢占。

3.2 异常场景日志诊断:当锁“失联”时你在看什么

现实远比理想复杂。下面这些日志组合,往往指向具体问题:

场景一:锁获取失败,但任务仍执行

INFO c.e.j.s.LockableTaskScheduler - Starting task 'orderSyncJob' WARN c.e.j.s.LockableTaskScheduler - Failed to acquire lock for 'orderSyncJob', will not execute

这看起来是“锁生效了”,但仔细看第二行是WARN而非ERROR,且末尾写着will not execute。这意味着ShedLock成功阻止了重复执行——这是预期行为,不是错误。此时你应该去查数据库:SELECT * FROM shedlock WHERE name = 'orderSyncJob',大概率看到lock_until是一个未来时间(比如10:05:00),说明节点B正在执行,节点A知趣退场。这种日志每天出现几次完全正常,是分布式协调的日常。

场景二:锁表无记录,但日志显示“Acquired”

INFO c.e.j.s.LockableTaskScheduler - Starting task 'orderSyncJob' INFO c.e.j.s.LockableTaskScheduler - Acquired lock for 'orderSyncJob' ... INFO c.e.j.s.LockableTaskScheduler - Released lock for 'orderSyncJob'

但查shedlock表,name = 'orderSyncJob'的记录为空。这说明ShedLock的JDBC操作根本没有落库!常见原因有两个:一是数据源配置错误,指向了错误的数据库或schema;二是事务管理器没配对,ShedLock的INSERT/UPDATE操作在一个只读事务里执行,被静默回滚。解决方案:开启JDBC驱动的SQL日志(如HikariCP的logStatementText=true),看ShedLock发出的SQL是否真的执行了。

场景三:大量“Failed to acquire lock”伴随高延迟

WARN c.e.j.s.LockableTaskScheduler - Failed to acquire lock for 'orderSyncJob', will not execute WARN c.e.j.s.LockableTaskScheduler - Failed to acquire lock for 'orderSyncJob', will not execute WARN c.e.j.s.LockableTaskScheduler - Failed to acquire lock for 'orderSyncJob', will not execute

连续出现,且间隔很短(比如每秒一次)。这通常不是锁竞争激烈,而是锁表查询变慢了。检查idx_shedlock_until索引是否存在,执行EXPLAIN SELECT * FROM shedlock WHERE lock_until < NOW(),确认是否走了索引。我们曾在一个MySQL 5.7实例上遇到:lock_until字段类型是DATETIME而非TIMESTAMP,导致索引失效,查询从10ms飙升到800ms,ShedLock每次获取锁都要等半秒,大量请求排队超时。改成TIMESTAMP并重建索引后,问题消失。

3.3 数据库锁表状态详解:五种状态对应五种运维动作

shedlock表的状态,是ShedLock的“健康体检报告”。我把它归纳为五种状态,每种都对应明确的运维动作:

状态lock_until值locked_at值含义运维动作
空闲态NULLNULL锁未被任何节点持有,下次调度可立即获取无需操作,健康状态
占用态2024-06-15 10:10:002024-06-15 10:00:00节点A正在执行,预计10分钟后释放监控任务执行日志,确认是否超时
过期态2024-06-15 09:55:002024-06-15 09:45:00节点A崩溃或网络中断,锁未释放,但已过期立即手动清理:DELETE FROM shedlock WHERE name = 'xxx',否则新节点永远抢不到
僵尸态2024-06-15 10:10:002024-06-15 09:45:00locked_at远早于lock_until,且locked_at时间点无对应执行日志可能是节点假死,建议先观察10分钟,无日志再清理
冲突态2024-06-15 10:10:002024-06-15 10:00:00多个节点日志都显示“Acquired”,但数据库只有一条记录数据库主从延迟或事务隔离级别问题,检查binlog同步状态

注意:ShedLock本身不提供自动清理过期锁的后台线程。它依赖每次获取锁时的“乐观清理”:当发现lock_until < now(),会先尝试UPDATE将其置为NULL,再插入新锁。所以“过期态”锁不会长期滞留,但若所有节点都宕机,它就会一直挂着。生产环境建议加一条运维脚本,每天凌晨执行DELETE FROM shedlock WHERE lock_until < DATE_SUB(NOW(), INTERVAL 1 HOUR),作为兜底。

4. 进阶实战:应对真实世界的复杂场景——从单库到多数据源,从简单任务到复合调度

ShedLock的文档写得简洁,但真实业务远比示例复杂。当你走出“Hello World”阶段,会遇到一堆文档里没提、但生产环境天天见的问题:比如你的系统既有主库又有报表库,定时任务要连报表库查数据,但ShedLock锁表必须在主库;再比如一个任务要按不同维度(商户ID、地区)分片执行,但ShedLock的name是静态字符串,没法动态生成。这些问题没有银弹,但有经过验证的工程解法。下面分享三个我在多个项目中落地的实战方案。

4.1 方案一:ShedLock与多数据源共存——锁表独立,业务数据源自由

典型架构:主业务库(master-ds)用于写交易,报表库(report-ds)用于读分析数据。定时任务ReportGenerateJob需要从report-ds查昨日数据,生成报表。但ShedLock的锁表必须放在master-ds,因为它是核心协调中心。如果强行把ShedLock配置指向report-ds,会导致两个风险:一是报表库通常是只读从库,ShedLock的写操作失败;二是把协调元数据混进分析库,违背关注点分离原则。

解决方案:ShedLock独占一个数据源,业务逻辑按需切换。配置如下:

@Configuration public class DataSourceConfig { @Bean @Primary @ConfigurationProperties("spring.datasource.master") public DataSource masterDataSource() { return DataSourceBuilder.create().build(); } @Bean @ConfigurationProperties("spring.datasource.report") public DataSource reportDataSource() { return DataSourceBuilder.create().build(); } // ShedLock专用数据源,复用masterDataSource,但用独立JdbcTemplate @Bean public JdbcTemplate shedLockJdbcTemplate(@Qualifier("masterDataSource") DataSource ds) { return new JdbcTemplate(ds); } } @Configuration public class ShedLockConfig { @Bean public LockProvider lockProvider(@Qualifier("shedLockJdbcTemplate") JdbcTemplate jdbcTemplate) { return new JdbcTemplateLockProvider( JdbcTemplateLockProvider.Configuration.builder() .withJdbcTemplate(jdbcTemplate) .build() ); } }

业务代码中,通过@Transactional或TransactionTemplate显式指定数据源:

@Component public class ReportGenerateJob { @Autowired private JdbcTemplate reportJdbcTemplate; // 查报表库 @Autowired private JdbcTemplate masterJdbcTemplate; // 写主库(如更新报表状态) @Scheduled(cron = "0 0 2 * * ?") // 凌晨2点 @SchedulerLock(name = "reportGenerateJob", lockAtMostFor = "2h", lockAtLeastFor = "1h") public void execute() { // 1. 从报表库查数据 List<ReportData> data = reportJdbcTemplate.query(...); // 2. 处理逻辑 generateReport(data); // 3. 更新主库状态表 masterJdbcTemplate.update(...); } }

关键点:ShedLock的JdbcTemplate和业务的JdbcTemplate是两个独立Bean,互不影响。这样既保证了锁的可靠性,又让业务数据源选择完全自由。我们在线上运行半年,零锁冲突,零数据源混淆。

4.2 方案二:动态锁名生成——用SpEL表达式实现分片任务互斥

需求:OrderSyncJob要按商户ID分片,每天同步1000个商户,每个商户一个子任务,但同一商户的同步不能并发。@SchedulerLock(name = "orderSyncJob")是静态的,所有商户共享一把锁,效率极低。

ShedLock 4.30+ 版本开始支持SpEL表达式,可以动态生成锁名。改造如下:

@Component public class OrderSyncJob { @Autowired private MerchantService merchantService; @Scheduled(cron = "0 0/10 * * * ?") // 每10分钟触发一次 @SchedulerLock( name = "'orderSync-' + #merchantId", lockAtMostFor = "30m", lockAtLeastFor = "10m" ) public void execute(@Value("#{T(java.util.UUID).randomUUID().toString()}") String runId) { // 1. 获取待同步商户列表(分页) List<Long> merchantIds = merchantService.getPendingMerchants(100); // 2. 遍历每个商户,触发子任务 for (Long mid : merchantIds) { // 关键:用SpEL中的#merchantId,必须在方法参数中声明 syncSingleMerchant(mid); } } // 子任务方法,用@SchedulerLock标注,锁名含商户ID @SchedulerLock( name = "'orderSync-' + #merchantId", lockAtMostFor = "30m", lockAtLeastFor = "10m" ) public void syncSingleMerchant(@Param("merchantId") Long merchantId) { // 同步单个商户逻辑 syncOrdersForMerchant(merchantId); } }

这里的关键技巧:

  • @SchedulerLock的name属性支持SpEL,#merchantId引用方法参数;
  • @Param("merchantId")注解必不可少,它告诉ShedLock:这个参数叫merchantId,可以在SpEL里用#merchantId取到;
  • 主任务execute()本身也加了@SchedulerLock,但锁名是固定字符串,目的是防止多个主任务实例同时拉取商户列表,造成重复分片;
  • 子任务syncSingleMerchant()的锁名是动态的,orderSync-1001、orderSync-1002……每个商户一把独立锁,互不干扰。

我们实测:1000商户,原来单锁串行要4小时,改成动态锁后,10个节点并行,40分钟完成,吞吐量提升6倍。而且锁粒度精准,不会出现“商户1001在A节点执行,商户1002在B节点执行,但商户1001又在C节点被重复触发”的问题。

4.3 方案三:与Quartz深度集成——当Cron表达式不够用时

@Scheduled简单好用,但缺乏高级调度能力:比如跳过节假日、按工作日执行、失败后重试、执行历史追溯。这时就得上Quartz。ShedLock官方提供了shedlock-provider-quartz模块,但它只解决了“Quartz Job执行时加锁”,没解决“Quartz Trigger如何感知锁状态”。真实需求是:如果orderSyncJob正在执行,新的Trigger应该跳过,而不是排队等待。

解决方案:自定义JobListener,在Trigger触发前检查锁状态。步骤如下:

  1. 引入Quartz依赖和ShedLock Quartz Provider:
<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-quartz</artifactId> </dependency> <dependency> <groupId>net.javacrumbs.shedlock</groupId> <artifactId>shedlock-provider-quartz</artifactId> <version>5.12.0</version> </dependency>
  1. 创建ShedLockJobListener,在jobToBeExecuted事件中检查锁:
@Component public class ShedLockJobListener implements JobListener { private final LockProvider lockProvider; public ShedLockJobListener(LockProvider lockProvider) { this.lockProvider = lockProvider; } @Override public String getName() { return "ShedLockJobListener"; } @Override public void jobToBeExecuted(JobExecutionContext context) { String jobName = context.getJobDetail().getKey().getName(); String lockName = "quartz-" + jobName; // 尝试获取锁,超时1秒 Optional<SimpleLock> lock = lockProvider.lock(lockName, Duration.ofSeconds(1)); if (lock.isPresent()) { // 获取成功,存入上下文,供Job执行时使用 context.getMergedJobDataMap().put("shedlock", lock.get()); } else { // 获取失败,取消本次执行 context.setResult(null); throw new JobExecutionException("ShedLock failed, skip execution for " + jobName); } } @Override public void jobWasExecuted(JobExecutionContext context, JobExecutionException jobException) { // 执行完成后,释放锁 SimpleLock lock = (SimpleLock) context.getMergedJobDataMap().get("shedlock"); if (lock != null && lock.isLocked()) { try { lock.unlock(); } catch (Exception e) { // 记录警告,但不抛出,避免影响Quartz调度 log.warn("Failed to unlock shedlock for {}", context.getJobDetail().getKey().getName(), e); } } } }
  1. 在Quartz配置中注册监听器:
@Configuration public class QuartzConfig { @Bean public SchedulerFactoryBean schedulerFactoryBean( @Qualifier("shedLockJobListener") ShedLockJobListener listener) { SchedulerFactoryBean factory = new SchedulerFactoryBean(); // ... 其他配置 factory.setGlobalJobListeners(listener); return factory; } }

这样,当Quartz Trigger触发时,ShedLockJobListener会先尝试获取锁,失败则直接throw,Quartz会记录JobExecutionException并标记本次执行为“跳过”,不会进入Job的executeInternal方法。我们用这套方案支撑了某金融系统的日终批处理,日志里清晰看到SKIPPED状态,运维同学一目了然知道“不是系统卡住,是锁被占着”。

5. 生产环境避坑指南——那些文档不会写的血泪教训

集成ShedLock不是“加个注解就完事”,它像一把精密的瑞士军刀,用对了事半功倍,用错了可能埋下隐患。下面这些坑,都是我在多个项目上线前后踩出来的,有些甚至导致过线上事故。我把它们按严重等级排序,标出症状、根因和永久性解决方案,帮你绕开所有弯路。

5.1 致命坑:数据库时钟不同步——锁过期时间错乱的根源

症状:任务在节点A上执行,lock_until设为10:10:00,但节点B在同一秒查询,发现lock_until < NOW(),于是抢锁成功,导致双跑。查数据库,lock_until确实是10:10:00,但节点B的系统时间是10:10:05。

根因:ShedLock默认使用应用服务器本地时间计算lock_until。如果三台机器时钟相差5秒,锁的有效期就乱套了。lockAtMostFor = "10m"在A机算出来是10:10:00,在B机算出来可能是10:09:55,C机是10:10:03。这种偏差在分布式系统里是常态,NTP同步也不可能做到毫秒级一致。

解决方案:强制使用数据库服务器时间。在JdbcTemplateLockProvider.Configuration中启用usingDbTime():

@Bean public LockProvider lockProvider(DataSource dataSource) { return new JdbcTemplateLockProvider( JdbcTemplateLockProvider.Configuration.builder() .withJdbcTemplate(new JdbcTemplate(dataSource)) .usingDbTime() // 关键!让所有节点以DB时间为基准 .build() ); }

原理:ShedLock执行INSERT INTO shedlock (...) VALUES (?, ?, ?, ?)时,lock_until字段不再用new Date(),而是用数据库函数,如MySQL的DATE_ADD(NOW(), INTERVAL 10 MINUTE)。这样,无论应用服务器时间快慢,所有节点看到的“当前时间”都是同一个——数据库的时间。我们在线上强制推行此配置后,锁冲突率从每月3次降到0。

5.2 高危坑:lockAtLeastFor设得太小——把定时任务变成高频请求

症状:@Scheduled(cron = "0 0 * * * ?")(每小时一次)的任务,日志里却每分钟都在执行,下游API被频繁调用,触发限流告警。

根因:lockAtLeastFor设成了"30s",而任务执行耗时20s。结果:A节点10:00:00拿到锁,10:00:20执行完,但锁强制挂到10:00:30;10:00:30一到,锁释放,B节点立刻抢到,10:00:31开始执行……如此循环,把“每小时一次”变成了“每30秒一次”。

解决方案:lockAtLeastFor必须 ≥ Cron间隔。计算公式:lockAtLeastFor >= Cron间隔 + 任务平均耗时 × 1.5。比如Cron是每小时一次(3600秒),任务平均耗时120秒,则lockAtLeastFor至少设为3780s(1小时3分钟)。这样,即使任务偶发卡顿,也能保证下次调度前锁始终有效。我们有个支付对账任务,Cron是"0 0 2 * * ?"(每天2点),lockAtLeastFor设为"25h"(比24小时多1小时),确保跨天时不会因时钟漂移导致锁提前释放。

5.3 常见坑:@SchedulerLock与@Transactional的顺序陷阱

症状:任务执行中数据库报Deadlock found when trying to get lock,或者部分数据写入成功,部分失败,事务不一致。

根因:@SchedulerLock是AOP切面,@Transactional也是AOP切面,两者嵌套时,切面执行顺序决定了事务边界。如果@Transactional在外层,@SchedulerLock在内层,那么锁获取操作会被包含在事务中。一旦锁获取失败(Failed to acquire lock),整个事务会回滚,包括你已经执行的业务逻辑——这显然不合理,因为锁失败不该影响前置操作。

解决方案:确保@SchedulerLock在@Transactional外层。Spring AOP默认按注解声明顺序执行,所以要把@SchedulerLock写在方法上方,@Transactional写在它下面:

@Scheduled(cron = "0 0/5 * * * ?") @SchedulerLock(name = "orderSyncJob", lockAtMostFor = "10m", lockAtLeastFor = "5m") @Transactional // 放在@SchedulerLock下方 public void execute() { // 业务逻辑 }

原理:这样配置后,AOP代理链是SchedulerLockInterceptor → TransactionInterceptor → Method。锁获取在事务开启前完成,锁失败直接退出,不

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进