分布式任务调度-定时任务重复执行解决方案

最近一期需求遇到这么个问题,需要写一个定时任务,项目是集群部署的并且服务器资源有限没有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实现分布式锁理解的整个过程,若有理解不对还望指出,我们共同学习!

posted @ 2022-04-22 16:02  小巫同学  阅读(2921)  评论(0编辑  收藏  举报