Spring 快速集成分布式定时器

前言

在业务生产中,定时器 Scheduler 的使用频率非常高。Spring 也为我们提供了默认的定时器。只需要加上 @Scheduled@EnableScheduling 两个注解,即可快速运行。在单实例的服务中,官方的提供的定时任务可以非常方便的使用。

但是在如今分布式多实例的环境中,使用这种定时任务,则每个实例都定时并发执行,做着相同的事情,这必然不是我们想要的效果。这时你一定会想办法,让定时任务每次只在一个服务中运行,例如:每个服务通过配置文件来做为定时的开关、或是通过数据库实现分布式锁,这都是非常不错的选择。

本文将介绍一个更方便的组件,在 SpringBoot 工程中,你仅需要通过少量的代码即可实现分布式定时器功能。

还等什么呢,即刻开始!

ShedLock 简介

ShedLock 的作用,确保任务在同一时刻最多执行一次。如果一个任务正在一个节点上执行,则它将获得一个锁,该锁将阻止从另一个节点(或线程)执行同一任务。如果一个任务已经在一个节点上执行,则在其他节点上的执行不会等待,只需跳过它即可 。

ShedLock 使用 Mongo,JDBC 数据库,Redis,Hazelcast,ZooKeeper 或其他外部存储进行协调,即通过外部存储来实现锁机制。

官方传送门:lukas-krecan/ShedLock: Distributed lock for your scheduled tasks

ShedLock 快速集成

本文将以 shedlock + mysql 为例,在 SpringBoot 中快速集成分布式定时任务。如果想

**核心思想:**通过对多个实例公共的数据库的 shedlock 表进行添加数据库锁,使得同一个定时任务在同一个时间点只有一个实例执行。

1. 引入依赖

这里引入了 shedlock-spring / shedlock-provider-redis-spring 两个依赖,shedlock-provider 也提供了丰富的 Lock Providers,例如:Redis、JdbcTemplate、Mongo 等等

1
2
3
4
5
6
7
8
9
10
<dependency>
<groupId>net.javacrumbs.shedlock</groupId>
<artifactId>shedlock-spring</artifactId>
<version>4.29.0</version>
</dependency>
<dependency>
<groupId>net.javacrumbs.shedlock</groupId>
<artifactId>shedlock-provider-jdbc-template</artifactId>
<version>4.29.0</version>
</dependency>

2. 配置数据库连接信息

resources/application.yml

1
2
3
4
5
6
7
spring:
datasource:
url: jdbc:mysql://127.0.0.1:3306/vote_app?useSSL=false&serverTimezone=UTC&characterEncoding=UTF8
username: ******
password: ******
driver-class-name: com.mysql.cj.jdbc.Driver
type: com.mysql.cj.jdbc.MysqlDataSource

如果 SpringBoot 工程已经集成了 MySQL 则可以跳过这一步。

3. 创建 MySQL 数据表

1
2
3
# MySQL, MariaDB
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));

4. ShedLockConfig 配置类,配置 lockProvider

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
import net.javacrumbs.shedlock.core.LockProvider;
import net.javacrumbs.shedlock.provider.jdbctemplate.JdbcTemplateLockProvider;
import net.javacrumbs.shedlock.spring.annotation.EnableSchedulerLock;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.scheduling.annotation.EnableScheduling;

import javax.sql.DataSource;

/**
* Shedlock 配置类
*
* @author frank_lin@whu.edu.cn
* @date 2021/10/23
*/

// 标识该类为配置类
@Configuration
// 开启定时器
@EnableScheduling
// 开启定时任务锁,指定一个默认的锁的时间30秒
@EnableSchedulerLock(defaultLockAtMostFor = "PT30S")
public class ShedlockJdbcConfig {
/**
* 配置锁的提供者
*/
@Bean
public LockProvider lockProvider(DataSource dataSource) {
return new JdbcTemplateLockProvider(
JdbcTemplateLockProvider.Configuration.builder()
.withJdbcTemplate(new JdbcTemplate(dataSource))
.withTableName("system_shedlock") // 这里可以指定你的 MySQL 锁表,默认表名为:shedlock
.usingDbTime()
.build()
);
}
}

5. MainApplication 启动类配置

1
2
3
4
5
6
7
8
@EnableScheduling
public class MainApplication {

public static void main(String[] args) {
SpringApplication.run(LatticyApplication.class, args);
}

}

6. 创建定时任务

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
import lombok.extern.slf4j.Slf4j;
import net.javacrumbs.shedlock.spring.annotation.SchedulerLock;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;


/**
* 开启分布式锁定时任务
* 在线 cron 生成工具:https://www.bejson.com/othertools/cron/
*
* @author frank_lin@whu.edu.cn
* @date 2021/10/23 16:55:12
*/

@Component
@Slf4j
public class TaskJobDemo {

private static Integer count = 1;

// @SchedulerLock的作用是保证当前定时任务的方法执行时获得锁,忽略其他相同任务的执行
// name必须要指定,ShedLock就是根据这个name进行相同任务判定的
// name:定时任务的名字,就是数据库中的主键(name)
// lockAtMostFor:锁的最大时间单位为毫秒
// lockAtLeastFor:锁的最小时间单位为毫秒

/**
* 任务1每5秒执行一次
* lockAtLeastFor:虽然,定时任务是每隔5秒执行一次,但是,分布式锁定义的是:每次任务要锁住20秒,20秒是持有锁的最小时间,必须等20秒后才释放锁,并且确保在20秒钟内,该任务不会运行超过1次;
* lockAtMostFor:锁最大持有时间30秒,表示最多锁定30秒钟,主要用于防止执行任务的节点挂掉(即使这个节点挂掉,在30秒钟后,锁也被释放),一般将其设置为明显大于任务的最大执行时长;如果任务运行时间超过该值(即任务30秒钟没有执行完),则该任务可能被重复执行。
*/
@Scheduled(cron = "0/5 * * * * ? ")
@SchedulerLock(name = "testJob1", lockAtLeastFor = "20000", lockAtMostFor = "30000")
public void scheduledTask1() {
log.info(Thread.currentThread().getName() + "->>>任务1执行第:" + (count++) + "次");
}

/**
* 任务 2 每 5 秒执行一次
*/
@Scheduled(cron = "0/5 * * * * ?")
@SchedulerLock(name = "shedlock-demo")
public void scheduledTask2() {
log.info(Thread.currentThread().getName() + "->>>任务2执行第:" + (count++) + "次");
}

}

@SchedulerLock 注解有五个参数

  • name:定时任务的名字,就是数据库中的内个主键
  • lockAtMostFor:锁的最大时间单位为毫秒
  • lockAtMostForString:最大时间的字符串形式,例如:PT30S 代表30秒
  • lockAtLeastFor:锁的最小时间单位为毫秒
  • lockAtLeastForString:最小时间的字符串形式

* 7. 让你的定时任务并行执行

这里有个小坑,默认 schedule 是单线程。如果你在多个函数上使用了 @Scheduled,定时任务是顺序执行,只有等定时任务 1 执行完成才执行任务 2。若其中一个定时任务阻塞,会影响其他的定时任务。因此,我们必须对定时任务进行配置,使定时任务互相不干扰。

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
import org.springframework.context.annotation.Configuration;
import org.springframework.scheduling.annotation.SchedulingConfigurer;
import org.springframework.scheduling.concurrent.ThreadPoolTaskScheduler;
import org.springframework.scheduling.config.ScheduledTaskRegistrar;

import java.util.concurrent.Executors;
//配置自定义线程池
@Configuration
public class ScheduleConfig implements SchedulingConfigurer {
// @Override
// public void configureTasks(ScheduledTaskRegistrar taskRegistrar) {
// taskRegistrar.setScheduler(Executors.newScheduledThreadPool(5));
// }

@Override
public void configureTasks(ScheduledTaskRegistrar taskRegistrar) {
taskRegistrar.setScheduler(this.getTaskScheduler());
}

private ThreadPoolTaskScheduler getTaskScheduler() {
ThreadPoolTaskScheduler taskScheduler = new ThreadPoolTaskScheduler();
taskScheduler.setPoolSize(5);
taskScheduler.setThreadNamePrefix("schedule-pool-");
taskScheduler.afterPropertiesSet();
return taskScheduler;
}
}

至此 @Scheduled 可以并发执行了,最高并发度是 20,但是同一个 @Schedule 不会并发执行。

参考资料