分布式锁tair redis zookeeper,安全性

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

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

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

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

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

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

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

使用setnx get getset实现分布式锁

多个进程执行如下Redis命令:c#

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

若是 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; } }

问题

其实还有问题

一、单点问题:加锁成功,可是尚未把数据同步到slave。忽然master挂了,某个slave变为master,其余节点加锁依旧成功。

可参考RedLock,加锁能够加锁多个节点,不只仅时master节点。可是这样性能又很差。没有银弹啊。

二、执行时间超过了锁的过时时间:上面写到为了避免出现一直上锁的状况,加了一个兜底的过时时间,时间到了锁自动释放,可是,若是在这期间任务并无作完怎么办?因为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出问题了。
相关文章
相关标签/搜索