欢迎你来读这篇博客,这篇博客主要围绕 ShedLock 展开:它是什么、解决什么问题、底层原理是什么、如何在 Spring Boot 3.x 项目中落地,以及在生产环境里应该怎么配置、怎么验证、怎么避坑。
如果你有多实例部署的 Spring Boot 服务,并且项目里存在 @Scheduled 定时任务,那么这篇文章基本就是给你的。因为普通的@Scheduled 在单机时代岁月静好,一旦服务扩容到 2 个、3 个、10 个实例,它就会从“自动执行任务”变成“自动重复执行任务”。有些重复执行只是日志吵一点,有些重复执行则会直接把财务、库存、订单、对账数据打穿。
序言 在很多业务系统中,我们都会写一些定时任务:
每天凌晨生成结算单;
每 5 分钟扫描超时订单;
每小时同步第三方账单;
每天清理过期 Token;
定时刷新缓存;
定时推送消息;
定时汇总报表数据。
在单实例部署时,下面这种代码看起来完全没有问题:
1 2 3 4 5 6 7 8 @Component public class SettlementScheduleTask { @Scheduled(cron = "0 0 2 * * ?") public void generateSettlementBill () { } }
但是,一旦服务变成多实例部署:
1 2 3 finance-service-1 finance-service-2 finance-service-3
那么每个实例都会在凌晨 2 点触发一次 generateSettlementBill()。
结果就是:
1 2 3 4 同一批结算单可能生成 3 次; 同一批消息可能发送 3 次; 同一批数据可能同步 3 次; 同一个外部接口可能被打 3 次。
这时候,业务同学问你:“为什么结算单重复了?”
你说:“因为我们服务有三个实例。”
业务同学:“那你们为什么要部署三个?”
这就很尴尬了。扩容是为了高可用,不是为了把事故扩大三倍。
ShedLock 要解决的正是这个问题:在分布式多实例环境下,让同一个定时任务同一时间最多只被一个节点执行。
正文 1. ShedLock 是什么? ShedLock 是一个 Java 分布式锁工具库,主要用于给定时任务加锁。
它最常见的使用方式是和 Spring 的 @Scheduled 配合:
1 2 3 4 5 6 7 8 9 @Scheduled(cron = "0 0 2 * * ?") @SchedulerLock( name = "settlement-generate-task", lockAtMostFor = "PT30M", lockAtLeastFor = "PT1M" ) public void generateSettlementBill () { }
它的核心作用是:
多个服务实例都触发同一个定时任务时,只有一个实例能抢到锁并执行任务,其他实例抢不到锁后直接跳过本次执行。
注意,是跳过 ,不是等待。
这一点非常关键。
如果实例 A 正在执行任务,实例 B 也到了触发时间,实例 B 不会阻塞等待实例 A 执行完成,而是直接放弃本次执行。
所以 ShedLock 更适合这种任务:
1 2 这次不执行没关系,下次周期还能继续跑; 但是同一时间绝对不能多个节点一起跑。
2. ShedLock 不是什么? 先把边界讲清楚,否则后面很容易用偏。
ShedLock 不是分布式任务调度平台 。
它不负责:
任务编排;
分片执行;
动态修改 Cron;
失败自动重试;
可视化任务中心;
手动触发任务;
任务日志管理;
任务告警;
Worker 调度。
它只做一件事:
给任务加锁,避免同一任务在多节点下并发或重复执行。
所以可以这样理解:
1 2 3 Spring @Scheduled:负责什么时候触发任务; ShedLock:负责多个节点同时触发时,只让一个节点执行; XXL-JOB / PowerJob / Quartz:负责更完整的任务调度、管理和运维能力。
如果你只是已经有 @Scheduled,现在服务要多实例部署,想防止重复执行,那么 ShedLock 很合适。
如果你需要任务平台、任务分片、失败重试、页面管理和执行历史,那就应该看 XXL-JOB、PowerJob、Quartz、JobRunr、db-scheduler 这类工具。
别让 ShedLock 硬扛调度中心,它只是把锁,不是整个门禁系统。
3. 为什么普通 @Scheduled 在集群下会重复执行? Spring 的 @Scheduled 是应用实例内的本地调度机制。
也就是说,每个 JVM 都会独立启动自己的调度器。
假设你有三个实例:
flowchart LR
A[finance-service-1] --> T1[@Scheduled 02:00 执行]
B[finance-service-2] --> T2[@Scheduled 02:00 执行]
C[finance-service-3] --> T3[@Scheduled 02:00 执行]
T1 --> R[生成结算单]
T2 --> R
T3 --> R
这不是 Spring 的 Bug,而是它的设计如此。Spring 并不知道其他机器上还有同样的任务。
于是,所有节点都会按自己的本地调度器触发任务。
要解决这个问题,需要一个所有节点都能访问的共享协调点 。
这个共享协调点可以是:
数据库;
Redis;
MongoDB;
ZooKeeper;
Hazelcast;
Cassandra;
其他外部存储。
ShedLock 就是通过这些外部存储进行协调。
4. ShedLock 的核心执行流程 ShedLock 的执行流程可以简化成下面这样:
sequenceDiagram
participant A as 实例 A
participant B as 实例 B
participant DB as 共享锁存储 shedlock
A ->> DB: 尝试获取 settlement-generate-task 锁
DB -->> A: 获取成功
A ->> A: 执行业务任务
B ->> DB: 尝试获取 settlement-generate-task 锁
DB -->> B: 获取失败
B ->> B: 跳过本次任务
A ->> DB: 任务完成,释放或更新锁过期时间
更具体一点,以 JDBC 锁表为例,ShedLock 会维护一张类似这样的表:
1 2 3 4 5 6 7 8 CREATE TABLE shedlock ( name VARCHAR (64 ) NOT NULL , lock_until TIMESTAMP NOT NULL , locked_at TIMESTAMP NOT NULL , locked_by VARCHAR (255 ) NOT NULL , PRIMARY KEY (name) );
核心字段含义如下:
字段
含义
name
锁名称,全局唯一,同一个任务必须使用同一个锁名
lock_until
锁持有到什么时候,超过该时间后其他节点可以重新抢锁
locked_at
当前锁记录被加锁的时间
locked_by
当前持有锁的节点标识
抢锁时,关键判断大致是:
1 2 如果 lock_until <= 当前时间,说明锁已经过期,可以抢; 如果 lock_until > 当前时间,说明锁还被其他节点持有,当前节点跳过。
当然,真实实现会根据不同数据库生成不同 SQL,并处理并发更新问题,不是简单查一下再改一下那么粗糙。
5. ShedLock 的核心组件 ShedLock 可以拆成三部分理解:
1 2 3 Core:核心锁机制; Integration:和 Spring AOP、Micronaut AOP 或手动代码集成; LockProvider:锁的存储实现,例如 JDBC、Redis、Mongo、ZooKeeper 等。
在 Spring Boot 项目里,你最常接触的是下面三个东西:
1 2 3 @EnableSchedulerLock:开启 ShedLock; @SchedulerLock:给某个任务加锁; LockProvider:告诉 ShedLock 锁存到哪里。
6. lockAtMostFor 和 lockAtLeastFor 深入理解 ShedLock 中最容易配错的两个参数就是:
1 2 lockAtMostFor lockAtLeastFor
6.1 lockAtMostFor:最多锁多久 lockAtMostFor 表示:这个锁最多持有多久。
它是一个安全兜底机制。
假设任务执行过程中服务宕机了,如果没有最大锁时间,锁可能永远不释放,后续任务永远执行不了。
所以 ShedLock 必须有一个最大锁时间。
示例:
1 @SchedulerLock(name = "settlement-generate-task", lockAtMostFor = "PT30M")
含义是:
1 2 这个任务最多持有锁 30 分钟。 如果节点执行过程中挂了,30 分钟后其他节点可以重新抢锁。
配置建议:
1 lockAtMostFor 应该明显大于任务的最大预估执行时间。
例如:
任务正常耗时
建议 lockAtMostFor
10 秒
1 分钟
2 分钟
5~10 分钟
15 分钟
30~60 分钟
1 小时
2 小时以上,且建议重新评估任务设计
如果任务执行时间超过了 lockAtMostFor,就可能出现非常危险的情况:
1 2 3 4 节点 A 还没执行完; 锁过期了; 节点 B 又抢到了锁; 两个节点开始同时执行同一个任务。
这时候 ShedLock 就救不了你了。锁不是魔法,过期时间配置错了,它也会一脸无辜。
6.2 lockAtLeastFor:至少锁多久 lockAtLeastFor 表示:这个锁至少持有多久。
它主要解决两个问题:
任务执行太快,多个节点在极短时间内连续触发;
不同节点之间存在轻微时钟差异。
示例:
1 2 3 4 5 @SchedulerLock( name = "cache-refresh-task", lockAtMostFor = "PT5M", lockAtLeastFor = "PT30S" )
含义是:
1 2 任务最多锁 5 分钟; 即使任务 1 秒就执行完,也至少持有锁 30 秒。
配置建议:
场景
是否需要 lockAtLeastFor
每天凌晨执行一次的大任务
可以较短,例如 PT1M
每 5 秒、10 秒执行一次的小任务
建议设置,避免过快重复
多节点时钟可能不一致
建议设置
任务本身耗时较长
不是重点,重点是 lockAtMostFor
6.3 参数关系建议 一般建议:
1 2 3 lockAtMostFor > 任务最大可能执行时间 lockAtLeastFor < lockAtMostFor lockAtLeastFor <= 调度周期
举个例子:
1 2 3 4 5 6 7 8 9 @Scheduled(cron = "0 */15 * * * ?") @SchedulerLock( name = "third-party-bill-sync-task", lockAtMostFor = "PT20M", lockAtLeastFor = "PT1M" ) public void syncThirdPartyBill () { }
这里 lockAtMostFor = PT20M 是为了防止极端情况下任务没执行完锁就过期。
7. Spring Boot 3.x + ShedLock + JDBC 实战 下面我们用一个贴近业务的例子:
财务系统每天凌晨生成结算单,多实例部署时只能一个节点执行。
7.1 环境版本 本文示例使用:
1 2 3 4 JDK:17+ Spring Boot:3.5.x ShedLock:7.7.0 数据库:PostgreSQL / MySQL 均可
根据 ShedLock 官方兼容矩阵,ShedLock 7.x 最低 JVM 版本是 17,并测试覆盖了 Spring Boot 3.5、3.4 以及 Spring 6.2、Spring 7.0 等版本。
7.2 Maven 依赖 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 <properties > <java.version > 17</java.version > <shedlock.version > 7.7.0</shedlock.version > </properties > <dependencies > <dependency > <groupId > org.springframework.boot</groupId > <artifactId > spring-boot-starter-web</artifactId > </dependency > <dependency > <groupId > org.springframework.boot</groupId > <artifactId > spring-boot-starter-jdbc</artifactId > </dependency > <dependency > <groupId > org.springframework.boot</groupId > <artifactId > spring-boot-starter-validation</artifactId > </dependency > <dependency > <groupId > org.postgresql</groupId > <artifactId > postgresql</artifactId > <scope > runtime</scope > </dependency > <dependency > <groupId > net.javacrumbs.shedlock</groupId > <artifactId > shedlock-spring</artifactId > <version > ${shedlock.version}</version > </dependency > <dependency > <groupId > net.javacrumbs.shedlock</groupId > <artifactId > shedlock-provider-jdbc-template</artifactId > <version > ${shedlock.version}</version > </dependency > </dependencies >
7.3 创建 shedlock 表 PostgreSQL:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 CREATE TABLE shedlock ( name VARCHAR (64 ) NOT NULL , lock_until TIMESTAMP NOT NULL , locked_at TIMESTAMP NOT NULL , locked_by VARCHAR (255 ) NOT NULL , PRIMARY KEY (name) ); COMMENTON TABLE shedlock IS 'ShedLock 分布式定时任务锁表' ; COMMENTON COLUMN shedlock.name IS '锁名称,全局唯一' ; COMMENTON COLUMN shedlock.lock_until IS '锁持有到期时间' ; COMMENTON COLUMN shedlock.locked_at IS '加锁时间' ; COMMENTON COLUMN shedlock.locked_by IS '持锁节点' ;
MySQL / MariaDB:
1 2 3 4 5 6 7 8 CREATE TABLE shedlock ( name VARCHAR (64 ) NOT NULL , lock_until TIMESTAMP (3 ) NOT NULL , locked_at TIMESTAMP (3 ) NOT NULL DEFAULT CURRENT_TIMESTAMP (3 ), locked_by VARCHAR (255 ) NOT NULL , PRIMARY KEY (name) );
生产建议:
1 2 3 4 1. name 必须是主键; 2. 不要手动删除 shedlock 表中的锁记录; 3. 如果要处理异常锁,优先更新 lock_until,而不是 delete; 4. 表名可以自定义,但需要在 LockProvider 中同步配置。
为什么不建议手动删除锁记录?
因为 ShedLock 对已存在的锁记录有内存缓存。手动删除数据库记录后,应用运行期间不一定会自动重新创建该锁记录,可能导致行为不符合预期。需要人工介入时,更推荐把lock_until 改成一个过去时间。
例如:
1 2 3 UPDATE shedlockSET lock_until = NOW() - INTERVAL '1 minute' WHERE name = 'settlement-generate-task' ;
MySQL 可以写成:
1 2 3 UPDATE shedlockSET lock_until = DATE_SUB(NOW(), INTERVAL 1 MINUTE )WHERE name = 'settlement-generate-task' ;
7.4 application.yml 配置 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 server: port: 8080 spring: application: name: finance-service datasource: url: jdbc:postgresql://localhost:5432/finance username: finance password: finance driver-class-name: org.postgresql.Driver task: scheduling: pool: size: 4 logging: level: net.javacrumbs.shedlock: DEBUG com.example.finance: INFO
scheduling.pool.size 建议显式配置,不要让所有定时任务都挤在单线程里。
不过要注意:ShedLock 是防止多节点同一锁名任务重复执行,不是用来解决本实例内所有任务排队问题的。
7.5 开启调度与 ShedLock 1 2 3 4 5 6 7 8 9 10 11 package com.example.finance.config;import net.javacrumbs.shedlock.spring.annotation.EnableSchedulerLock;import org.springframework.context.annotation.Configuration;import org.springframework.scheduling.annotation.EnableScheduling;@Configuration @EnableScheduling @EnableSchedulerLock(defaultLockAtMostFor = "PT30M") public class ScheduleConfiguration { }
这里的 defaultLockAtMostFor = "PT30M" 是默认值。
如果某个任务的 @SchedulerLock 没有单独配置 lockAtMostFor,就会使用这个默认值。
7.6 配置 JDBC LockProvider 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 package com.example.finance.config;import net.javacrumbs.shedlock.core.LockProvider;import net.javacrumbs.shedlock.provider.jdbctemplate.JdbcTemplateLockProvider;import org.springframework.context.annotation.Bean;import org.springframework.context.annotation.Configuration;import org.springframework.jdbc.core.JdbcTemplate;import javax.sql.DataSource;@Configuration public class ShedLockJdbcConfiguration { @Bean public LockProvider lockProvider (DataSource dataSource) { return new JdbcTemplateLockProvider ( JdbcTemplateLockProvider.Configuration.builder() .withJdbcTemplate(new JdbcTemplate (dataSource)) .usingDbTime() .build() ); } }
这里强烈建议使用:
原因是:
1 使用数据库时间作为锁判断时间,避免不同应用服务器之间系统时间不一致导致锁异常。
如果你有三台机器:
1 2 3 实例 A 时间:02:00:00 实例 B 时间:02:00:08 实例 C 时间:01:59:55
然后你又不用数据库时间,那锁的判断就容易出现奇怪行为。
分布式系统里,时间是个老六。能统一就统一。
7.7 编写业务任务 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 package com.example.finance.schedule;import com.example.finance.service.SettlementBillService;import lombok.RequiredArgsConstructor;import lombok.extern.slf4j.Slf4j;import net.javacrumbs.shedlock.core.LockAssert;import net.javacrumbs.shedlock.spring.annotation.SchedulerLock;import org.springframework.scheduling.annotation.Scheduled;import org.springframework.stereotype.Component;import java.time.LocalDate;@Slf4j @Component @RequiredArgsConstructor public class SettlementBillScheduleTask { private final SettlementBillService settlementBillService; @Scheduled(cron = "0 0 2 * * ?") @SchedulerLock( name = "finance:settlement:generate-yesterday-bill", lockAtMostFor = "PT60M", lockAtLeastFor = "PT1M" ) public void generateYesterdaySettlementBill () { LockAssert.assertLocked(); LocalDate billDate = LocalDate.now().minusDays(1 ); log.info("[settlement-schedule] start generate settlement bill, billDate={}" , billDate); settlementBillService.generateBillIfAbsent(billDate); log.info("[settlement-schedule] finish generate settlement bill, billDate={}" , billDate); } }
这里有几个重点。
第一,锁名称建议有业务前缀:
1 finance:settlement:generate-yesterday-bill
不要写成:
1 2 3 task1 scheduledTask generate
锁名一旦乱了,后面排查就像在日志里找一只会隐身的蟑螂。
第二,建议在关键任务中加入:
1 LockAssert.assertLocked();
它可以帮助你确认 ShedLock AOP 是否真的生效。
第三,业务层必须保证幂等。
ShedLock 只能减少重复执行概率,不能代替业务幂等。
7.8 业务幂等设计 定时任务里千万不要只依赖 ShedLock。
正确做法是:
1 2 3 ShedLock 防止多节点同时执行; 数据库唯一约束防止重复数据落库; 业务幂等逻辑防止重复处理。
例如结算单表:
1 2 3 4 5 6 7 8 9 10 11 12 CREATE TABLE settlement_bill ( id BIGSERIAL PRIMARY KEY , bill_date DATE NOT NULL , shop_id BIGINT NOT NULL , bill_no VARCHAR (64 ) NOT NULL , amount NUMERIC (18 , 2 ) NOT NULL , status VARCHAR (32 ) NOT NULL , created_at TIMESTAMP NOT NULL DEFAULT NOW(), updated_at TIMESTAMP NOT NULL DEFAULT NOW(), UNIQUE (bill_date, shop_id) );
业务服务:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 package com.example.finance.service;import lombok.RequiredArgsConstructor;import lombok.extern.slf4j.Slf4j;import org.springframework.dao.DuplicateKeyException;import org.springframework.jdbc.core.JdbcTemplate;import org.springframework.stereotype.Service;import org.springframework.transaction.annotation.Transactional;import java.math.BigDecimal;import java.time.LocalDate;import java.util.List;@Slf4j @Service @RequiredArgsConstructor public class SettlementBillService { private final JdbcTemplate jdbcTemplate; @Transactional(rollbackFor = Exception.class) public void generateBillIfAbsent (LocalDate billDate) { List<Long> shopIds = loadNeedGenerateShopIds(billDate); for (Long shopId : shopIds) { generateOneShopBillIfAbsent(billDate, shopId); } } private void generateOneShopBillIfAbsent (LocalDate billDate, Long shopId) { Integer count = jdbcTemplate.queryForObject(""" SELECT COUNT(1) FROM settlement_bill WHERE bill_date = ? AND shop_id = ? """ , Integer.class, billDate, shopId); if (count != null && count > 0 ) { log.info("[settlement] bill already exists, billDate={}, shopId={}" , billDate, shopId); return ; } BigDecimal amount = calculateAmount(billDate, shopId); String billNo = "SETTLE-" + billDate + "-" + shopId; try { jdbcTemplate.update(""" INSERT INTO settlement_bill(bill_date, shop_id, bill_no, amount, status) VALUES (?, ?, ?, ?, ?) """ , billDate, shopId, billNo, amount, "INIT" ); } catch (DuplicateKeyException duplicateKeyException) { log.warn("[settlement] duplicate bill ignored, billDate={}, shopId={}" , billDate, shopId); } } private List<Long> loadNeedGenerateShopIds (LocalDate billDate) { return jdbcTemplate.queryForList(""" SELECT id FROM shop WHERE enabled = TRUE """ , Long.class); } private BigDecimal calculateAmount (LocalDate billDate, Long shopId) { BigDecimal amount = jdbcTemplate.queryForObject(""" SELECT COALESCE(SUM(amount), 0) FROM order_bill_detail WHERE bill_date = ? AND shop_id = ? """ , BigDecimal.class, billDate, shopId); return amount == null ? BigDecimal.ZERO : amount; } }
为什么还要唯一约束?
因为极端情况下,比如:
1 2 3 4 5 任务执行超过 lockAtMostFor; 数据库时间或机器时间异常; 锁表被人工错误修改; 不同任务误用了不同锁名; 服务重启时刚好处在边界时刻;
都可能导致重复执行。
而业务幂等和唯一索引是最后一道防线。
数据库唯一约束看起来土,但在生产里它经常是最后的门神。
8. 本地双实例验证 8.1 准备 Docker Compose 1 2 3 4 5 6 7 8 9 10 11 12 13 14 version: "3.9" services: postgres: image: postgres:16 container_name: shedlock-postgres environment: POSTGRES_DB: finance POSTGRES_USER: finance POSTGRES_PASSWORD: finance ports: - "5432:5432" volumes: - ./postgres-data:/var/lib/postgresql/data
启动:
8.2 启动两个服务实例 实例 1:
1 java -jar target/finance-service.jar --server.port=8081
实例 2:
1 java -jar target/finance-service.jar --server.port=8082
为了测试方便,可以临时把任务改成每 10 秒一次:
1 2 3 4 5 6 7 8 9 10 @Scheduled(cron = "*/10 * * * * ?") @SchedulerLock( name = "finance:settlement:test-task", lockAtMostFor = "PT30S", lockAtLeastFor = "PT8S" ) public void testTask () { LockAssert.assertLocked(); log.info("test shedlock task running..." ); }
8.3 观察日志 你应该看到两个实例不是同时打印任务执行日志,而是同一时间只有一个实例真正执行。
示例:
1 2 3 4 8081: test shedlock task running... 8082: skipped because lock is held by another node 8081: test shedlock task running... 8082: skipped because lock is held by another node
实际日志格式会根据 ShedLock 版本和日志级别有所不同,但核心现象是:同一个周期只有一个实例执行。
8.4 查看锁表
你会看到类似数据:
1 2 name lock_until locked_at locked_by finance:settlement:test-task 2026-06-12 02:00:30 2026-06-12 02:00:00 192.168.1.10
lock_until 会随着任务执行不断变化。
9. Spring Boot 3.x + ShedLock + Redis 实战 如果你的项目本身已经强依赖 Redis,也可以使用 Redis 作为 ShedLock 的锁存储。
不过这里先说结论:
1 2 关键财务、结算、对账类任务,优先推荐 JDBC; 缓存刷新、消息扫描、轻量同步类任务,可以考虑 Redis。
原因是官方也提醒:Redis LockProvider 使用经典 Redis 锁机制,在 Redis 主节点故障场景下可能不可靠。
9.1 Maven 依赖 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 <properties > <shedlock.version > 7.7.0</shedlock.version > </properties > <dependencies > <dependency > <groupId > org.springframework.boot</groupId > <artifactId > spring-boot-starter-data-redis</artifactId > </dependency > <dependency > <groupId > net.javacrumbs.shedlock</groupId > <artifactId > shedlock-spring</artifactId > <version > ${shedlock.version}</version > </dependency > <dependency > <groupId > net.javacrumbs.shedlock</groupId > <artifactId > shedlock-provider-redis-spring</artifactId > <version > ${shedlock.version}</version > </dependency > </dependencies >
9.2 application.yml 1 2 3 4 5 6 7 8 9 10 11 spring: data: redis: host: localhost port: 6379 database: 2 timeout: 3s logging: level: net.javacrumbs.shedlock: DEBUG
9.3 Redis LockProvider 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 package com.example.finance.config;import net.javacrumbs.shedlock.core.LockProvider;import net.javacrumbs.shedlock.provider.redis.spring.RedisLockProvider;import org.springframework.context.annotation.Bean;import org.springframework.context.annotation.Configuration;import org.springframework.data.redis.connection.RedisConnectionFactory;@Configuration public class ShedLockRedisConfiguration { @Bean public LockProvider lockProvider (RedisConnectionFactory connectionFactory) { return new RedisLockProvider (connectionFactory, "prod:finance-service" ); } }
第二个参数可以理解为环境隔离前缀。
建议区分:
1 2 3 4 dev:finance-service test:finance-service pre:finance-service prod:finance-service
不要让测试环境和生产环境共用同一套 Redis 锁前缀,否则你会收获一种名叫“我本地跑了个任务,把生产锁抢了”的快乐。
9.4 Redis 适用场景 适合:
缓存刷新任务;
短周期轻量任务;
允许下一周期修复的同步任务;
对极端一致性要求不高的后台任务。
不太适合:
财务结算;
扣款;
库存最终扣减;
一次性迁移脚本;
不允许重复执行的强一致任务。
这些任务最好使用数据库锁,并且业务上继续做幂等和唯一约束。
10. 多 LockProvider 场景 有些系统中可能同时需要 JDBC 和 Redis 两种锁。
比如:
1 2 结算任务使用 JDBC 锁; 缓存刷新任务使用 Redis 锁。
ShedLock 6.0.0 之后支持多个 LockProvider,可以通过 @LockProviderToUse 指定。
示例:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 @Configuration public class ShedLockMultiProviderConfiguration { @Bean("jdbcLockProvider") public LockProvider jdbcLockProvider (DataSource dataSource) { return new JdbcTemplateLockProvider ( JdbcTemplateLockProvider.Configuration.builder() .withJdbcTemplate(new JdbcTemplate (dataSource)) .usingDbTime() .build() ); } @Bean("redisLockProvider") public LockProvider redisLockProvider (RedisConnectionFactory connectionFactory) { return new RedisLockProvider (connectionFactory, "prod:finance-service" ); } }
任务上指定:
1 2 3 4 5 6 @Scheduled(cron = "0 0 2 * * ?") @SchedulerLock(name = "finance:settlement:generate", lockAtMostFor = "PT60M") @LockProviderToUse("jdbcLockProvider") public void generateSettlementBill () { }
1 2 3 4 5 6 @Scheduled(cron = "0 */5 * * * ?") @SchedulerLock(name = "finance:cache:refresh", lockAtMostFor = "PT2M") @LockProviderToUse("redisLockProvider") public void refreshCache () { }
如果没有指定,且容器里存在多个 LockProvider,运行时可能会失败,所以不要模棱两可。
11. 手动加锁:LockingTaskExecutor 虽然 ShedLock 最常见的方式是配合 @Scheduled,但它也支持手动加锁。
适合这种场景:
1 2 不是 @Scheduled 触发; 但是某段代码仍然希望在分布式环境下互斥执行。
示例:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 package com.example.finance.service;import lombok.RequiredArgsConstructor;import net.javacrumbs.shedlock.core.LockConfiguration;import net.javacrumbs.shedlock.core.LockingTaskExecutor;import net.javacrumbs.shedlock.core.DefaultLockingTaskExecutor;import net.javacrumbs.shedlock.core.LockProvider;import org.springframework.stereotype.Service;import java.time.Duration;import java.time.Instant;@Service @RequiredArgsConstructor public class ManualLockService { private final LockProvider lockProvider; public void executeWithLock () { LockingTaskExecutor executor = new DefaultLockingTaskExecutor (lockProvider); executor.executeWithLock( (Runnable) () -> { doSomethingOnlyOnce(); }, new LockConfiguration ( Instant.now(), "finance:manual:rebuild-report" , Duration.ofMinutes(30 ), Duration.ofMinutes(1 ) ) ); } private void doSomethingOnlyOnce () { } }
不过,大多数项目里还是注解方式更清爽。
12. 生产环境参数怎么定? 12.1 按任务类型配置
任务类型
示例
lockAtMostFor 建议
lockAtLeastFor 建议
高频轻任务
每 10 秒刷新缓存
30 秒~1 分钟
5~10 秒
中频同步任务
每 5 分钟同步订单
5~15 分钟
30 秒~1 分钟
低频批处理任务
每天生成结算单
30~120 分钟
1~5 分钟
超长任务
大批量数据迁移
不建议直接这样跑
拆分任务或引入调度平台
12.2 lockAtMostFor 不要拍脑袋 建议先统计任务耗时:
1 2 3 4 P50:正常耗时; P95:大部分情况下的高耗时; P99:极端耗时; Max:历史最大耗时。
然后:
1 lockAtMostFor >= P99 或 Max 的安全倍数
如果任务耗时波动很大,例如有时 1 分钟,有时 1 小时,说明任务本身可能需要拆分、分页、分片或改成异步队列模型。
不要用一个超长锁时间掩盖任务设计问题。
12.3 Cron 周期与锁时间要配合 假设任务每 5 分钟跑一次:
1 2 3 4 5 6 @Scheduled(cron = "0 */5 * * * ?") @SchedulerLock( name = "order-sync-task", lockAtMostFor = "PT10M", lockAtLeastFor = "PT1M" )
如果上一次执行超过 5 分钟,下一个周期触发时会抢不到锁并跳过。
这通常是合理的,因为你本来就不希望任务重叠执行。
但如果你的业务要求“每个周期都必须执行,不能跳过”,那 ShedLock 就不够了。你需要任务队列、调度平台或补偿机制。
13. ShedLock 与事务的关系 一个常见问题是:
ShedLock 的锁释放和业务事务提交是什么关系?
使用 JDBC Provider 时,ShedLock 自己会进行锁表操作。业务方法里的事务由你自己的 @Transactional 控制。
一般建议:
1 2 3 4 5 1. 不要把超长任务全部包在一个大事务里; 2. 定时任务入口尽量只负责编排; 3. 真正的数据处理按批次拆分事务; 4. 每个批次业务上要保证幂等; 5. 失败后允许下次周期继续补偿。
错误示例:
1 2 3 4 5 6 @Transactional @Scheduled(cron = "0 0 2 * * ?") @SchedulerLock(name = "huge-task", lockAtMostFor = "PT2H") public void hugeTask () { }
更好的方式:
1 2 3 4 5 6 7 8 9 10 @Scheduled(cron = "0 0 2 * * ?") @SchedulerLock(name = "huge-task", lockAtMostFor = "PT2H") public void hugeTask () { while (true ) { int affected = batchProcessor.processNextBatch(); if (affected == 0 ) { break ; } } }
1 2 3 4 5 6 7 8 9 10 11 12 @Service @RequiredArgsConstructor public class BatchProcessor { @Transactional(rollbackFor = Exception.class) public int processNextBatch () { return 0 ; } }
14. ShedLock 与业务幂等的关系 这是生产环境最重要的一节。
请记住:
ShedLock 不是幂等方案,它只是降低重复执行概率的分布式互斥工具。
真正可靠的任务需要三层保障:
flowchart TB
A[第一层:ShedLock 分布式锁] --> B[避免多节点同时执行]
C[第二层:业务状态机] --> D[避免重复推进状态]
E[第三层:数据库唯一约束] --> F[避免重复落库]
B --> G[更安全的定时任务]
D --> G
F --> G
比如生成结算单:
1 2 3 ShedLock:保证同一时间只有一个节点跑生成任务; 状态机:只处理 INIT / WAIT_GENERATE 状态的数据; 唯一索引:保证同一店铺同一账期只有一张结算单。
这样即使某次锁失效或任务重试,也不会直接把数据打爆。
15. 日志与可观测性建议 定时任务是后台静默执行的,越静默,出问题越难查。
建议每个重要任务都打结构化日志:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 log.info("[schedule-start] task={}, lockName={}, bizDate={}" , "generateSettlementBill" , "finance:settlement:generate-yesterday-bill" , billDate);long start = System.currentTimeMillis();try { settlementBillService.generateBillIfAbsent(billDate); log.info("[schedule-success] task={}, bizDate={}, costMs={}" , "generateSettlementBill" , billDate, System.currentTimeMillis() - start); } catch (Exception ex) { log.error("[schedule-error] task={}, bizDate={}, costMs={}, errorMsg={}" , "generateSettlementBill" , billDate, System.currentTimeMillis() - start, ex.getMessage(), ex); throw ex; }
建议日志字段:
字段
含义
task
任务名称
lockName
ShedLock 锁名称
bizDate
业务日期
batchNo
批次号
status
start / success / fail / skip
costMs
耗时
affectedRows
影响行数
errorCode
错误码
errorMsg
错误信息
如果公司有监控系统,建议加指标:
1 2 3 4 5 schedule_task_success_total schedule_task_fail_total schedule_task_cost_seconds schedule_task_last_success_time schedule_task_affected_rows
不要等凌晨 2 点任务失败,第二天 10 点业务来问才知道。那不是监控,那叫考古。
16. 常见坑点 16.1 忘记加 @EnableSchedulerLock 只加了:
但是忘了:
1 @EnableSchedulerLock(defaultLockAtMostFor = "PT30M")
结果 @SchedulerLock 不生效。
建议在关键任务里加:
1 LockAssert.assertLocked();
如果 AOP 没生效,可以尽早暴露问题。
16.2 方法不是 Spring Bean 方法 错误示例:
1 2 3 4 5 6 public class MyTask { @Scheduled(cron = "...") @SchedulerLock(name = "task") public void run () { } }
如果这个类没有被 Spring 管理,AOP 不会生效。
正确:
1 2 3 4 5 6 7 @Component public class MyTask { @Scheduled(cron = "...") @SchedulerLock(name = "task") public void run () { } }
16.3 同一任务不同实例用了不同 lock name 例如实例 A:
1 @SchedulerLock(name = "generate-bill")
实例 B:
1 @SchedulerLock(name = "generate-settlement-bill")
这相当于两把不同的锁,当然会同时执行。
锁名必须统一,并且最好用常量管理:
1 2 3 4 5 6 7 8 public final class ScheduleLockNames { private ScheduleLockNames () { } public static final String GENERATE_SETTLEMENT_BILL = "finance:settlement:generate-yesterday-bill" ; }
使用:
1 @SchedulerLock(name = ScheduleLockNames.GENERATE_SETTLEMENT_BILL)
16.4 lockAtMostFor 太短 任务正常要跑 10 分钟,你配置:
两分钟后锁过期,其他节点又开始执行。
这就是自己把保险丝接成了烟花。
16.5 lockAtMostFor 太长 任务正常 10 秒,你配置:
一旦任务执行节点宕机,后续 24 小时都不会再执行。
所以不要极端。
16.6 任务必须执行但 ShedLock 会跳过 ShedLock 的行为是:抢不到锁就跳过。
如果你的业务要求:
1 2 3 每个调度周期都必须被处理,不能跳过; 失败后必须补偿; 每个分片必须执行;
那就不要只用 ShedLock。
应该考虑:
任务表;
消息队列;
XXL-JOB;
PowerJob;
Quartz;
JobRunr;
db-scheduler。
16.7 手动删除锁表记录 前面提过,不建议手动 DELETE FROM shedlock。
如果你需要释放锁,优先:
1 2 3 UPDATE shedlockSET lock_until = NOW() - INTERVAL '1 minute' WHERE name = 'xxx' ;
16.8 本地开发多个服务互相抢锁 如果开发、测试、预发、生产共用一套数据库或 Redis,很容易互相影响。
建议:
1 2 3 4 不同环境使用不同数据库; 或者不同环境使用不同 shedlock 表; Redis Provider 使用不同 environment 前缀; 锁名中也可以带环境前缀。
17. ShedLock 和其他方案对比
工具
定位
适合场景
是否有任务平台
ShedLock
定时任务分布式锁
已有 @Scheduled,只想防重复执行
否
Quartz
任务调度框架
复杂调度、持久化 Trigger
部分能力
XXL-JOB
分布式任务调度平台
可视化、分片、失败重试、手动触发
是
PowerJob
分布式任务调度平台
复杂任务编排、MapReduce 风格任务
是
JobRunr
后台任务与调度
Java 后台任务、延迟任务、重试
是
Redis Lock
通用分布式锁
自定义互斥逻辑
否
DB 乐观锁
数据更新并发控制
单条业务数据并发修改
否
选型建议:
1 2 3 4 5 小项目 / 中后台 / 简单定时任务:ShedLock; 已有 @Scheduled,不想引入调度平台:ShedLock; 任务需要页面管理、重试、分片、日志:XXL-JOB / PowerJob; 任务和业务数据强绑定:任务表 + 状态机 + ShedLock; 只控制数据修改并发:数据库唯一约束 / 乐观锁 / 悲观锁。
18. 一个更工程化的项目结构 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 finance-service ├── src/main/java/com/example/finance │ ├── FinanceApplication.java │ ├── config │ │ ├── ScheduleConfiguration.java │ │ └── ShedLockJdbcConfiguration.java │ ├── constant │ │ └── ScheduleLockNames.java │ ├── schedule │ │ ├── SettlementBillScheduleTask.java │ │ └── CacheRefreshScheduleTask.java │ ├── service │ │ ├── SettlementBillService.java │ │ └── BatchProcessor.java │ └── repository │ └── SettlementBillRepository.java └── src/main/resources ├── application.yml └── db/migration ├── V1__create_shedlock.sql └── V2__create_settlement_bill.sql
推荐把锁名集中管理:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 package com.example.finance.constant;public final class ScheduleLockNames { private static final String PREFIX = "finance:" ; private ScheduleLockNames () { } public static final String GENERATE_YESTERDAY_SETTLEMENT_BILL = PREFIX + "settlement:generate-yesterday-bill" ; public static final String REFRESH_SETTLEMENT_CACHE = PREFIX + "cache:refresh-settlement-cache" ; }
任务里使用:
1 2 3 4 5 @SchedulerLock( name = ScheduleLockNames.GENERATE_YESTERDAY_SETTLEMENT_BILL, lockAtMostFor = "PT60M", lockAtLeastFor = "PT1M" )
这样后续排查、搜索、统一规范都会舒服很多。
19. 生产落地 Checklist 上线前建议检查:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 [ ] 是否引入 shedlock-spring? [ ] 是否引入对应 LockProvider? [ ] 是否加了 @EnableScheduling? [ ] 是否加了 @EnableSchedulerLock? [ ] shedlock 表是否创建? [ ] name 是否是主键? [ ] 是否使用 usingDbTime()? [ ] 每个任务 lock name 是否全局唯一且稳定? [ ] lockAtMostFor 是否大于任务最大执行时间? [ ] 短任务是否配置 lockAtLeastFor? [ ] 关键任务是否加 LockAssert.assertLocked()? [ ] 业务是否有幂等? [ ] 数据库是否有唯一约束? [ ] 是否有任务执行日志? [ ] 是否有失败告警? [ ] 是否验证过双实例启动? [ ] 是否区分 dev/test/prod 锁存储或锁前缀?
20. 推荐实践总结 20.1 优先使用 JDBC Provider 如果你的任务是严肃业务任务,比如:
财务结算;
对账;
账单生成;
订单状态推进;
数据归档;
建议优先使用 JDBC Provider。
原因:
1 2 3 4 1. 和业务数据库一致性更容易理解; 2. 可观测性更好,直接查表; 3. 运维介入方便; 4. usingDbTime() 可以减少节点时钟差异问题。
20.2 Redis Provider 用在轻量任务 Redis Provider 很方便,但不要神化 Redis 锁。
适合:
1 2 3 4 缓存刷新; 轻量扫描; 非核心同步; 允许下一周期补偿的任务。
20.3 任务要短、小、可重入、可补偿 好的定时任务应该具备:
1 2 3 4 5 短:不要一个任务跑几个小时; 小:按批次处理; 可重入:重复执行不会产生错误结果; 可补偿:失败后下次可以继续; 可观测:日志、指标、告警齐全。
20.4 ShedLock + 幂等 + 唯一索引才是完整方案 最终推荐组合:
1 2 3 4 5 ShedLock:防止多节点并发执行; 业务状态:防止重复推进; 唯一索引:防止重复落库; 日志指标:保证可观测; 告警补偿:保证故障可恢复。
21. 完整示例汇总 21.1 依赖 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 <properties > <java.version > 17</java.version > <shedlock.version > 7.7.0</shedlock.version > </properties > <dependencies > <dependency > <groupId > org.springframework.boot</groupId > <artifactId > spring-boot-starter-web</artifactId > </dependency > <dependency > <groupId > org.springframework.boot</groupId > <artifactId > spring-boot-starter-jdbc</artifactId > </dependency > <dependency > <groupId > org.postgresql</groupId > <artifactId > postgresql</artifactId > <scope > runtime</scope > </dependency > <dependency > <groupId > net.javacrumbs.shedlock</groupId > <artifactId > shedlock-spring</artifactId > <version > ${shedlock.version}</version > </dependency > <dependency > <groupId > net.javacrumbs.shedlock</groupId > <artifactId > shedlock-provider-jdbc-template</artifactId > <version > ${shedlock.version}</version > </dependency > </dependencies >
21.2 锁表 1 2 3 4 5 6 7 8 CREATE TABLE shedlock ( name VARCHAR (64 ) NOT NULL , lock_until TIMESTAMP NOT NULL , locked_at TIMESTAMP NOT NULL , locked_by VARCHAR (255 ) NOT NULL , PRIMARY KEY (name) );
21.3 配置类 1 2 3 4 5 @Configuration @EnableScheduling @EnableSchedulerLock(defaultLockAtMostFor = "PT30M") public class ScheduleConfiguration { }
1 2 3 4 5 6 7 8 9 10 11 12 13 @Configuration public class ShedLockJdbcConfiguration { @Bean public LockProvider lockProvider (DataSource dataSource) { return new JdbcTemplateLockProvider ( JdbcTemplateLockProvider.Configuration.builder() .withJdbcTemplate(new JdbcTemplate (dataSource)) .usingDbTime() .build() ); } }
21.4 定时任务 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 @Slf4j @Component @RequiredArgsConstructor public class SettlementBillScheduleTask { private final SettlementBillService settlementBillService; @Scheduled(cron = "0 0 2 * * ?") @SchedulerLock( name = "finance:settlement:generate-yesterday-bill", lockAtMostFor = "PT60M", lockAtLeastFor = "PT1M" ) public void generateYesterdaySettlementBill () { LockAssert.assertLocked(); LocalDate billDate = LocalDate.now().minusDays(1 ); long start = System.currentTimeMillis(); log.info("[schedule-start] task=generateYesterdaySettlementBill, billDate={}" , billDate); try { settlementBillService.generateBillIfAbsent(billDate); log.info("[schedule-success] task=generateYesterdaySettlementBill, billDate={}, costMs={}" , billDate, System.currentTimeMillis() - start); } catch (Exception ex) { log.error("[schedule-error] task=generateYesterdaySettlementBill, billDate={}, costMs={}, errorMsg={}" , billDate, System.currentTimeMillis() - start, ex.getMessage(), ex); throw ex; } } }
参考资料
ShedLock GitHub 官方仓库:https://github.com/lukas-krecan/ShedLock
ShedLock README:https://github.com/lukas-krecan/ShedLock/blob/master/README.md
Maven Central - shedlock-spring:https://central.sonatype.com/artifact/net.javacrumbs.shedlock/shedlock-spring
Maven Repository - shedlock-spring:https://mvnrepository.com/artifact/net.javacrumbs.shedlock/shedlock-spring
Baeldung - Guide to ShedLock with Spring:https://www.baeldung.com/shedlock-spring
启示录 ShedLock 的价值不在于它有多复杂,而在于它刚好补上了 @Scheduled 在分布式部署下缺失的那块拼图。
但要记住:它只是锁,不是调度平台;它能减少重复执行,但不能替代业务幂等;它能防止多数情况下的多节点并发,但不能替你修复所有任务设计问题。
真正靠谱的生产方案应该是:
1 2 3 4 ShedLock 控制入口; 业务幂等控制过程; 唯一约束控制结果; 日志监控控制风险。
富贵岂由人,时会高志须酬。
能成功于千载者,必以近察远。