分布式锁tair redis zookeeper,安全性

tair分布式锁实现:https://yq.aliyun.com/articles/58928

redis分布式锁:https://www.cnblogs.com/jianwei-dai/p/6137896.html

                          https://blog.csdn.net/abbc7758521/article/details/77990048

                  分布式锁之Redis实现(最终版)

redis、zookeeper分布式锁安全性讨论:https://blog.csdn.net/jackcaptain1015/article/details/71157004

http://mp.weixin.qq.com/s/JTsJCDuasgIJ0j95K8Ay8w      http://mp.weixin.qq.com/s/4CUe7OpM6y1kQRK8TOC_qQ

基于Redis实现分布式锁之前,这些坑你一定得知道(最终版的问题及解决方案)

使用setnx get getset实现分布式锁

多个进程执行以下Redis命令:

SETNX lock.foo <current Unix time + lock timeout + 1>

如果 SETNX 返回1,说明该进程获得锁,SETNX将键 lock.foo 的值设置为锁的超时时间(当前时间 + 锁的有效时间)。 
如果 SETNX 返回0,说明其他进程已经获得了锁,进程不能进入临界区。进程可以在一个循环中不断地尝试 SETNX 操作,以获得锁。

但是这个命令没有超时设置,得用expire命令设置超时时间。但是2个命令啊,不是原子操作了。

解决死锁

考虑一种情况,如果进程获得锁后,断开了与 Redis 的连接(可能是进程挂掉,或者网络中断),如果没有有效的释放锁的机制,那么其他进程都会处于一直等待的状态,即出现“死锁”。

上面在使用 SETNX 获得锁时,我们将键 lock.foo 的值设置为锁的有效时间,进程获得锁后,其他进程还会不断的检测锁是否已超时,如果超时,那么等待的进程也将有机会获得锁。

然而,锁超时时,我们不能简单地使用 DEL 命令删除键 lock.foo 以释放锁。考虑以下情况,进程P1已经首先获得了锁 lock.foo,然后进程P1挂掉了。进程P2,P3正在不断地检测锁是否已释放或者已超时,执行流程如下:

  • P2和P3进程读取键 lock.foo 的值,检测锁是否已超时(通过比较当前时间和键 lock.foo 的值来判断是否超时)
  • P2和P3进程发现锁 lock.foo 已超时
  • P2执行 DEL lock.foo命令
  • P2执行 SETNX lock.foo命令,并返回1,即P2获得锁
  • P3执行 DEL lock.foo命令将P2刚刚设置的键 lock.foo 删除(这步是由于P3刚才已检测到锁已超时)
  • P3执行 SETNX lock.foo命令,并返回1,即P3获得锁
  • P2和P3同时获得了锁

从上面的情况可以得知,在检测到锁超时后,进程不能直接简单地执行 DEL 删除键的操作以获得锁。

为了解决上述算法可能出现的多个进程同时获得锁的问题,我们再来看以下的算法。 
我们同样假设进程P1已经首先获得了锁 lock.foo,然后进程P1挂掉了。接下来的情况:

  • 进程P4执行 SETNX lock.foo 以尝试获取锁
  • 由于进程P1已获得了锁,所以P4执行 SETNX lock.foo 返回0,即获取锁失败
  • P4执行 GET lock.foo 来检测锁是否已超时,如果没超时,则等待一段时间,再次检测
  • 如果P4检测到锁已超时,即当前的时间大于键 lock.foo 的值,P4会执行以下操作 
    GETSET lock.foo <current Unix timestamp + lock timeout + 1>
  • 由于 GETSET 操作在设置键的值的同时,还会返回键的旧值,通过比较键 lock.foo 的旧值是否小于当前时间,可以判断进程是否已获得锁
  • 假如另一个进程P5也检测到锁已超时,并在P4之前执行了 GETSET 操作,那么P4的 GETSET 操作返回的是一个大于当前时间的时间戳,这样P4就不会获得锁而继续等待。注意到,即使P4接下来将键 lock.foo 的值设置了比P5设置的更大的值也没影响。

另外,值得注意的是,在进程释放锁,即执行 DEL lock.foo 操作前,需要先判断锁是否已超时。如果锁已超时,那么锁可能已由其他进程获得,这时直接执行 DEL lock.foo 操作会导致把其他进程已获得的锁释放掉,所以不需要再对锁进行处理。

public static boolean acquireLock(String lock) {
    // 1. 通过SETNX试图获取一个lock
    boolean success = false;
    Jedis jedis = pool.getResource(); 
    long value = System.currentTimeMillis() + expired + 1; 
    System.out.println(value); 
    long acquired = jedis.setnx(lock, String.valueOf(value));
    //SETNX成功,则成功获取一个锁
    if (acquired == 1) 
        success = true;
        //SETNX失败,说明锁仍然被其他对象保持,检查其是否已经超时
    else {
        long oldValue = Long.valueOf(jedis.get(lock));

    //超时
    if (oldValue < System.currentTimeMillis()) {
        String getValue = jedis.getSet(lock, String.valueOf(value)); 
        // 获取锁成功
        if (Long.valueOf(getValue) == oldValue) 
          success = true;
        // 已被其他进程捷足先登了
      else 
          success = false;
    }
    //未超时,则直接返回失败
    else 
        success = false;
    } 
    pool.returnResource(jedis);
    return success; 
}                               

最终版redis锁

加锁

public class Redis {
    private static final String LOCK_SUCCESS = "OK";
    private static final String SET_IF_NOT_EXIST = "NX";
    private static final String SET_WITH_EXPIRE_TIME = "PX";

    /**
     * 尝试获取分布式锁
     * @param jedis Redis客户端
     * @param lockKey 锁
     * @param requestId 客户端标识
     * @param expireTime 超期时间
     * @return 是否获取成功
     */
    public static boolean tryGetDistributedLock(Jedis jedis, String lockKey, String requestId, int expireTime) {

        String result = jedis.set(lockKey, requestId, SET_IF_NOT_EXIST, SET_WITH_EXPIRE_TIME, expireTime);

        if (LOCK_SUCCESS.equals(result)) {
            return true;
        }
        return false;

    }
}

可以看到,我们加锁就一行代码:jedis.set(String key, String value, String nxxx, String expx, int time),这个set()方法一共有五个形参:

  • 第一个为key,我们使用key来当锁,因为key是唯一的。
  • 第二个为value,我们传的是requestId,requestId是客户端的唯一标志。
  • 第三个为nxxx,这个参数我们填的是NX,意思是SET IF NOT EXIST,即当key不存在时,我们进行set操作;若key已经存在,则不做任何操作;
  • 第四个为expx,这个参数我们传的是PX,意思是我们要给这个key加一个过期的设置,具体时间由第五个参数决定。
  • 第五个为time,与第四个参数相呼应,代表key的过期时间。

可以看到上面的set()方法,通过requestId解决了分布式下不同客户端时间不统一问题,通过超期时间解决了多次getset覆盖问题,通过解锁时判断requestId解决了任何客户端都可以解锁问题。

解锁

使用LUA,将get、del操作原子化。因为有这样的场景:

比如客户端A加锁,一段时间之后客户端A解锁,在执行jedis.del()之前,锁突然过期了,此时客户端B尝试加锁成功,然后客户端A再执行del()方法,则将客户端B的锁给解除了。

public class RedisTool {
    private static final Long RELEASE_SUCCESS = 1L;

    /**
     * 释放分布式锁
     * @param jedis Redis客户端
     * @param lockKey 锁
     * @param requestId 请求标识
     * @return 是否释放成功
     */
    public static boolean releaseDistributedLock(Jedis jedis, String lockKey, String requestId) {

        String script = "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end";
        Object result = jedis.eval(script, Collections.singletonList(lockKey), Collections.singletonList(requestId));

        if (RELEASE_SUCCESS.equals(result)) {
            return true;
        }
        return false;

    }
}

问题

其实还有问题

1、单点问题:加锁成功,但是还没有把数据同步到slave。突然master挂了,某个slave变为master,其他节点加锁依旧成功。

可参考RedLock,加锁可以加锁多个节点,不仅仅时master节点。但是这样性能又不好。没有银弹啊。

2、执行时间超过了锁的过期时间:上面写到为了不出现一直上锁的情况,加了一个兜底的过期时间,时间到了锁自动释放,但是,如果在这期间任务并没有做完怎么办?由于GC或者网络延迟导致的任务时间变长,很难保证任务一定能在锁的过期时间内完成。

深入思考点

Redlock算法

对于第一个单点问题,顺着redis的思路,接下来想到的肯定是Redlock了。Redlock为了解决单机的问题,需要多个(大于2)redis的master节点,多个master节点互相独立,没有数据同步。

Redlock的实现如下:

  1. 获取当前时间。
  2. 依次获取N个节点的锁。 每个节点加锁的实现方式同上。这里有个细节,就是每次获取锁的时候的过期时间都不同,需要减去之前获取锁的操作的耗时,
  • 比如传入的锁的过期时间为500ms,
  • 获取第一个节点的锁花了1ms,那么第一个节点的锁的过期时间就是499ms,
  • 获取第二个节点的锁花了2ms,那么第二个节点的锁的过期时间就是497ms
  • 如果锁的过期时间小于等于0了,说明整个获取锁的操作超时了,整个操作失败
  1. 判断是否获取锁成功。 如果client在上述步骤中获取到了(N/2 + 1)个节点锁,并且每个锁的过期时间都是大于0的,则获取锁成功,否则失败。失败时释放锁。
  2. 释放锁。 对所有节点发送释放锁的指令,每个节点的实现逻辑和上面的简单实现一样。为什么要对所有节点操作?因为分布式场景下从一个节点获取锁失败不代表在那个节点上加速失败,可能实际上加锁已经成功了,但是返回时因为网络抖动超时了。

以上就是大家常见的redlock实现的描述了,一眼看上去就是简单版本的多master版本,如果真是这样就太简单了,接下来分析下这个算法在各个场景下是怎样被玩坏的。

高并发场景下的问题

以下问题不是说在并发不高的场景下不容易出现,只是在高并发场景下出现的概率更高些而已。 性能问题。 性能问题来自于两个方面。

  1. 获取锁的时间上。如果redlock运用在高并发的场景下,存在N个master节点,一个一个去请求,耗时会比较长,从而影响性能。这个好解决。通过上面描述不难发现,从多个节点获取锁的操作并不是一个同步操作,可以是异步操作,这样可以多个节点同时获取。即使是并行处理的,还是得预估好获取锁的时间,保证锁的TTL > 获取锁的时间+任务处理时间。
  2. 被加锁的资源太大。加锁的方案本身就是会为了正确性而牺牲并发的,牺牲和资源大小成正比。这个时候可以考虑对资源做拆分,拆分的方式有两种:
  3. 从业务上将锁住的资源拆分成多段,每段分开加锁。比如,我要对一个商户做若干个操作,操作前要锁住这个商户,这时我可以将若干个操作拆成多个独立的步骤分开加锁,提高并发。
  4. 用分桶的思想,将一个资源拆分成多个桶,一个加锁失败立即尝试下一个。比如批量任务处理的场景,要处理200w个商户的任务,为了提高处理速度,用多个线程,每个线程取100个商户处理,就得给这100个商户加锁,如果不加处理,很难保证同一时刻两个线程加锁的商户没有重叠,这时可以按一个维度,比如某个标签,对商户进行分桶,然后一个任务处理一个分桶,处理完这个分桶再处理下一个分桶,减少竞争。

重试的问题。 无论是简单实现还是redlock实现,都会有重试的逻辑。如果直接按上面的算法实现,是会存在多个client几乎在同一时刻获取同一个锁,然后每个client都锁住了部分节点,但是没有一个client获取大多数节点的情况。解决的方案也很常见,在重试的时候让多个节点错开,错开的方式就是在重试时间中加一个随机时间。这样并不能根治这个问题,但是可以有效缓解问题,亲试有效。

节点宕机

对于单master节点且没有做持久化的场景,宕机就挂了,这个就必须在实现上支持重复操作,自己做好幂等。

对于多master的场景,比如redlock,我们来看这样一个场景:

  1. 假设有5个redis的节点:A、B、C、D、E,没有做持久化。
  2. client1从A、B、C 3个节点获取锁成功,那么client1获取锁成功。
  3. 节点C挂了。
  4. client2从C、D、E获取锁成功,client2也获取锁成功,那么在同一时刻client1和client2同时获取锁,redlock被玩坏了。

怎么解决呢?最容易想到的方案是打开持久化。持久化可以做到持久化每一条redis命令,但这对性能影响会很大,一般不会采用,如果不采用这种方式,在节点挂的时候肯定会损失小部分的数据,可能我们的锁就在其中。 另一个方案是延迟启动。就是一个节点挂了修复后,不立即加入,而是等待一段时间再加入,等待时间要大于宕机那一刻所有锁的最大TTL。 但这个方案依然不能解决问题,如果在上述步骤3中B和C都挂了呢,那么只剩A、D、E三个节点,从D和E获取锁成功就可以了,还是会出问题。那么只能增加master节点的总量,缓解这个问题了。增加master节点会提高稳定性,但是也增加了成本,需要在两者之间权衡。

任务执行时间超过锁的TTL

之前产线上出现过因为网络延迟导致任务的执行时间远超预期,锁过期,被多个线程执行的情况。 这个问题是所有分布式锁都要面临的问题,包括基于zookeeper和DB实现的分布式锁,这是锁过期了和client不知道锁过期了之间的矛盾。 在加锁的时候,我们一般都会给一个锁的TTL,这是为了防止加锁后client宕机,锁无法被释放的问题。但是所有这种姿势的用法都会面临同一个问题,就是没发保证client的执行时间一定小于锁的TTL。虽然大多数程序员都会乐观的认为这种情况不可能发生,我也曾经这么认为,直到被现实一次又一次的打脸。
示例流程:
  1. Client1获取到锁
  2. Client1开始任务,然后发生了STW的GC,时间超过了锁的过期时间
  3. Client2 获取到锁,开始了任务
  4. Client1的GC结束,继续任务,这个时候Client1和Client2都认为自己获取了锁,都会处理任务,从而发生错误。

Martin Kleppmann举的是GC的例子,我碰到的是网络延迟的情况。不管是哪种情况,不可否认的是这种情况无法避免,一旦出现很容易懵逼。

如何解决呢?一种解决方案是不设置TTL,而是在获取锁成功后,给锁加一个watchdog,watchdog会起一个定时任务,在锁没有被释放且快要过期的时候会续期。这样说有些抽象,下面结合redisson源码说下:

 public class RedissonLock extends RedissonExpirable implements RLock {
    ...
    @Override
    public void lock() {
        try {
            lockInterruptibly();
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
        }
    }

    @Override
    public void lock(long leaseTime, TimeUnit unit) {
        try {
            lockInterruptibly(leaseTime, unit);
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
        }
    }
    ...
 }
redisson
redisson常用的加锁api是上面两个,一个是不传入TTL,这时是redisson自己维护,会主动续期;另外一种是自己传入TTL,这种redisson就不会帮我们自动续期了,或者自己将leaseTime的值传成-1,但是不建议这种方式,既然已经有现成的API了,何必还要用这种奇怪的写法呢。 接下来分析下不传参的方法的加锁逻辑:
public class RedissonLock extends RedissonExpirable implements RLock {

    ...
        
    public static final long LOCK_EXPIRATION_INTERVAL_SECONDS = 30;
    protected long internalLockLeaseTime = TimeUnit.SECONDS.toMillis(LOCK_EXPIRATION_INTERVAL_SECONDS);

        
    @Override
    public void lock() {
        try {
            lockInterruptibly();
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
        }
    }
        
    @Override
    public void lockInterruptibly() throws InterruptedException {
        lockInterruptibly(-1, null);
    }
    
    @Override
    public void lockInterruptibly(long leaseTime, TimeUnit unit) throws InterruptedException {
        long threadId = Thread.currentThread().getId();
        Long ttl = tryAcquire(leaseTime, unit, threadId);
        // lock acquired
        if (ttl == null) {
            return;
        }

        RFuture<RedissonLockEntry> future = subscribe(threadId);
        commandExecutor.syncSubscription(future);

        try {
            while (true) {
                ttl = tryAcquire(leaseTime, unit, threadId);
                // lock acquired
                if (ttl == null) {
                    break;
                }

                // waiting for message
                if (ttl >= 0) {
                    getEntry(threadId).getLatch().tryAcquire(ttl, TimeUnit.MILLISECONDS);
                } else {
                    getEntry(threadId).getLatch().acquire();
                }
            }
        } finally {
            unsubscribe(future, threadId);
        }
//        get(lockAsync(leaseTime, unit));
    }

    private Long tryAcquire(long leaseTime, TimeUnit unit, long threadId) {
        return get(tryAcquireAsync(leaseTime, unit, threadId));
    }

    private <T> RFuture<Long> tryAcquireAsync(long leaseTime, TimeUnit unit, final long threadId) {
        if (leaseTime != -1) {
            return tryLockInnerAsync(leaseTime, unit, threadId, RedisCommands.EVAL_LONG);
        }
        RFuture<Long> ttlRemainingFuture = tryLockInnerAsync(LOCK_EXPIRATION_INTERVAL_SECONDS, TimeUnit.SECONDS, threadId, RedisCommands.EVAL_LONG);
        ttlRemainingFuture.addListener(new FutureListener<Long>() {
            @Override
            public void operationComplete(Future<Long> future) throws Exception {
                if (!future.isSuccess()) {
                    return;
                }

                Long ttlRemaining = future.getNow();
                // lock acquired
                if (ttlRemaining == null) {
                    scheduleExpirationRenewal(threadId);
                }
            }
        });
        return ttlRemainingFuture;
    }

    private void scheduleExpirationRenewal(final long threadId) {
        if (expirationRenewalMap.containsKey(getEntryName())) {
            return;
        }

        Timeout task = commandExecutor.getConnectionManager().newTimeout(new TimerTask() {
            @Override
            public void run(Timeout timeout) throws Exception {
                
                RFuture<Boolean> future = commandExecutor.evalWriteAsync(getName(), LongCodec.INSTANCE, RedisCommands.EVAL_BOOLEAN,
                        "if (redis.call('hexists', KEYS[1], ARGV[2]) == 1) then " +
                            "redis.call('pexpire', KEYS[1], ARGV[1]); " +
                            "return 1; " +
                        "end; " +
                        "return 0;",
                          Collections.<Object>singletonList(getName()), internalLockLeaseTime, getLockName(threadId));
                
                future.addListener(new FutureListener<Boolean>() {
                    @Override
                    public void operationComplete(Future<Boolean> future) throws Exception {
                        expirationRenewalMap.remove(getEntryName());
                        if (!future.isSuccess()) {
                            log.error("Can't update lock " + getName() + " expiration", future.cause());
                            return;
                        }
                        
                        if (future.getNow()) {
                            // reschedule itself
                            scheduleExpirationRenewal(threadId);
                        }
                    }
                });
            }
        }, internalLockLeaseTime / 3, TimeUnit.MILLISECONDS);

        if (expirationRenewalMap.putIfAbsent(getEntryName(), task) != null) {
            task.cancel();
        }
    }

    
    ...
}
具体加锁逻辑

可以看到,最后加锁的逻辑会进入到org.redisson.RedissonLock#tryAcquireAsync中,在获取锁成功后,会进入scheduleExpirationRenewal,这里面初始化了一个定时器,dely的时间是internalLockLeaseTime / 3。在redisson中,internalLockLeaseTime是30s,也就是每隔10s续期一次,每次30s。 如果是基于zookeeper实现的分布式锁,可以利用zookeeper检查节点是否存活,从而实现续期,zookeeper分布式锁没用过,不详细说。

不过这种做法也无法百分百做到同一时刻只有一个client获取到锁,如果续期失败,比如发生了Martin Kleppmann所说的STW的GC,或者client和redis集群失联了,只要续期失败,就会造成同一时刻有多个client获得锁了。在我的场景下,我将锁的粒度拆小了,redisson的续期机制已经够用了。 如果要做得更严格,得加一个续期失败终止任务的逻辑。这种做法在以前Python的代码中实现过,Java还没有碰到这么严格的情况。

这里也提下Martin Kleppmann的解决方案,我自己觉得这个方案并不靠谱,原因后面会提到。 他的方案是让加锁的资源自己维护一套保证不会因加锁失败而导致多个client在同一时刻访问同一个资源的情况。

在客户端获取锁的同时,也获取到一个资源的token,这个token是单调递增的,每次在写资源时,都检查当前的token是否是较老的token,如果是就不让写。对于上面的场景,Client1获取锁的同时分配一个33的token,Client2获取锁的时候分配一个34的token,在client1 GC期间,Client2已经写了资源,这时最大的token就是34了,client1 从GC中回来,再带着33的token写资源时,会因为token过期被拒绝。这种做法需要资源那一边提供一个token生成器。 对于这种fencing的方案,我有几点问题:
  1. 无法保证事务。示意图中画的只有34访问了storage,但是在实际场景中,可能出现在一个任务内多次访问storage的情况,而且必须是原子的。如果client1带着33token在GC前访问过一次storage,然后发生了GC。client2获取到锁,带着34的token也访问了storage,这时两个client写入的数据是否还能保证数据正确?如果不能,那么这种方案就有缺陷,除非storage自己有其他机制可以保证,比如事务机制;如果能,那么这里的token就是多余的,fencing的方案就是多此一举。
  2. 高并发场景不实用。因为每次只有最大的token能写,这样storage的访问就是线性的,在高并发场景下,这种方式会极大的限制吞吐量,而分布式锁也大多是在这种场景下用的,很矛盾的设计。
  3. 这是所有分布式锁的问题。这个方案是一个通用的方案,可以和Redlock用,也可以和其他的lock用。所以我理解仅仅是一个和Redlock无关的解决方案。

系统时钟漂移

这个问题只是考虑过,但在实际项目中并没有碰到过,因为理论上是可能出现的,这里也说下。 redis的过期时间是依赖系统时钟的,如果时钟漂移过大时会影响到过期时间的计算。

为什么系统时钟会存在漂移呢?先简单说下系统时间,linux提供了两个系统时间:clock realtime和clock monotonic。clock realtime也就是xtime/wall time,这个时间时可以被用户改变的,被NTP改变,gettimeofday拿的就是这个时间,redis的过期计算用的也是这个时间。 clock monotonic ,直译过来时单调时间,不会被用户改变,但是会被NTP改变。

最理想的情况时,所有系统的时钟都时时刻刻和NTP服务器保持同步,但这显然时不可能的。导致系统时钟漂移的原因有两个:

  1. 系统的时钟和NTP服务器不同步。这个目前没有特别好的解决方案,只能相信运维同学了。
  2. clock realtime被人为修改。在实现分布式锁时,不要使用clock realtime。不过很可惜,redis使用的就是这个时间,我看了下Redis 5.0源码,使用的还是clock realtime。Antirez说过改成clock monotonic的,不过大佬还没有改。也就是说,人为修改redis服务器的时间,就能让redis出问题了。
原文地址:https://www.cnblogs.com/fanguangdexiaoyuer/p/9443976.html