深入理解分布式锁

一、锁概述

1.锁分类

深入理解分布式锁_第1张图片

2.悲观锁和乐观锁

悲观锁原理、使用场景和常见实现

原理:认为数据一定会有冲突则需要先锁资源,再处理业务同时阻塞其他线程访问资源
使用场景:符合所有的 多线程竞争共享资源 的业务场景 ,如 同金额相关的业务
实现: 常见实现有 Synchronized、ReentrantLock、分布式锁 等

悲观锁常见问题?

  1. 线程阻塞 会影响系统整体的吞吐量;
  2. 连接池耗尽 在大流量下由于线程阻塞会让Tomcat连接池打满,导致服务雪崩;
  3. 内存溢出(OOM)在大流量下由于线程堆积(主线程约占1M内存,子线程约占512kb内存)而导致的内存溢出。

乐观锁原理、使用场景和常见实现

原理:认为数据一般情况下不会造成冲突,所以在数据进行提交更新的时候,才会正式对数据的冲突与否进行检测,如果发现冲突了,则让返回用户错误的信息,让用户决定如何去做(常见有抛错或重试)
使用场景: 线程竞争不激烈情况(即没有并发量)下去访问共享资源 (使用场景相对较少,如 库存兜底、自旋重试等
实现: 常见实现有CAS、MVCC

乐观锁常见问题?

  1. 自旋 会不停进行线程的上下文切换,从而导致CPU飙高(重试机制);
  2. 大批量失败 影响客户体验,高频率抛错会导致系统崩溃(抛错机制);
  3. DB崩溃 如果是DB实现乐观锁在大流量下会直接击垮DB。

乐观锁和悲观锁区别?

锁资源:悲观锁会先锁资源,而乐观锁不会锁资源而是在提交时才会对冲突做校验;
锁开销:在线程竞争不激烈时,乐观锁开销更小,性能更好;在线程竞争激烈时,悲观锁相对开销小,性能好
阻塞:悲观锁会阻塞其他线程;乐观锁不会阻塞其他线程。

二、分布式锁概述

1.分布式锁介绍

什么是线程锁?

在同一个JVM中线程是通过JMM规范来实现在共享内存中的通信,为了解决多线程的线程安全问题,则需要使锁来锁住共享资源来确保共享资源安全,这个锁就是线程锁。

为什么要用分布式锁?

常见线程锁( synchronized和Reenlock)只有在同一JVM中才有效果,因为线程锁的实现在根本上是依靠线程之间共享内存实现的,比如synchronized是共享对象头,显示锁Lock是共享某个变量(state)。
则在分布式环境,线程去竞争共享资源时这些线程可以是分布在多个节点上即不同的JVM下,则线程锁失效,此时就需要分布式锁来解决分布式环境下资源安全问题。

2. 分布式锁具备条件

  • 互斥性:在任意时刻,只有一个客户端(进程)能持有锁
  • 安全性:避免死锁情况,当一个客户端在持有锁期间内,由于意外崩溃而导致锁未能主动解锁,其持有的锁也能够被正确释放,并保证后续其它客户端也能加锁
  • 可用性:分布式锁需要有一定的高可用能力,当提供锁的服务节点故障(宕机)时不影响服务运行,避免单点风险,如Redis的哨兵模式,ETCD/zookeeper的集群选主能力等保证HA,保证自身持有的数据与故障节点一致。
  • 对称性:对同一个锁,加锁和解锁必须是同一个进程,即不能把其他进程持有的锁给释放了,这又称为锁的可重入性。

3. 分布式锁常见实现方式

  1. DB实现分布式锁

强依赖于数据库,性能瓶颈上限非常低(等同于DB性能 ),流量稍大就会打垮数据库,只适用于小流量且没有其他中间件资源时,否则不推荐此方案(具体实现本篇不做介绍)。

  1. redis实现分布式锁

redis主流有2种实现方式一种是基于客户端RedisTemplate实现,还有一种是客户端redission实现,由于redis超高性能优势,是互联网实现分布式锁的主流。

  1. zookeeper实现分布式锁

zk的客户端Curator也有现成的轮子,且zk在分布式环境下有超高的可靠性,是金融领域实现分布式锁的主流,但是目前对于可靠性高场景,会优先考虑ETCD(本篇先不做介绍)。

三、zookeeper实现分布式锁

zookeeper是一个开源的分布式应用程序协调服务,可以把它看做为支持事件监听的文件服务

1. 实现分布式锁原理

先给结论zookeeper 就是基于临时顺序节点以及 Watcher 监听器机制实现分布式锁,具体的细节如下:

  1. 顺序临时节点特性;

在每一个节点下面创建临时顺序节点(EPHEMERAL_SEQUENTIAL)类型,新的子节点后面会加上一个次序编号,而这个生成的次序编号是上一个生成的次序编号加一。

  1. 顺序公平锁;

每个线程在尝试占用锁之前,首先判断自己是序号是不是当前最小,如果是则获取锁。

  1. 节点监听机制;

ZooKeeper 这种首尾相接、后面监听前面的方式,可以保障占有锁的传递有序而且高效,并且可以解决羊群效应。

深入理解分布式锁_第2张图片

2. 相关问题

  1. 成本较高,由于ZK是CP架构,目前使用注册中心主流都是AP架构(如nacos),需要额外搭建并管理zk集群;
  2. 性能问题,zk每次在创建锁和释放锁的过程中,都要动态创建、销毁瞬时节点来实现锁功能,由于zk是CP架构保证是一致性,ZK中创建和删除节点只能通过Leader服务器来执行,然后Leader服务器还需要将数据同步到所有的Follower机器上,大多数Follower都返回ACK之后才会创建或删除成功;
  3. GC停顿问题,当客户端有较长时间GC停顿时,zk的探活会认定其已死亡,主动断开和客户端连接,此时临时顺序节点也会删除,则锁失效了。

3. 小结

zk在高并发下创建和删除节点性能非常差,则不适合于互联网高并发场景下,但是它的CP架构和临时顺序节点特性能够保证高可靠(至少比redis实现分布式锁的可靠性高),综合以上则它更适合于对可靠性要求高且流量小业务场景。
但是由于成本高和性能差现在基本上在技术选型上抛弃zk了,取代zk的是ETCD,性能至少是zk 10+倍,在k8s作为核心组件已经经历过各种大流量的洗礼,则对于高可靠的业务场景ETCD是主流了。

四、Redistemplate实现分布式锁

详细介绍 redistemplate和redission实现分布式锁细节

1. Redistemplate简介

redistemplate是spring框架对jedis和lettuce的封装,且 redis2.6后 SET指令已经支持nx/px/expire,实现加锁非常简单且保证了加锁的原子性(redis 2.6之前加锁过程需要分2步,在客户端不能保证原子性)。

2. 加锁过程

SET(key,value,NX,PX,time)
key 就是要锁定的资源,和NX一起来保证锁的互斥性;
value 可以是客户端唯一标识,保证锁的可重入性;
NX代表只在键不存在时,才对键进行设置操作,保证了锁的互斥性;
PX 表示对这个key要设置过期时间,保证了锁的安全性,避免异常情况下死锁问题;
time 表示key过期的具体时间,可以根据实际业务配置合适时间。

3. 解锁过程

解锁时可重入性问题

解锁时要保证锁的可重入性,即只有当前持有锁的客户端才能够解锁,否则会在特殊情况下导致失效,如 线程v1拿到了锁,此时v1锁刚好过期正在进行删除锁的操作,同时v2刚好拿到锁,如果不校验当前锁的客户端则会出现v1误删了v2的锁,导致锁失效。

解锁时原子性问题

上面谈到了解锁时的可重入性问题需要校验锁持有者,则 需要保证 校验锁持有者和删除锁 这个2个动作要保证原子性,否则也会出现误删锁从而导致锁失效问题。
如果在redis客户端实现则如下:

//查询到当前锁的客户端标识
String v = redistemplate.get(key);
//判断客户端是否一致
if(requestId.equals(v)){
  	redistemplate.delete(key);
}
// 以上存在一个问题,这个2个操作不能保证原子性,会存在在查询时当前线程所持锁存在,
但是在删除时自己所持锁过期了,则也会出现误删问题,即在redis客户端不能保证以上2个动作的原子性

基于以上分析,解锁的原子性只能在redis服务端实现,redis支持lua基本,它可以保证在redis中多个操作的原子性(严格意思上来说 是隔离性,lua不支持回滚),具体如下:

----lua脚本实现------
if redis.call('get',KEYS[1]) == ARGV[1] then 
   return redis.call('del',KEYS[1]) 
else
   return 0 
end

4. 相关问题

锁过期时间评估问题

这里有2种方案:

  1. 保守策略,即锁过期时间略大于业务接口的熔断超时时间;
  2. 压测策略,可以通过大量压测数据来评估一个可行性的过期时间+GC停顿时间。

其中方案2适合于有较强的测试团队,如果测试团队受限建议选取方案1。

高可用问题

单机redis分布式锁存在单机故障问题,这个可以使用哨兵集群来解决单点故障问题,但是哨兵集群在极端情况下(master异常时key没有同步到slave节点,发生了主从切换导致数据丢失)会导致锁失效,但是这种情况概率非常小。做为锁的数据类型是string ,key-value都非常小理论上同步时间是微妙级别。

5. 小结

成本较低,只需要单机redis或哨兵redis集群都可以,且redis几乎是现在项目的标配中间件不会额外增加成本;
性能较高,相对于zk和redission实现分布式锁,这种方式的性能是最高的(单机redis都号称可以抗住10wQPS)。
适用场景,适合于所有中小型互联网项目,也是个人比较推荐的一种方式(具体原因下面会详细说明)。

五、Redission实现分布式锁

1. Redission介绍

Reddissin 它提供的功能远远超出了一个 Redis 客户端的范畴,使用它来替换默认的 Lettuce。在可以使用基本 Redis 功能的同时,也能使用它提供的一些高级服务:

  • 远程调用
  • 分布式锁
  • 分布式对象、容器

也是redis官方推荐的客户端。

redission支持的分布式锁

Redisson还实现多种业务场景下的分布式锁,包括 可重入锁(ReentrantLock),多锁(MultiLock),读写锁(ReadWriteLock),公平锁(Fair Lock),红锁(RedLock),信号量(Semaphore),可过期性信号量(PermitExpirableSemaphore)和闭锁(CountDownLatch)这些实际当中对多线程高并发应用至关重要的基本部件.

2. Redission实现分布式锁原理

深入理解分布式锁_第3张图片

看门狗机制自动续期机制

Redisson中客户端v1一旦加锁成功,就会启动一个watch dog看门狗,他是一个后台线程(守护线程),会每隔10秒检查一下,如果客户端v1还持有锁key,那么就会不断的延长锁key的生存时间(默认30s)。

RedLock官方推荐使用的前提

  1. 官方推荐使用5台redis单机实例部署(不能使用哨兵、主从这种集群);
  2. 由于红锁的高可用是使用一致性算法(类似于zab的多数投票),则推荐使用奇数台;
  3. 推荐多台redis实例都部署在同一内网中,且时钟保持一致(时钟漂移会导致一致性算法失效)。

则如果单机redis去使用红锁则会破坏红锁高可用的设计。

RedLock使用示例

//官网demo如下
//------------------------------------------------------------
//redis服务 1 
RLock lock1 = redissonInstance1.getLock("lock1");
//redis服务 2
RLock lock2 = redissonInstance2.getLock("lock2");
//redis服务 3
RLock lock3 = redissonInstance3.getLock("lock3");

RedissonRedLock lock = new RedissonRedLock(lock1, lock2, lock3);
// 同时加锁:lock1 lock2 lock3
// 红锁在大部分节点上加锁成功就算成功。
lock.lock();
try{
	//处理业务
} catch (Exception e) {
    e.printStackTrace();
} finally {
    lock.unlock();
} 

知识扩展点:大佬对RedLock可靠性的互撕

2位国际大佬对于RedLock是否安全问题从NPC(网络异常、GC系统停顿、时钟漂移)方面进行了激烈讨论:
Martin Kleppmann(《数据密集型应用系统设计》作者,国际资深分布式问题解决专家) 质疑帖子:https://martin.kleppmann.com/2016/02/08/how-to-do-distributed-locking.html
Antirez(Redis开源的作者,国际资深分布式问题解决专家) 反击帖子:
http://antirez.com/news/101
强烈推荐去看看大佬是怎么考虑分布式问题的方向和思路。

3. Redission加锁实现

redission锁的实现在客户端通过lua脚本实现的,具体实现如下源码:

RLock lock = redisson.getLock("myLock");
-------------------------------------------------------------

//判断key不存在 则直接创建一个hash类型数据 过期时间为30s 并返回
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;
//判断客户端是重入,则值自增+1 且 自动续期30s
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]);


//字段解释
KEYS[1]:表示你加锁的那个key,代指要加锁的资源;
ARGV[1]:表示锁的有效期,默认30s;
ARGV[2]:表示表示加锁的客户端ID 线程id。
HASH数据结构 :
 myLock:
    {
        6c719a6b4586:1 1
    }
其中 myLock为资源简称;6c719a6b4586为客户端ID

4. Redission解锁实现

解锁时lua的源码:

lock.unlock();
----------------------------------------------------
//key 不存在则直接 返回  -- 访问资源不对 无法解锁
if (redis.call('exists', KEYS[1]) == 0) then
       redis.call('publish', KEYS[2], ARGV[1]);
        return 1; 
        end;
//key 和 客户端ID 查询都不存在 则返回  -- 访问资源对 但是客户端ID不对  无法解锁
if (redis.call('hexists', KEYS[1], ARGV[3]) == 0) then 
     return nil;
     end;
//退出时则直接递减 -1 (同 java ReentrantLock逻辑一致)
local counter = redis.call('hincrby', KEYS[1], ARGV[3], -1); 
//如果次数>0 表示还持有锁 
if (counter > 0) then
     redis.call('pexpire', KEYS[1], ARGV[2]); 
     return 0; 
//如果次数等于0 则删除key
else redis.call('del', KEYS[1]); 
     redis.call('publish', KEYS[2], ARGV[1]); 
     return 1;
     end;
return nil;

5. 小结

redission客户端的分布式锁其实是对redistemplate客户端实现分布式锁的补充,它解决是后者过期时间不好评估和可靠性这2个问题:

  1. 看门狗自动续期机制解决了过期时间不好评估的问题;
  2. 多节点冗余部署+一致性算法 解决了可靠性问题。

由于redLock设计时兼顾了可靠性,则代价就是牺牲部分的性能(一致性算法-投票)。

六、分布式锁总结

1. 多维度对比

基于以上对于分布式锁学习来做一下总结(方便后续遇到相关业务的技术选型):
  1. 性能: jedis分布式锁 > RedLock > ZK 分布式锁
  2. 安全: jedis分布式锁 < RedLock < ZK 分布式锁
  3. 成本: jedis分布式锁 < ZK 分布式锁 =< RedLock

2. 推荐使用jedis分布式锁的原因

基于以上3种分布式锁实现方式,在互联网领域我比较推荐选择使用的是jedis分布式锁,理由如下:

不推荐使用ZK分布式锁原因

ZK分布式锁 成本高、性能差,而且还会增加相应的架构复杂性和运维成本(目前国内中小型项目使用ZK相对较少),而且对于可靠性要求比较高场景现在也会优化考虑ETCD。
事实上ZK现在已经慢慢掉队了。

不推荐使用RedLock原因

redission分布式锁,看起来兼顾了高性能和高可靠,但是使用成本非常高(5台单机redis),对于主从集群或哨兵集群的redis只能使用master节点,而且也没有解决分布式NPC的三座大山问题。
单机版的RedLock舍弃高可靠的设计,可靠性同单机版jedis分布式锁一致都存在单点故障问题,且不如哨兵集群下的jedis分布式锁的可靠性。
关于它自动续期(默认30s,每10s探活续期),但是在互联网应用中接口普遍最大超时时间不会超过6s,则这个设计在实际应用中有些鸡肋,和jedis分布式锁的保守策略没有太大区别。
RedLock的自动续期功能,实际上全靠客户端的守护线程,这个守护线程是有可能被业务系统破坏的可能,则自动续期功能也没有那么可靠。

推荐使用jedis分布式锁的原因

jedis分布式锁从上面对比可以看出,除了安全方面外在性能和成本上远超其他2种方式,那么在实际生产环境中jedis分布式锁是否真的那么不安全呢?下面会简单分析:
一般中小型项目,项目初期可能由于成本预算可能只会使用单机redis,这时使用jedis分布式锁和RedLock都会存在单点故障问题,但是jedis性能更好。
随着项目上线,用户和数据量都在递增,一般都会升级redis的部署架构,对于99%中小型项目redis哨兵集群就够用了(一般中型项目都用不到Cluster集群,因为缓存数据量级不够则没有必要),哨兵集群(一主两从 使用RedLock 相当于只使用了一个主节点)下的jedis分布式锁是解决了单点故障问题,只有在主从切换才会出现数据丢失导致锁不安全这种情况。
现在来分析一下实际生产环境中哨兵集群主从切换丢数据的可能性:

  1. 制定规范 ; 只有在master节点写数据成功(刚好在加锁)且该数据还没有来得及同步给salve节点时master挂了哨兵帮忙处理主从切换导致锁失效,可想而知这个概率非常小,可以通过运维和开发按照一定标准合理使用和维护redis(如 部署在同一内网 ;redis版本都一致;开发杜绝bigkey等规范),尽可能减少这种情况;
  2. 数据结构优化:先看看jedis锁的数据结构–最简单的String类型,key可以使用资源缩写(如 用户的书籍资源缩写 key = u : bk : + id ),value 为客户端请求标识 可以用 雪花算法生成19位数字(数字比字符串占内存小),用这种策略尽可能减少 锁的大小,理论上这种非常小key在主从同步时所花费时间都是微秒级别,则发生以上加锁时主从切换异常时丢锁的数据的可能性也是及其低的;
  3. 配置优化:影响主从同步速度主要是3个问题:bigkey、网络、同步方式(其中前2项可以通过第一条可以解决),同步方式有全量复制和增量复制在redis 2.8+ 版本中除了第一次启动是全量复制其他正常情况都是增量复制,则redis版本推荐使用 redis 2.8+,且为了尽可能减少可能出现异常情况导致的全量复制可以根据实际压测数据合理配置复制缓冲区大小(关于redis主从复制细节这里就不赘述了)。

通过以上优化和分析,在实际生产环境中哨兵集群在加锁时出现主从切换导致锁失效的情况非常非常少,即哨兵集群下的jedis分布式锁也是可以很安全的。

最后,本次技术分享都是基于个人对分布式锁理解和看法,有什么不对请帮忙指出。

你可能感兴趣的:(redis,面试,分布式,分布式,jvm,java)