使用ShedLock和SpringBoot锁定@scheduled
一旦你扩展你的SpringBoot应用程序(运行多个实例)以增加吞吐量或可用性,你必须确保你的应用程序已经为这种架构做好了准备。应用程序的某些部分在适合这种架构之前需要进行调整。
@Scheduled任务的使用是一个候选者。大多数情况下,您只希望此执行发生在一个实例上,而不是并行执行。通过这篇博客文章,您将了解如何使用ShedLock为SpringBoot应用程序只执行一次计划任务。
@@scheduled横向扩展环境中的计划任务
许多Springboot应用程序使用@Scheduled注释定期执行任务。从每天晚上的简单报告作业开始,到清理作业,再到同步机制,用例的种类是巨大的。
只要我们的应用程序运行一个实例,就没有问题,因为执行只发生一次。但是,一旦我们的应用程序部署到负载平衡的环境中,同一个Spring靴子应用程序的多个实例并行运行,我们的调度作业就会并行执行。
在报告或同步的情况下,我们可能希望对整个应用程序只执行一次。默认情况下,每个实例都将执行计划任务,而不管是否有其他实例正在运行该任务。这可能会导致数据不一致或重复操作。
Spring并没有提供一个解决方案,即开即用地在一个实例上运行@Scheduled任务。这就是ShedLock发挥作用的地方,因为它解决了这个问题。
ShedLock如何确保只运行一次作业
ShedLock是一个用于计划任务的分布式锁。它确保同一时间只执行一次任务。一旦第一个实例获得了计划任务的锁,所有其他实例将跳过任务执行。一旦下一个任务调度发生,所有节点都会再次尝试获取锁。
ShedLock使用所有节点连接到的持久存储(所谓的LockProvider)存储有关每个调度作业的信息。对于这个LockProvider有多种实现(例如RDBMS,MongoDB,DynamoDB,Etcd等),我们将选择PostgreSQL作为示例。
ShedLock在内部用来管理锁的数据库表很简单,因为它只有四列:
name:计划任务的唯一名称lock_until:当前执行被锁定的时间locked_at:节点获取当前锁的时间戳locked_by:获取当前锁的节点的标识符
ShedLock在我们第一次运行任务时为每个计划任务创建一个条目。从这一点开始,数据库行(每个作业一行)始终存在,并且只会更新(不会删除和重新创建)。
ShedLocks如何锁定计划任务
通过将lock_until列设置为将来的日期,可以实际锁定计划任务。一旦计划执行某个任务,所有应用程序实例都会尝试为此任务更新数据库行。它们只能在任务当前未运行时锁定任务(即lock_until= now())。能够更新lock_until、locked_at、locked_by列的节点具有该执行周期的锁,并将lock_until设置为now() + lockAtMostFor (e.g. 30minutes):
所有其他节点都无法获取锁,因为它们将尝试更新作业的行,其中lock_until= now()。不会更新任何行,因为该锁已被一个实例获取,并且该实例将lock_until设置为未来的日期。
一旦任务完成,ShedLock就会更新数据库行,并将lock_until设置为当前时间戳。有一个例外,ShedLock不会使用当前的时间戳,我们将在下一节中发现。使用更新的lock_until,所有节点都有资格运行下一个任务执行:
如果任务没有完成(例如,节点崩溃或出现意外延迟),我们会在lockAtMostFor之后执行一个新的任务。正如我们将在接下来的部分中看到的,我们必须为所有任务提供一个lockAtMostFor属性。这充当了一个安全网,以避免在节点死亡时发生死锁,从而无法释放锁。
使用ShedLock锁定短时间运行的任务
对于短期运行的任务,我们可以配置一个至少持续X的锁。如果没有这样的配置,如果节点之间的时钟差大于作业的执行时间,我们可能会得到一个任务的多次执行。
让我们来看看锁是如何在短时间运行的任务中工作的。
获取锁的过程与前面描述的场景相同。不同的是解锁阶段。ShedLock没有将lock_until设置为now(),而是在任务执行速度快于locked_at + lockAtLeastFor时将其设置为lockAtLeastFor。
让我们用一个例子来更好地理解这一点。为此,让我们假设我们的应用程序每分钟执行一个短时间运行的任务。
@Scheduled(cron = "0 * * * * *")
@SchedulerLock(name = "shortRunningTask", lockAtMostFor = "50s", lockAtLeastFor = "30s")
public void shortRunningTask() {
System.out.println("Start short running task");
}
一旦这个任务完成,ShedLock会将lock_until设置为now()。如果我们的实例之间存在时钟差异(这在分布式系统中很难避免),如果任务执行非常快,另一个节点可能会再次执行。
为了避免这种情况,我们将lockAtLeastFor设置为作业定义的一部分,以阻止下一次执行至少指定的时间段。然后,ShedLock将在解锁作业时将lock_until设置为至少locked_at + lockAtLeastFor。
示例1(lockAtLeastFor=30s,执行速度非常快):
- 作业开始时间为8:00:00.000
- 作业在8:00:00.450完成
- 解锁此作业时,ShedLock将
lock_until设置为8:00:30.000,而不是now()
示例二(lockAtLeastFor=30s,执行缓慢):
- 作业开始时间为8:00:00.000
- 作业在8:00:31.500完成
- 解锁此作业时,ShedLock将
lock_until设置为8:00:31.500(now()),因为执行时间比我们的配置lockAtLeastFor长
SpringBoot项目设置
我们正在将ShedLock与Spring靴子应用程序集成。请注意,ShedLock仅在具有共享数据库的环境中通过声明适当的LockProvider来工作。它在数据库中创建一个表或文档,在其中存储有关当前锁的信息。目前,ShedLock支持Mongo,Redis,Hazelcast,ZooKeeper和任何带有JDBC驱动程序的东西。
Maven双胞胎
要在Spring中使用ShedLock,我们需要添加shedlock-spring依赖项
<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>
接下来,我们需要为ShedLock创建一个数据库表来保存有关调度器锁的信息:
CREATE TABLE shedlock (
name VARCHAR(64),
lock_until TIMESTAMP(3) NULL,
locked_at TIMESTAMP(3) NULL,
locked_by VARCHAR(255),
PRIMARY KEY (name)
)
这就是我们数据库设置所需要的一切。Shedlock的内部LockProvider也可以与其他底层存储系统一起使用。我们不限于关系数据库,还可以使用MongoDB、DynamoDB、Hazelcast、Redis、Etcd等。
ShedLock在springboot中设置
作为第一步,我们必须为我们的Spring靴子应用程序启用调度和ShedLock的Spring集成。然后ShedLock期望一个类型为LockProvider的Spring Bean作为我们的ApplicationContext的一部分。
对于我们的关系数据库设置,我们使用JdbcTemplateLockProvider并使用自动配置的DataSource配置它:
@Configuration
@EnableScheduling
@EnableSchedulerLock(defaultLockAtMostFor = "15m")
public class ShedLockConfig {@Bean
public LockProvider lockProvider(DataSource dataSource) {
return new JdbcTemplateLockProvider(
JdbcTemplateLockProvider.Configuration.builder()
.withJdbcTemplate(new JdbcTemplate(dataSource))
.usingDbTime()
.build()
);
}
}
在启用ShedLock的Spring集成(@EnableSchedulerLock)时,我们必须指定defaultLockAtMostFor。这是属性作为我们没有显式指定lockAtMostFor的锁的后备配置。
有了这个配置,我们就可以开始为我们的计划任务添加锁了。
使用SpringBoot为计划任务添加锁
剩下的就是将@SchedulerLock添加到我们想要防止多个并行执行的所有@Scheduled作业中。
作为该注释的一部分,我们为ShedLock用作内部shedlock表的主键的计划任务提供了一个名称。因此,这个名称在我们的应用程序中必须是唯一的:
import net.javacrumbs.shedlock.spring.annotation.SchedulerLock;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
@Component
public class Scheduler {
@Scheduled(cron = "0 * * * * *")
@SchedulerLock(name = "shortRunningTask", lockAtMostFor = "50s", lockAtLeastFor = "30s")
public void shortRunningTask() {
System.out.println("Start short running task");
}
}
对于短期运行的任务,我们应该配置lockAtLeastFor。这可以防止由于应用程序节点之间的时钟差异而多次执行短期运行的任务。
总之,ShedLock的集成对于我们的SpringBoot应用程序来说几乎不费吹灰之力。由于LockProviders的多样性,您应该能够将您的主存储解决方案也用于此目的。剩下的就是为你的每个工作调整lockAtMostFor和lockAtLeastFor(如果需要的话)。监视作业的执行时间,然后决定这些值可能会有所帮助。