yyystar

概述

所谓分布式锁,就是在分布式网络环境中对本地锁机制的升级,制造分布式环境下的临界区。保证操作的原子性。 一句话概之就是保证多台服务器在执行某一段代码时保证只有一台服务器执行。
为什么需要分布式锁呢 ? 单机多线程环境是JVM锁就搞定了。但是现在的微服务架构是跨多进程的,需要保证进程级别的互斥性,所以需要分布式锁保证,既然是进程级别就需要依赖外部独立的系统。

不管是正常工作中,还是各大面试场景中都使用烂了,但是真要把这个东西彻底搞清楚还不是那么简单的,所以本地进行详细的阐述。

目前本人使用场景主要是, 在常用缓存读取不到再从DB读取数据,完了之后把结果set到缓存的时候,对于一些大事务DB操作负载很重,要是不限制,完全并发。则会导致DB负载过大,导致数据库连接数被打满,进而引起 RT 明显增大,进而出现各种稳定性问题。所以必须加以限制保证查询DB 和set 缓存的原子性,只有一个线程能操作成功,其余线程等待该线程操作完毕之后,直接从缓存读取即可。此举会大大提高业务稳定性和性能。

实现分布式锁需要考察的几点

完成分布式锁必须满足的条件核心三原则:

  • 互斥性。在任何时刻,保证只有一个客户端持有锁。
  • 不能出现死锁。如果在一个客户端持有锁的期间,这个客户端崩溃了,也要保证后续的其他客户端可以上锁。
  • 保证上锁和解锁都是同一个客户端。

一般分布式锁的手段如下:

  • MySQL 唯一索引 (行锁 for update )
  • 使用 ZooKeeper,基于临时有序节点。(节点序号最小的获取到锁)
  • 使用Redis,基于setnx命令。

MySQL 并发能力有限,所以不建议作为分布式锁解决方案。 ZooKeeper 并发能力业有限,在高并发时会有”惊群“ 效应,一般仅仅作为分布式协调元数据存储和 HA解决方案。 Redis是最适合分布式锁的 一来实现相对简单,二来 性能很佳。

所以本文主要讲解下 Redis实现的思路

Redis实现分布式锁主要利用Redis的setnx命令。setnx是SET if not exists(如果不存在,则 SET)的简写。
加锁:使用setnx key value命令,如果key不存在,设置value(加锁成功)。如果已经存在lock(也就是有客户端持有锁了),则设置失败(加锁失败)。
解锁:使用del命令,通过删除键值释放锁。释放锁之后,其他客户端可以通过setnx命令进行加锁。

因为每个分布式锁 对应不同的业务场景,所以 KEY 可以定义成不同的业务含义的KEY。比如 transaction_create_key。
value可以使用 UUID 保证唯一性,用于标识加锁的客户端,进而保证加锁和解锁都是同一个客户端。 此处是必须的,主要原因如下:
线程A 加锁成功之后执行业务操作,由于时间很长,导致锁已经过期删除了,中间线程B 有抢占了这个相同的锁,最后线程A 执行完业务之后再去释放锁,如果不进行 value 验证加锁人身份就会导致 线程A 把 线程B的锁给释放了。 具体流程图如下:

最常用的解决方案如下, 以下也是绝大数人面试甚至生产中使用的方案。

以上满足原则1 和原则3 (注意之后所谓分析都是围绕是否满足原则分析)此处类比数学证明题的先有定理,再分析问题的时候依照定理推理出新的定理。
原则 2 没法保证,线程A 加锁之后奔溃退出了,导致锁还没有释放;线程B则会一直申请不到锁进入循环等待,导致死锁。如下图所示:

因为锁必须要有超时时间,保证即使线程A 奔溃退出了,锁到期了依然可以释放掉,线程B 就能获取到锁了。这也是大部分人能想到的。 此时用到了 Redis的另外一个原子命令 jedis.set(key, requestId, "NX", "EX", expireTime);

问题一 超时时间设置

但是上述有个问题就是超时时间设置多大 ? 太小了没等业务执行完毕就把锁释放了,起不到独占的效果。太大了当线程A奔溃之后,锁一直不释放会增大 线程B的等待时间。 此处大部分人的解决方案是,设置一个默认值比如 5S , 因为大部分业务操作时间不会超过5S ,即使奔溃了,锁释放时间最大5S , 下一个线程申请到锁的最大时间5S的话是一个临界值。
这种方式大部分情况不会出现问题,但是不够严谨,网络抖动等服务执行时间是没法预估的。

以上可行解决方案如下:
给锁续期,在Redisson框架实现分布式锁的思路,就使用watchDog机制实现锁的续期。
当加锁成功后,同时开启守护线程,默认有效期是30秒,每隔10秒就会给锁续期到30秒,只要持有锁的客户端没有宕机,就能保证一直持有锁,直到业务代码执行完毕由客户端自己解锁,如果宕机了自然就在有效期失效后自动解锁。
线程A开启守护线程,相当于电子狗的概念,比如我设置过期时间是 5S , 电子狗可以每隔4S 就给锁续期到 5S;只要线程A存活在执行锁就一直不会释放。当线程A 主动释放锁或者 线程A奔溃的时候锁到期了,锁就释放了,线程B就能正确的获取到锁了保证临界区的原子性。
至于为啥线程A奔溃了,锁到期了就会释放,这块就用到了守护线程的特性,守护线程不会单独存在只会绑定业务线程,当业务线程结束了,守护线程就没有存在的必要了,也就不会继续续期了。

问题二 锁不可重入

以上方案还是有问题,就是锁不可重入,线程A只能加锁一次执行一次再释放锁
在Redisson实现可重入锁的思路,使用Redis的哈希表存储可重入次数,当加锁成功后,使用hset命令,value(重入次数)则是1。
KEYS[1] 就是加锁的key,比如 transaction_create_key
ARGV[2] 就是加锁的唯一值 ,requestID
ARGV[1] 就是对 transaction_create_key 进行过期时间设置
为了保证操作原子性,加锁和解锁操作都是使用lua脚本执行。

"if (redis.call('exists', KEYS[1]) == 0) then " +
"redis.call('hset', KEYS[1], ARGV[2], 1); " +
"redis.call('pexpire', KEYS[1], ARGV[1]); " +
"return nil; " +
"end; "

如果同一个客户端再次加锁成功,则使用hincrby自增加一。

"if (redis.call('hexists', KEYS[1], ARGV[2]) == 1) then " +
"redis.call('hincrby', KEYS[1], ARGV[2], 1); " +
"redis.call('pexpire', KEYS[1], ARGV[1]); " +
"return nil; " +
"end; " +
"return redis.call('pttl', KEYS[1]);" // 以MS为单位返回KEYS[1]的剩余过期时间 

解锁时,先判断可重复次数是否大于0,大于0则减一,否则删除键值,释放锁资源。
KEYS[1] 就是加锁的key,比如 transaction_create_key
ARGV[3] 就是加锁的唯一值 ,requestID
ARGV[2] 就是对 transaction_create_key 进行过期时间设置
HINCRBY 执行之后的返回值是 执行 HINCRBY 命令之后,哈希表中字段的值。


protected RFuture<Boolean> unlockInnerAsync(long threadId) {
    return commandExecutor.evalWriteAsync(getName(), LongCodec.INSTANCE, RedisCommands.EVAL_BOOLEAN,
"if (redis.call('hexists', KEYS[1], ARGV[3]) == 0) then " +
"return nil;" +
"end; " +
"local counter = redis.call('hincrby', KEYS[1], ARGV[3], -1); " +
"if (counter > 0) then " +
"redis.call('pexpire', KEYS[1], ARGV[2]); " +
"return 0; " +
"else " +
"redis.call('del', KEYS[1]); " +
"redis.call('publish', KEYS[2], ARGV[1]); " +
"return 1; "+
"end; " +
"return nil;",
Arrays.<Object>asList(getName(), getChannelName()), LockPubSub.UNLOCK_MESSAGE, internalLockLeaseTime, getLockName(threadId));
}

问题三

上面的加锁方法是加锁后立即返回加锁结果,如果加锁失败的情况下,总不可能一直轮询尝试加锁,直到加锁成功为止,这样太过耗费性能。所以需要利用发布订阅的机制进行优化。
具体步骤如下:
此处的堵塞队列可以使用 java 并发安全的堵塞队列,堵塞队列通知机制原理是 condition通知不会有线程的忙等待。

  • 当加锁失败后,订阅锁释放的消息,自身进入阻塞状态。
  • 当持有锁的客户端释放锁的时候,发布锁释放的消息。
  • 当进入阻塞等待的其他客户端收到锁释放的消息后,解除阻塞等待状态,再次尝试加锁。

{{uploading-image-747980.png(uploading...)}}

问题四

如果使用的是 Redis-cluster集群,则线程A先在master加锁成功之后, master挂了,此时还没同步到 slave,slave切换为 master,会导致 线程B在 新的master加锁成功, 也就是 线程A 线程B 同时认为都占有锁,若业务处理是幂等的没啥问题,但是在有些情况下会导致各种脏数据产生。

总结:
问题一 解决了死锁的问题,即使线程A奔溃了,锁超时也会被释放,线程B 也会得到锁。并且引入了守护线程解决了线程A还没执行完毕,锁就释放了,进而破坏了临界区操作原子性的问题。
问题二 解决了了可重复锁的问题,就是保证该段被分布式锁逻辑实现的代码,在相同的线程进入继续执行时还能继续执行。
问题三 解决了线程B获取不到锁 堵塞效率低的问题。

相关文章: