分布式任务调度-定时任务重复执行解决方案
最近一期需求遇到这么个问题,需要写一个定时任务,项目是集群部署的并且服务器资源有限没有redis、Zookeeper等。
我们都知道,当我们服务端在部署集群模式时,会出现所有的定时任务在各自的节点处都会执行一遍,这显然是不符合要求的,如何解决这个问题?那就是引入分布式锁。
分布式锁三种实现方式:1、基于数据库实现分布式锁;2、基于缓存(Redis等)实现分布式锁;3、基于Zookeeper实现分布式锁
基于Redis实现分布式锁的传送门:redis分布式锁
那要怎么解决呢?没错,就是使用第一种方式:基于数据库实现分布式锁
本文给出一种springboot集成shedlock的解决方案,以及对shedlock的大致实现原理的源码解析
1.引入相关jar包
<dependency> <groupId>net.javacrumbs.shedlock</groupId> <artifactId>shedlock-spring</artifactId> <version>2.2.1</version> </dependency> <dependency> <groupId>net.javacrumbs.shedlock</groupId> <artifactId>shedlock-provider-jdbc-template</artifactId> <version>2.2.1</version> </dependency>
2.数据库新建表
CREATE TABLE `t_scheduled_lock` ( `NAME` varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '任务名称', `lock_until` timestamp(3) NULL DEFAULT NULL COMMENT '到期时间', `locked_at` timestamp(3) NULL DEFAULT NULL COMMENT '开始时间', `locked_by` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL, PRIMARY KEY (`NAME`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='分布式任务调度锁表';
3.配置类
import net.javacrumbs.shedlock.core.LockProvider; import net.javacrumbs.shedlock.provider.jdbctemplate.JdbcTemplateLockProvider; import net.javacrumbs.shedlock.spring.ScheduledLockConfiguration; import net.javacrumbs.shedlock.spring.ScheduledLockConfigurationBuilder; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.scheduling.annotation.EnableScheduling; import javax.sql.DataSource; import java.time.Duration; @Configuration @EnableScheduling public class ScheduledConfig { @Bean public LockProvider lockProvider(DataSource dataSource) { //指定表名,会自动在数据库中记录任务执行日志 return new JdbcTemplateLockProvider(dataSource,"t_scheduled_lock"); } @Bean public ScheduledLockConfiguration scheduledLockConfiguration(LockProvider lockProvider) { return ScheduledLockConfigurationBuilder .withLockProvider(lockProvider) .withPoolSize(10) .withDefaultLockAtMostFor(Duration.ofMinutes(30)) .build(); } }
4.在启动类添加注解
//默认持有锁时间=30分钟 @EnableSchedulerLock(defaultLockAtMostFor = "PT30M")
5.添加定时任务方法
@RequestMapping(value ="/text", method = {RequestMethod.GET,RequestMethod.POST}) // @Scheduled(cron = "-") //表示不执行 @Scheduled(cron = "0 0 0 * * ?") //表示每天0点执行 @SchedulerLock(name = "test",lockAtLeastForString = "PT30M") public void test() { // do something... }
@SchedulerLock注解有以下几个属性
name:锁名称,锁名称必须是全局唯一的;
lockAtMostFor(单位:毫秒):设置锁的最大持有时间,为了解决如果持有锁的节点挂了,无法释放锁,其他节点无法进行下一次任务,正常情况下任务执行完就会释放锁;
lockAtMostForString:锁的最大持有时间的字符串表达,例如“PT30M”表示为30分钟;
lockAtLeastFor(单位:毫秒):保留锁的最短时间。这个属性是锁的持有时间。设置了多少就一定会持有多长时间,再此期间,下一次任务执行时,其他节点包括它本身是不会执行任务的;
lockAtLeastForString:保留锁的最短时间的字符串表达,例如“PT30M”表示为30分钟
拿我上面的代码解释一下:锁test设置了lockAtLeastFor或者lockAtLeastForString属性的值为30分钟,就意味这30分钟内,text()方法不会执行第二遍,30分钟后才会执行下一次的任务调度。
源码解析:
假如这个定时任务开发环境不执行,但是测试环境跟生产环境又执行
可以这么设置@Scheduled(cron = "-"),可以将cron的值写在不同环境的配置文件中
因为在ScheduledAnnotationBeanPostProcessor.java中的processScheduled()方法提到
接下来我们看看@SchedulerLock是如何加锁的
1.在DefaultLockingTaskExecutor.class中executeWithLock方法写到,先执行加锁lock,加锁成功则执行任务,最后unlock释放锁,加锁不成功则提示It's locked
public void executeWithLock(Task task, LockConfiguration lockConfig) throws Throwable { Optional<SimpleLock> lock = this.lockProvider.lock(lockConfig); if (lock.isPresent()) { try { logger.debug("Locked {}.", lockConfig.getName()); task.call(); } finally { ((SimpleLock)lock.get()).unlock(); logger.debug("Unlocked {}.", lockConfig.getName()); } } else { logger.debug("Not executing {}. It's locked.", lockConfig.getName()); } }
2.再看看lock()方法是怎么写的,StorageBasedLockProvider.class中有提到,意思是先去判断当前的任务name是否添加过,如果没有则执行insertRecord做新增操作,新增成功则保存当前任务的名称,若新增失败,则执行updateRecord更新操作,最终返回新增或更新的结果。注意:这里有可能新增或更新失败,为什么?因为我们是集群部署的,定时任务同一时刻同时执行时,两个不同的线程会同时去执行新增或更新,那么问题来了,为什么最终会只有一个线程成功呢?继续看看insertRecord方法或者updateRecord方法内部怎么写的了
public Optional<SimpleLock> lock(LockConfiguration lockConfiguration) { boolean lockObtained = this.doLock(lockConfiguration); return lockObtained ? Optional.of(new StorageBasedLockProvider.StorageLock(lockConfiguration, this.storageAccessor)) : Optional.empty(); } protected boolean doLock(LockConfiguration lockConfiguration) { String name = lockConfiguration.getName(); if (!this.lockRecordRegistry.lockRecordRecentlyCreated(name)) { if (this.storageAccessor.insertRecord(lockConfiguration)) { this.lockRecordRegistry.addLockRecord(name); return true; } this.lockRecordRegistry.addLockRecord(name); } return this.storageAccessor.updateRecord(lockConfiguration); }
3.JdbcTemplateStorageAccessor.class
//insertRecord方法其实就是对数据库插入一条锁的记录,包括锁名称,锁到期时间,锁开始时间以及加锁的来源,然后将插入的结果返回
public boolean insertRecord(LockConfiguration lockConfiguration) { String sql = "INSERT INTO " + this.tableName + "(name, lock_until, locked_at, locked_by) VALUES(?, ?, ?, ?)"; return (Boolean)this.transactionTemplate.execute((status) -> { try { int insertedRows = this.jdbcTemplate.update(sql, (preparedStatement) -> { preparedStatement.setString(1, lockConfiguration.getName()); this.setTimestamp(preparedStatement, 2, lockConfiguration.getLockAtMostUntil()); this.setTimestamp(preparedStatement, 3, Instant.now()); preparedStatement.setString(4, this.getHostname()); }); return insertedRows > 0; } catch (DataIntegrityViolationException var5) { return false; } }); }
//updateRecord方法就是更新数据库的记录,注意where条件,它会更新(当前锁名称+锁的到期时间<=当前时间)的记录,并且返回更新结果
//举个例子:有一条name=test的锁记录,lock_until锁到期时间是中午12点半,当中午12点时定时任务test执行,执行update更新语句事数据库会返回影响条数0条
//因为test这个锁还没到释放的时间,所以updateRecord方法返回值是true public boolean updateRecord(LockConfiguration lockConfiguration) { String sql = "UPDATE " + this.tableName + " SET lock_until = ?, locked_at = ?, locked_by = ? WHERE name = ? AND lock_until <= ?"; return (Boolean)this.transactionTemplate.execute((status) -> { int updatedRows = this.jdbcTemplate.update(sql, (statement) -> { Instant now = Instant.now(); this.setTimestamp(statement, 1, lockConfiguration.getLockAtMostUntil());//锁的到期时间 this.setTimestamp(statement, 2, now); statement.setString(3, this.getHostname()); statement.setString(4, lockConfiguration.getName()); this.setTimestamp(statement, 5, now); }); return updatedRows > 0; }); }
其实说到这里,还是没能解答我们上面提到的问题:为什么最终会只有一个线程成功呢?看一下transactionTemplate.execute的源码你就懂了
@Override @Nullable public <T> T execute(TransactionCallback<T> action) throws TransactionException { Assert.state(this.transactionManager != null, "No PlatformTransactionManager set"); if (this.transactionManager instanceof CallbackPreferringPlatformTransactionManager) { return ((CallbackPreferringPlatformTransactionManager) this.transactionManager).execute(this, action); } else { TransactionStatus status = this.transactionManager.getTransaction(this); T result; try { result = action.doInTransaction(status); } catch (RuntimeException | Error ex) { // Transactional code threw application exception -> rollback rollbackOnException(status, ex); throw ex; } catch (Throwable ex) { // Transactional code threw unexpected exception -> rollback rollbackOnException(status, ex); throw new UndeclaredThrowableException(ex, "TransactionCallback threw undeclared checked exception"); } this.transactionManager.commit(status); return result; } }
可以看出,当A线程先执行新增或者更新数据时,会为这条数据添加事务并且为数据添加排他锁,其他B、C、D等线程会一直等待A线程的事务处理完。当A处理完时,其他线程才能继续对这条数据进行加锁处理,发现加锁不成功(因为已经有其他线程比它先行一步处理了),所以就会提示It's locked(上文有提到)
排他锁的定义:若事务T对数据对象A加上X锁,则只允许T读取和修改A,其他任何事务都不能再对A加任何类型的锁,直到T释放A上的锁。这就保证了其他事务在T释放A上的锁之前不能再读取和修改A
线程加锁完成、任务执行完成之后,下一步就是释放锁,看看它是怎么释放锁的
public void unlock(final LockConfiguration lockConfiguration) {
//更新这条数据锁的到期时间 final String sql = "UPDATE " + this.tableName + " SET lock_until = ? WHERE name = ?"; this.transactionTemplate.execute(new TransactionCallbackWithoutResult() { protected void doInTransactionWithoutResult(TransactionStatus status) { JdbcTemplateStorageAccessor.this.jdbcTemplate.update(sql, (statement) -> { JdbcTemplateStorageAccessor.this.setTimestamp(statement, 1, lockConfiguration.getUnlockTime());//获取解锁时间 statement.setString(2, lockConfiguration.getName()); }); } }); }
//获取解锁时间
public Instant getUnlockTime() {
Instant now = Instant.now();//获取当前时间
return this.lockAtLeastUntil.isAfter(now) ? this.lockAtLeastUntil : now;
}
lockAtLeastUntil的值就是上面提到的lockAtLeastFor或者lockAtLeastForString属性经过换算后得到的时间
释放锁的逻辑就是:会用当前时间跟保留锁的最短时间进行比较,如果当前时间小于lockAtLeastUntil,则继续用lockAtLeastUntil更新数据(相当于没更新),如果当前时间大于lockAtLeastUntil,则更新为当前时间(也就是从现在开始,这条数据的锁已经到期了)。
举个例子:数据库有一条中午12点加锁的记录A,并且lockAtLeastFor或者lockAtLeastForString设置了30分钟,就意味着A的释放锁时间=12点30分,也就是lockAtLeastUntil=12点30分 ,在当前时间12点20分线程执行到这里想要释放锁,就会用12点20分跟12点30分进行比较发现结果是小于,则继续用lockAtLeastUntil更新数据(相当于没更新)
以上就是本人对shedlock实现分布式锁理解的整个过程,若有理解不对还望指出,我们共同学习!
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· 全程不用写代码,我用AI程序员写了一个飞机大战
· MongoDB 8.0这个新功能碉堡了,比商业数据库还牛
· 记一次.NET内存居高不下排查解决与启示
· 白话解读 Dapr 1.15:你的「微服务管家」又秀新绝活了
· DeepSeek 开源周回顾「GitHub 热点速览」