ShedLock 深入解析:Spring Boot 分布式定时任务锁的原理与生产实战

欢迎你来读这篇博客,这篇博客主要围绕 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. 不同节点之间存在轻微时钟差异。

示例:

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() {
// 每 15 分钟同步一次,正常 2~5 分钟,极端 10 分钟
}

这里 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)
);

COMMENT
ON TABLE shedlock IS 'ShedLock 分布式定时任务锁表';
COMMENT
ON COLUMN shedlock.name IS '锁名称,全局唯一';
COMMENT
ON COLUMN shedlock.lock_until IS '锁持有到期时间';
COMMENT
ON COLUMN shedlock.locked_at IS '加锁时间';
COMMENT
ON 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 shedlock
SET lock_until = NOW() - INTERVAL '1 minute'
WHERE name = 'settlement-generate-task';

MySQL 可以写成:

1
2
3
UPDATE shedlock
SET 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
.usingDbTime()

原因是:

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;

/**
* 每天凌晨 2 点生成昨日结算单。
*/
@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

启动:

1
docker compose up -d

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
SELECT *
FROM shedlock;

你会看到类似数据:

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() {
// 结算任务,用 JDBC 锁
}
1
2
3
4
5
6
@Scheduled(cron = "0 */5 * * * ?")
@SchedulerLock(name = "finance:cache:refresh", lockAtMostFor = "PT2M")
@LockProviderToUse("redisLockProvider")
public void refreshCache() {
// 缓存刷新任务,用 Redis 锁
}

如果没有指定,且容器里存在多个 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() {
// do something
}
}

不过,大多数项目里还是注解方式更清爽。

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() {
// 一次性处理 100 万条数据
}

更好的方式:

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
@EnableScheduling

但是忘了:

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 分钟,你配置:

1
lockAtMostFor = "PT2M"

两分钟后锁过期,其他节点又开始执行。

这就是自己把保险丝接成了烟花。

16.5 lockAtMostFor 太长

任务正常 10 秒,你配置:

1
lockAtMostFor = "PT24H"

一旦任务执行节点宕机,后续 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 shedlock
SET 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;
}
}
}

参考资料

  1. ShedLock GitHub 官方仓库:https://github.com/lukas-krecan/ShedLock
  2. ShedLock README:https://github.com/lukas-krecan/ShedLock/blob/master/README.md
  3. Maven Central - shedlock-springhttps://central.sonatype.com/artifact/net.javacrumbs.shedlock/shedlock-spring
  4. Maven Repository - shedlock-springhttps://mvnrepository.com/artifact/net.javacrumbs.shedlock/shedlock-spring
  5. Baeldung - Guide to ShedLock with Spring:https://www.baeldung.com/shedlock-spring

启示录

ShedLock 的价值不在于它有多复杂,而在于它刚好补上了 @Scheduled 在分布式部署下缺失的那块拼图。

但要记住:它只是锁,不是调度平台;它能减少重复执行,但不能替代业务幂等;它能防止多数情况下的多节点并发,但不能替你修复所有任务设计问题。

真正靠谱的生产方案应该是:

1
2
3
4
ShedLock 控制入口;
业务幂等控制过程;
唯一约束控制结果;
日志监控控制风险。

富贵岂由人,时会高志须酬。

能成功于千载者,必以近察远。


ShedLock 深入解析:Spring Boot 分布式定时任务锁的原理与生产实战
https://allendericdalexander.github.io/2026/06/12/archtect/distribute/shedlock-deep-dive-practice-blog/
作者
AtLuoFu
发布于
2026年6月12日
许可协议