Spring中使用分布式任务同步ShedLock

在本文中,了解如何使用 ShedLock 在分布式系统中执行任务,ShedLock 是一个在复杂 Spring 应用程序中协调任务的有用工具。

在当今的分布式计算环境中,协调多个节点之间的任务同时确保它们在没有冲突或重复的情况下执行,提出了重大挑战。无论是管理定期作业、批处理还是关键系统任务,保持同步和一致性对于无缝操作至关重要。

问题
假设我们需要按计划运行一些任务,无论是数据库清理任务还是某些数据生成任务。如果直接处理问题,可以使用Spring框架@Schedules中包含的注释来解决这个问题。此注释允许您以固定时间间隔或按 cron 计划运行代码。但是,如果我们的服务实例数量不止一个怎么办?在这种情况下,该任务将在我们服务的每个实例上执行。

ShedLock
ShedLock确保您的计划任务最多同时执行一次。该库通过外部存储实现锁定。如果某个任务在一个实例上执行,则设置锁,所有其他实例不等待,并跳过该任务的执行。这实现了“最多执行一次”。

外部存储可以是通过 JDBC、NoSQL(Mongo、Redis、DynamoDB)和许多其他数据库(完整列表可以在项目页面上找到)工作的关系数据库( PostgreSQL、MySQL 、Oracle 等)。

假设有一个使用 PostgreSQL 的示例。首先,让我们使用Docker启动数据库:

docker run -d -p 5432:5432 --name db \
    -e POSTGRES_USER=admin \
    -e POSTGRES_PASSWORD=password \
    -e POSTGRES_DB=demo \
    postgres:alpine


现在需要创建一个锁表。在项目页面,我们需要找到 PostgreSQL 的 SQL 脚本:

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 - 获取锁的实体(如应用程序实例)的标识符

接下来,创建 Spring Boot 项目并在 build.gradle 中添加必要的依赖项:
implementation 'net.javacrumbs.shedlock:shedlock-spring:5.10.2'
implementation 'net.javacrumbs.shedlock:shedlock-provider-jdbc-template:5.10.2'

配置:

@Configuration
@EnableScheduling
@EnableSchedulerLock(defaultLockAtMostFor = "10m")
public class ShedLockConfig {
    @Bean
    public LockProvider lockProvider(DataSource dataSource) {
        return new JdbcTemplateLockProvider(
                JdbcTemplateLockProvider.Configuration.builder()
                        .withJdbcTemplate(new JdbcTemplate(dataSource))
                        .usingDbTime()
                        .build()
        );
    }
}


让我们创建一个 ExampleTask,每分钟启动一次,执行一些耗时的操作。为此,我们将使用 @Scheduled 注解:

@Service
public class ExampleTask {

    @Scheduled(cron = "0 * * ? * *")
    @SchedulerLock(name = "exampleTask", lockAtMostFor = "50s", lockAtLeastFor = "20s")
    public void scheduledTask() throws InterruptedException {
        System.out.println("task scheduled!");
        Thread.sleep(15000);
        System.out.println("task executed!");
    }
}

在此,我们使用 Thread.sleep 15 秒来模拟任务的执行时间。一旦应用程序启动,任务开始执行,一条记录将被插入数据库:

docker exec -ti <CONTAINER ID> bash

psql -U admin demo
psql (12.16)
Type "help" for help.

demo=# SELECT * FROM shedlock;
    name     |         lock_until         |         locked_at          |   locked_by
-------------+----------------------------+----------------------------+---------------
 exampleTask | 2024-02-18 08:08:50.055274 | 2024-02-18 08:08:00.055274 | MacBook.local

如果在同一时间,另一个应用程序试图运行任务,它将无法获得锁,并跳过任务的执行:
2024-02-18 08:08:50.057 DEBUG 45988 --- [   scheduling-1] n.j.s.core.DefaultLockingTaskExecutor    
: Not executing 'exampleTask'. It's locked.

  • 第一个应用程序获得锁时,会在数据库中创建一条记录,其锁定时间等于锁设置中的 lockAtMostFor。这个时间是必要的,以确保在应用程序因某种原因崩溃或终止时(例如,在 Kubernetes 中将 pod 从一个节点驱逐到另一个节点),锁不会被永久设置。
  • 任务成功执行后,应用程序将更新数据库条目,并将锁定时间减少到当前时间,但如果任务执行时间很短,该值不能小于配置中的 lockAtLeastFor。为了尽量减少实例之间的时钟不同步,这个值是必要的。它可确保计划任务只同时执行一次。


结论
ShedLock 是在复杂 Spring 应用程序中协调任务的有用工具。它能确保任务顺利运行,即使在多个实例中也只运行一次。它易于设置,能为 Spring 应用程序提供可靠的任务处理能力,因此对于处理分布式系统的人来说,它是一个非常有价值的工具。

 GitHub