【小笔记】多节点定时任务引起的对DB式分布式锁的思考

1. 场景

定时任务定时扫表,每天进行数据上报。遇到一个问题:定时任务程序是多节点部署的,如何保证扫描到的数据不会重复呢?

2. 分析

会出现重复的原因是部署了多个节点,每个节点到时间都会执行一次任务,就可能会造成重复。所以只要保证每次到时间时,只有一个节点执行就行了。于是有3种解决方案:

  • 只启动一个节点
  • 使用分布式锁
  • 使用分布式定时任务框架(如Quartz)

为了最快的、在不改动代码的情况下解决问题,最后还是选择了只启动一个节点,因为这个定时任务对系统的影响不是很大。如果说后期确实需要修改的话,使用Redisson提供的分布式锁则是比较正规的一个方案。

虽然没怎么改动,但是也借次机会回顾下关于数据库实现分布式锁的相关问题及实现方式。

3. Database实现分布式锁

最简单的莫过于使用数据库来实现分布式锁了,在某些场景下,作为分布式锁的简单实现,但也只能是一个临时的替代方案。利用数据库自带的锁和事务的特性:任意时刻对同一条数据的修改只会有一个成功。根据其update的返回值(0或大于0)来实现加锁、解锁以及可重入锁。

以下结合定时任务这个场景,使用DB实现一个分布式锁,来保证同一时刻只会有一个节点执行同一个任务。

3.1 定时任务表

简单的建个不太规范的表:

create table SCHEDULED_LOCK
(
    SCHEDULED_KEY  VARCHAR(50) comment '定时任务key',
    SCHEDULED_NAME VARCHAR(100) comment '定时任务名称',
    IS_RUNNING     TINYINT comment '是否正在运行',
    EXECUTOR       VARCHAR(100) comment '执行者'
);

其中EXECUTOR代表定时任务的执行者,一般的,在单机环境中可以用线程名+方法全限定名来定义一个执行者,但是在分布式环境中,这个名字也是会重复的,所以这里使用的是:项目名称+启动端口+MAC地址来确定一个唯一的excutor

3.2 锁操作-获取锁

我们只需要通过SCHEDULED_KEY、IS_RUNNING来定位到一行记录,并尝试更新IS_RUNNING和EXECUTOR字段的值。

update SCHEDULED_LOCK 
set IS_RUNNING=1,EXECUTOR=?
where SCHEDULED_KEY=? and IS_RUNNING=0 ;

假设有两个节点:

test-1(port:8000)和test-2(port:8001)。

表初始数据为:

SCHEDULED_KEY SCHEDULED_NAME IS_RUNNING EXECUTOR
Scheduled-1 任务1 0 null

(1)test-1先过来,更新数据:

update SCHEDULED_LOCK 
set IS_RUNNING=1,EXECUTOR='test-1-8000-58-A4-12-48-D5-B6'
where SCHEDULED_KEY='Scheduled-1' and IS_RUNNING=0;

此时表数据如下

SCHEDULED_KEY SCHEDULED_NAME IS_RUNNING EXECUTOR
Scheduled-1 任务1 1 test-1-8000-58-A4-12-48-D5-B6

更新成功返回值为1,即获取锁成功

(2)此时test-2过来,更新数据:

update SCHEDULED_LOCK 
set IS_RUNNING=1,EXECUTOR='test-2-8000-58-A4-12-48-D5-B6'
where SCHEDULED_KEY='Scheduled-1' and IS_RUNNING=0 ;

没有匹配到的行,返回值为0 ,即获取锁失败。

获取锁成功的继续执行业务逻辑,失败的不执行。执行成功后进行释放锁

3.3 锁操作-释放锁

和获取锁类似,释放锁只需要将表数据还原为初始状态即可,即IS_RUNNING=0,EXECUTOR=null

update SCHEDULED_LOCK 
set IS_RUNNING=1,EXECUTOR=?
where SCHEDULED_KEY=? and IS_RUNNING=1 and EXECUTOR=?;

3.4 锁操作-可重入及超时释放

分布式锁至少满足以下几点:

  • 互斥
  • 可重入
  • 超时释放
  • 效率高

而作为一个临时的替代方案,至少需要满足前3点,上边获取锁和释放锁的操作只是满足了第一条,而可重入和超时释放并没有满足。

为了实现可重入,则必须知道是谁获取到了锁,所以用前边定义的EXECUTOR字段来存储锁的获取者,当锁的获取者再次获取锁时,可以获取到,就实现了可重入性。

获取锁操作的SQL修改为如下:

update SCHEDULED_LOCK 
set IS_RUNNING=1,EXECUTOR=?
where SCHEDULED_KEY=? and (IS_RUNNING=0 or EXECUTOR=?) ;

为了实现超时释放,有两种方式,一种是定时去扫表,将超时的锁释放掉(update IS_RUNNING=0,EXECUTOR=null),另一种是在获取锁的时候判断是否超时,如果超时则直接获取。第一种不能保证实时性,采取第二种方法。

增加字段LOCK_TIME,在获取锁时:

update SCHEDULED_LOCK 
set IS_RUNNING=1,EXECUTOR=?,LOCK_TIME=now()
where SCHEDULED_KEY=? 
      and (IS_RUNNING=0 
      	  or EXECUTOR=?
      	  or TIMESTAMPDIFF(SECOND, LOCK_TIME,now())>=3) ;

如果当前时间超过上次锁时间,意味着获取锁的客户端未成功释放锁,新来的就可以获取锁

3.4 代码实现

锁的实现:

/**
 * Database lock
 *
 * @author wxg
 */
@Component
public class DBLock extends BaseServiceImpl {
    @Value("${spring.application.name:''}")
    private String appName;
    @Value("${server.port:0}")
    private int port;

    private static String MAC;

    static {
        MAC = getMac();
    }

    public boolean lock(String key) {
        String executor = appName + "-" + port + "-" + MAC;
        final String sql = "update SCHEDULED_LOCK set IS_RUNNING=1,EXECUTOR='" + executor + "',LOCK_TIME=now() where SCHEDULED_KEY='" + key + "' and (IS_RUNNING=0 or EXECUTOR='" + executor + "' or  TIMESTAMPDIFF(SECOND, LOCK_TIME,now())>=3)";
        int update = super.nativeUpdate(sql);
        return update == 1;
    }

    public boolean lock(String key, int timeoutSeconds) {
        String executor = appName + "-" + port + "-" + MAC;
        final String sql = "update SCHEDULED_LOCK set IS_RUNNING=1,EXECUTOR='" + executor + "',LOCK_TIME=now() where SCHEDULED_KEY='" + key + "' and (IS_RUNNING=0 or EXECUTOR='" + executor + "' or  TIMESTAMPDIFF(SECOND, LOCK_TIME,now())>=" + timeoutSeconds + ")";
        int update = super.nativeUpdate(sql);
        return update == 1;
    }

    public boolean releaseLock(String key) {
        String executor = appName + "-" + port + "-" + MAC;
        final String sql = "update SCHEDULED_LOCK set IS_RUNNING=0,EXECUTOR=null where SCHEDULED_KEY='" + key + "' and IS_RUNNING=1 and EXECUTOR='" + executor + "'";
        int update = super.nativeUpdate(sql);
        return update == 1;
    }

    private static String getMac() {
        InetAddress ia;
        byte[] mac = null;
        try {
            ia = InetAddress.getLocalHost();
            mac = NetworkInterface.getByInetAddress(ia).getHardwareAddress();
        } catch (Exception e) {
            e.printStackTrace();
        }
        StringBuilder sb = new StringBuilder();
        if (mac != null) {
            for (int i = 0; i < mac.length; i++) {
                if (i != 0) {
                    sb.append("-");
                }
                String s = Integer.toHexString(mac[i] & 0xFF);
                sb.append(s.length() == 1 ? 0 + s : s);
            }
        }
        return sb.toString().toUpperCase();
    }
}

定时任务:

/**
 * Task
 *
 * @author wxg
 */
@Component
public class TestTask extends BaseServiceImpl {

    private final static Logger logger = LoggerFactory.getLogger(TestTask.class);
    @Value("${spring.application.name}")
    private String appName;


    @Autowired
    private DBLock dbLock;

    @Scheduled(cron = "0/10 * * * * *")
    public void task() {
        String scheduledKey = "Scheduled-1";
        if (dbLock.lock(scheduledKey)) {
            logger.info("{}获取到锁", appName);
            logger.info("{}执行任务", appName);
            dbLock.releaseLock(scheduledKey);
            logger.info("{}释放锁", appName);
        } else {
            logger.error("获取锁失败,其他节点正在执行");
        }
    }

}

启动两个节点,输出:

节点1:

【小笔记】多节点定时任务引起的对DB式分布式锁的思考_第1张图片

节点2输出:
【小笔记】多节点定时任务引起的对DB式分布式锁的思考_第2张图片

4.总结

最后发现这个场景其实完全不用锁,其目的是保证同一时刻只有一个节点执行就行,那就只需要给定时任务一个状态:是否正在运行。即尝试更新状态,更新成功就执行,否则不执行,执行成功后再将状态还原即可。

隐秘的角落:“哪有一路走来都是顺风的”

你可能感兴趣的:(小笔记)