分布式锁超时问题的处理(只是参考,推荐使用redission框架和ZK做分布式锁)

1、redis分布式锁的基本实现

redis加锁命令:

SETNX resource_name my_random_value PX 30000 

这个命令的作用是在只有这个key不存在的时候才会设置这个key的值(NX选项的作用),超时时间设为30000毫秒(PX选项的作用) 这个key的值设为“my_random_value”。这个值必须在所有获取锁请求的客户端里保持唯一。

SETNX 值保持唯一的是为了确保安全的释放锁,避免误删其他客户端得到的锁。举个例子,一个客户端拿到了锁,被某个操作阻塞了很长时间,过了超时时间后自动释放了这个锁,然后这个客户端之后又尝试删除这个其实已经被其他客户端拿到的锁。所以单纯的用DEL指令有可能造成一个客户端删除了其他客户端的锁,通过校验这个值保证每个客户端都用一个随机字符串’签名’了,这样每个锁就只能被获得锁的客户端删除了。

既然释放锁时既需要校验这个值又需要删除锁,那么就需要保证原子性,redis支持原子地执行一个lua脚本,所以我们通过lua脚本实现原子操作。代码如下:

if redis.call("get",KEYS[1]) == ARGV[1] then
         return redis.call("del",KEYS[1])     
else
         return 0
end

2、业务逻辑执行时间超出锁的超时限制导致两个客户端同时执行(而不是叫同时获取锁的错误写法)的问题

如果在加锁和释放锁之间的逻辑执行得太长,以至于超出了锁的超时限制,就会出现问题。因为这时候第一个线程持有的锁过期了,临界区的逻辑还没有执行完,(疑问:锁超时了,是不是释放锁呢,既然释放锁了,怎么是二个线程都持有这把锁呢),这个时候第二个线程就提前重新持有了这把锁,导致临界区代码不能得到严格的串行执行。

不难发现正常情况下锁操作完后都会被手动释放,常见的解决方案是调大锁的超时时间,之后若再出现超时带来的并发问题,人工介入修正数据。这也不是一个完美的方案,因为但业务逻辑执行时间是不可控的,所以还是可能出现超时,当前线程的逻辑没有执行完,其它线程乘虚而入。并且如果锁超时时间设置过长,当持有锁的客户端宕机,释放锁就得依靠redis的超时时间,这将导致业务在一个超时时间周期内不可用。

基本上,如果在执行计算期间发现锁快要超时了,客户端可以给redis服务实例发送一个Lua脚本让redis服务端延长锁的时间,只要这个锁的key还存在而且值还等于客户端设置的那个值。 客户端应当只有在失效时间内无法延长锁时再去重新获取锁(基本上这个和获取锁的算法是差不多的)。

启动另外一个线程去检查的问题,这个key是否超时,在某个时间还没释放。

当锁超时时间快到期且逻辑未执行完,延长锁超时时间的伪代码:

if redis.call("get",KEYS[1]) == ARGV[1] then
          redis.call("set",KEYS[1],ex=3000)  
else
          getDLock();//重新获取锁 

3、redis的单点故障主从切换带来的两个客户端同时执行(而不是叫同时获取锁的错误写法)的问题

生产中redis一般是主从模式,主节点挂掉时,从节点会取而代之,客户端上却并没有明显感知。原先第一个客户端在主节点中申请成功了一把锁,但是这把锁还没有来得及同步到从节点,主节点突然挂掉了。然后从节点变成了主节点,这个新的节点内部没有这个锁,所以当另一个客户端过来请求加锁时,立即就批准了。这样就会导致系统中同样一把锁被两个客户端同时持有,不安全性由此产生。

不过这种不安全也仅仅是在主从发生 failover 的情况下才会产生,而且持续时间极短,业务系统多数情况下可以容忍。

发现问题

但在最近查线上日志的时候偶然发现,有一个业务场景下,分布式锁偶尔会失效,导致有多个线程同时执行了相同的代码。

(这里理解,当一个线程锁超时后,锁会被自动释放,另一个线程可以获取锁,造成多个线程同时执行此段代码,关键就在这个“同时”问题

我们经过初步排查,定位到是因为在这段代码中间调用了第三方的接口导致。

因为业务代码耗时过长,超过了锁的超时时间,造成锁自动失效,然后另外一个线程意外的持有了锁。于是就出现了多个线程同时执行代码问题。

 

解决方案

问题既然已经出现了,那么接下来我们就应该考虑解决方案了。

我们也曾经想过,是否可以通过合理地设置LockTime(锁超时时间)来解决这个问题?

但LockTime的设置原本就很不容易。LockTime设置过小,锁自动超时的概率就会增加,锁异常失效的概率也就会增加,而LockTime设置过大,万一服务出现异常无法正常释放锁,那么出现这种异常锁的时间也就越长。我们只能通过经验去配置,一个可以接受的值,基本上是这个服务历史上的平均耗时再增加一定的buff。

既然这条路走不通了,那么还有其他路可以走么?

当然还是有的,我们可以先给锁设置一个LockTime,然后启动一个守护线程,让守护线程在一段时间后,重新去设置这个锁的LockTime。这种做法,自己手动来实现锁超时间延长,我们可以使用Redission框架来实现锁超时间延长。推荐使用redission来实现

看起来很简单是不是?

但在实际操作中,我们要注意以下几点:
1、和释放锁的情况一致,我们需要先判断锁的对象是否没有变。否则会造成无论谁持有锁,守护线程都会去重新设置锁的LockTime。不应该续的不能瞎续。
2、守护线程要在合理的时间再去重新设置锁的LockTime,否则会造成资源的浪费。不能动不动就去续。
3、如果持有锁的线程已经处理完业务了,那么守护线程也应该被销毁。不能主人都挂了,守护者还在那里继续浪费资源。

代码实现

我们首先先生成一个内部类去实现Runnable,作为守护线程的参数。

public class SurvivalClamProcessor implements Runnable {
 
    private static final int REDIS_EXPIRE_SUCCESS = 1;
 
    SurvivalClamProcessor(String field, String key, String value, int lockTime) {
        this.field = field;
        this.key = key;
        this.value = value;
        this.lockTime = lockTime;
        this.signal = Boolean.TRUE;
    }
 
    private String field;
 
    private String key;
 
    private String value;
 
    private int lockTime;
 
    //线程关闭的标记
    private volatile Boolean signal;
 
    void stop() {
        this.signal = Boolean.FALSE;
    }
 
    @Override
    public void run() {
        int waitTime = lockTime * 1000 * 2 / 3;
        while (signal) {
            try {
                Thread.sleep(waitTime);
                if (cacheUtils.expandLockTime(field, key, value, lockTime) == REDIS_EXPIRE_SUCCESS) {
                    if (logger.isInfoEnabled()) {
                        logger.info("expandLockTime 成功,本次等待{}ms,将重置锁超时时间重置为{}s,其中field为{},key为{}", waitTime, lockTime, field, key);
                    }
                } else {
                    if (logger.isInfoEnabled()) {
                        logger.info("expandLockTime 失败,将导致SurvivalClamConsumer中断");
                    }
                    this.stop();
                }
            } catch (InterruptedException e) {
                if (logger.isInfoEnabled()) {
                    logger.info("SurvivalClamProcessor 处理线程被强制中断");
                }
            } catch (Exception e) {
                logger.error("SurvivalClamProcessor run error", e);
            }
        }
        if (logger.isInfoEnabled()) {
            logger.info("SurvivalClamProcessor 处理线程已停止");
        }
    }
}

其中expandLockTime是通过Lua脚本实现的。延长锁超时的脚本语句和释放锁的Lua脚本类似。

String script = "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('expire', KEYS[1],ARGV[2]) else return '0' end";

在以上代码中,我们将waitTime设置为Math.max(1, lockTime * 2 / 3),即守护线程许需要等待waitTime后才可以去重新设置锁的超时时间,避免了资源的浪费。

同时在expandLockTime时候也去判断了当前持有锁的对象是否一致,避免了胡乱重置锁超时时间的情况。

然后我们在获得锁的代码之后,添加如下代码:

SurvivalClamProcessor survivalClamProcessor 
	= new SurvivalClamProcessor(lockField, lockKey, randomValue, lockTime);
Thread survivalThread = new Thread(survivalClamProcessor);
survivalThread.setDaemon(Boolean.TRUE);
survivalThread.start();
Object returnObject = joinPoint.proceed(args);
survivalClamProcessor.stop();
survivalThread.interrupt();
return returnObject;

这段代码会先初始化守护线程的内部参数,然后通过start函数启动线程,最后在业务执行完之后,设置守护线程的关闭标记,最后通过interrupt()去中断sleep状态,保证线程及时销毁。

你可能感兴趣的:(分布式锁超时问题的处理(只是参考,推荐使用redission框架和ZK做分布式锁))