分布式锁实现的三种方法
分布式 CAP 理论:
任何一个分布式系统都无法同时满足一致性(Consistency)、可用性(Availability)和分区容错性(Partition tolerance),最多只能同时满足两项。
锁只要能保证互斥就行,可以将标记存在内存当中。
单机将标记存在堆内存当中,针对的是多线程
分布式可以采用redis,将标记设在redis缓(公共内存)中,针对的是多进程
分布式锁:保证在分布式部署的应用集群中,同一个方法在同一时间只能被一台机器上的一个线程执行。
实现分布式锁一般有三种实现方式:
1.数据库乐观锁
2.基于Redis的分布式锁
3.基于Zookeeper的分布式锁
确保分布式锁可用,我们至少要确保锁的实现同时满足以下四个条件:
1.互斥性。在任意时刻,只有一个客户端能持有锁。
2.不会发生死锁。即使有一个客户端在持有锁的期间崩溃而没有主动解锁,也能保证后续其他客户端能加锁。
3.安全性。加锁和解锁必须是同一个客户端,客户端自己不能把别人加的锁给解了,即不能误解锁。
4.容错性。只要大多数Redis节点正常运行,客户端就能够获取和释放锁。
1.基于数据库的分布式锁
1)思路:利用主键唯一的特性来保证互斥,当有多个请求同时提交到数据库的话,数据库会保证只有一个操作可以成功,那么我们可以认为操作成功的那个线程获得了该方法的锁,当方法执行完毕之后,想要释放锁的话,删除这条数据库记录。
缺点:强依赖数据库:数据库挂掉,整个服务就停止运行了
锁没有失效时间:一旦解锁失败,其它线程不能再获取到锁
不可重入:获取到锁后,没有解锁,无法再次获取到锁
2)思路:通过数据库的行锁(排他锁)来保证互斥
缺点:强依赖
不可重入
2.基于redis缓存的分布式锁
思路:通过多系统连接同一个redis库,写入同一个key,通过key的唯一性来保证互斥。如果成功写入key,则获取到锁,然后给key设置过期时间并实现后续的业务操作,业务操作执行完毕后,再删除key,释放锁。
优点:可重入
有失效时间,可自动释放锁
缺点:强依赖
使用方法:setnx() 、expire()、getset()、get()
setnx() :方法是原子性的,含义为 SET if Not Exists。如果key不存在,则设置当前的key,并返回1;如果key存在,则设置key失败,返回0。
expire(): 设置key的过期时间
get(): 获取key的value
getset():方法是原子性的。对 key 设置 newValue 这个值,并且返回 key 原来的旧值。原来的key不存在,则返回null
操作步骤:
1、setnx(lockkey, 1) 如果返回 0,则说明占位失败;如果返回 1,则说明占位成功
2、expire() 命令对 lockkey 设置超时时间,为的是避免死锁问题。
3、执行完业务代码后,可以通过 delete 命令删除 key。
容易出现,步骤1执行完,但是步骤2还未执行的时候,服务器宕机,则会出现死锁情况
调整后的操作步骤:
1、setnx(lockkey, 当前时间+过期超时时间),如果返回 1,则获取锁成功,继续执行步骤2;如果返回 0 则没有获取到锁,转向 步骤4。
2、expire() 命令对 lockkey 设置超时时间。
3、执行完业务代码后,可以通过 delete 命令删除 key。流程结束。
4、get(lockkey) 获取值 oldExpireTime ,并将这个 value 值与当前的系统时间进行比较,如果小于当前系统时间,则认为这个锁已经超时,可以允许别的请求重新获取,继续执行步骤5。
5、计算 newExpireTime = 当前时间+过期超时时间,然后 getset(lockkey, newExpireTime) 会返回当前 lockkey 的值currentExpireTime。
6、判断 currentExpireTime 与 oldExpireTime 是否相等,如果相等,说明当前 getset 设置成功,获取到了锁。如果不相等,说明这个锁又被别的请求获取走了,那么当前请求可以直接返回失败,或者继续重试。
7、在获取到锁之后,当前线程可以开始自己的业务处理,当处理完毕后,比较自己的处理时间和对于锁设置的超时时间,如果小于锁设置的超时时间,则直接执行 delete 释放锁;如果大于锁设置的超时时间,则不需要对锁进行处理。
上述步骤,基本保证了锁的正常功能,但是一旦redis服务挂掉,则整个系统就停止工作了,所以为了保证redis的高可用性,算法又增加了以下步骤:
建立 N 个完全独立的 Redis 节点(通常情况下 N 可以设置成 5)。通过获取所有redis服务的锁,如果获取到锁的redis节点数量大于等于3则表明获取锁成功。这样可以保证在大部分redis节点都存活的情况下,系统仍然可用。
算法步骤:
1、客户端获取当前时间,以毫秒为单位。
2、客户端尝试获取 N 个节点的锁,(每个节点获取锁的方式和前面的单redis节点获取锁的操作步骤一样),N 个节点以相同的 key 和 value 获取锁。客户端需要设置接口访问超时,接口超时时间需要远远小于锁超时时间,比如锁自动释放的时间是 10s,那么接口超时大概设置 5-50ms。这样可以在有 redis 节点宕机后,访问该节点时能尽快超时,而减小锁正常使用时间。
3、客户端计算在获得锁的时候花费了多少时间,方法是用当前时间减去在步骤1获取的时间,只有客户端获得了超过 3 个节点的锁,而且获取锁的时间小于锁的超时时间,客户端才获得了分布式锁。
4、客户端获取锁的时间为设置的锁超时时间减去步骤3计算出的获取锁花费时间。
5、如果客户端获取锁失败了,客户端会依次删除获取到的redis节点的锁。
3.基于redisson的分布式锁
redisson是redis官方的分布式锁组件。GitHub 地址:https://github.com/redisson/redisson
锁超时时间设置:每获得一个锁时,只设置一个很短的超时时间,同时起一个线程在每次快要到超时时间时去刷新锁的超时时间。在释放锁的同时结束这个线程。
4.基于zookeeper的分布式锁
原理:利用临时节点的唯一性来保证互斥。每个锁占用一个普通节点 /lock,当需要获取锁时在 /lock 目录下创建一个临时节点,创建成功则表示获取锁成功,失败则 watch(监听)节点,有删除操作后再去争锁。临时节点好处在于当进程挂掉后能自动上锁的节点自动删除即取消锁。
缺点:所有失败的进程都监听 锁的父节点。容易发生羊群效应,即当释放锁后所有等待进程一起来创建节点,并发量很大。
针对上述步骤进行优化:
获取锁改为创建临时有序节点,每个获取锁的节点均能创建节点成功,只是其序号不同。只有序号最小的可以拥有锁,如果这个节点序号不是最小的则 watch 序号比本身小的前一个节点 (公平锁)。
步骤:
1.在 /lock 节点下创建一个有序临时节点 (EPHEMERAL_SEQUENTIAL)。
2.判断创建的节点序号是否最小,如果是最小则获取锁成功。不是则取锁失败,然后 watch 序号比本身小的前一个节点。
3.当取锁失败,设置 watch 后则等待 watch 事件到来后,再次判断是否序号最小。
4.取锁成功则执行代码,最后释放锁(删除该节点)。
优点:有效的解决单点问题,不可重入问题,非阻塞问题以及锁无法释放的问题。实现起来较为简单。
缺点:性能没有缓存服务高