Redis分布式锁的实现

一、前言

  • 在介绍分布式锁之前,我们来聊一聊锁的种类

线程锁

  • 线程锁就是在同一个进程中访问临界资源时使用的锁,主要是用来线程间同步与互斥的
  • 以Linux为例,常用的线程锁有:互斥量、读写锁、条件变量、自旋锁等...

进程锁

  • 例如Nginx里面有一个accept锁,是使用共享内存+信号量构成的

分布式锁

  • 不同机器的不同进程之间的锁

二、分布式锁的实现方式

  • 常见的实现方式有:
    • Redis分布式锁
    • MySQL分布式锁
    • ZooKeeper分布式锁(最常用)
    • ......其他还有,可以自行百度搜索
  • 高效性:ZooKeeper > Redis > MySQL

三、分布式锁的特性

互斥性

  • 锁的最基本特征,保证临界资源只能被一者访问

可重入性

  • 一个进程允许递归获取锁(并且可以递归释放锁)

锁超时

  • 当客户端或者服务中心crash掉之后,crash掉之后,锁需要被释放掉

高效性、可用性

  • 数据中心需要集群,ZooKeeper、Redis等都提供了集群的功能,集群保证数据中心一台机器crash掉之后,其他机器仍然能正常工作,保证机器在crash掉之后能够正确的重新获取锁和释放锁
  • 对于高可用性来说:ZooKeeper是强于Redis的
    • 因为Redis保证的是最终一致性:对于主从服务器来说,当从服务器crash掉之后,会导致至少有1秒(rdb方式运行)或者至少有1个事务操作(aof方式运行)丢失,因为此时可能会有新的数据写入了主服务器,但是从服务器没有更新。但是当从服务器再次重启之后会根据复制偏移量来与主服务器进行数据同步,达到数据最终一致
    • ZooKeeper保证的是强一致性:ZooKeeper通过zab协议使得每个节点之间的数据是一直保持一致的

公平锁、非公平锁

  • 公平锁:各个进程按照顺序获取锁
  • 非公平锁:各个进程获取锁的顺序可以是不按照顺序执行的
  • 还有一系列其他特性等

四、Redis分布式锁

  • Redis分布式锁是一种悲观锁

五、方案1:使用setnx实现(有死锁问题)

实现思路

  • 第一步(获取锁):访问分布式共享资源时,先调用setnx()设置一个key,setnx()只有当key不存在的时候才能设置成功
    • 如果setnx()设置成功,说明获取锁成功,进行第二步
    • 如果setnx()没有设置成功,说明该key已经存在(已经被其他进程上锁),因此阻塞等待其他一个进程del key(释放锁),另外进程释放之后,自己再调用setnx()设置key(获取锁)
  • 第二步(操作临界资源):第一步获取锁之后,可以对临界资源进行操作了
  • 第三步(释放锁):对临界资源操作完成之后,调用del key删除刚才创建的key,相当于释放锁了

Redis分布式锁的实现_第1张图片

伪代码

// 1.阻塞等待setnx()执行成功
while(setnx(key, val) == -1)
{
    
}

// 2.操作资源
// ......


// 3.释放锁
if(havekey(key) != -1)
    del(key);

该方式存在的问题

  • 以上图为例,如果机器A调用setnx()之后,其一直没有调用del key来释放锁(例如,进程阻塞了或者进程crash了),那么其他的进程会一直阻塞等待机器A释放锁,从而造成死锁
  • 解决方案见下

六、方案2:使用set+键超时(有锁不能正确被释放的问题)

  • 对于上面setnx()会造成死锁的问题,可以使用set+键超时来实现

实现思路

  • 因为stenx()会造成死锁问题,因此我们可以调用Redis中set命令的nx来完成setnx()的功能,并使用ex选项来设置锁超时时间
  • 代码如下:
// NX:等同于setnx()
// EX: 设置锁的超时时间为30毫秒
set("lock", value, "NX", "EX", 30);
  • 这样的话,当一个进程crash或者阻塞之后,就不会一直占用着锁而不释放(因为超时key会被自动释放),从而避免了死锁问题

该方式存在的问题

  • 如果一个进程在设置超时锁成功之后,其临界区代码还没有执行完,但是锁会被自动删除释放,因此其他进程可以获取到锁,从而造成了一系列的问题

Redis分布式锁的实现_第2张图片

  • 解决方案见下

七、方案3:使用进程标识锁(有锁会被多个进程共享的问题)

  • 上面的设计方案有一个明显的错误就是:键超时被自动删除(自动释放)之后,其他进程可以获取锁,当自己完成任务去手动删除(手动释放)锁时,删除了其它进程加的锁

实现思路

  • 从上面的图我们可以看出,当A未执行完,锁被自动释放,此时B获取锁,当A执行完之后,调用del key释放锁,此时释放的是B的锁
  • 因此,改进的方法就是:把锁用一个标志来标识(例如用进程标识),加锁的时候用自己的进程标识这个锁,当释放锁的时候判断进程标识与自己的是否相同,如果不相同,那么就不释放
  • 标识一个进程的方式有很多,例如:
    • 使用pid、或ip+pid、或guid、或uuid、或算法等标识一个进程
    • 使用"ip:port:starttime:pid"标识一个进程(其中starttime表示进程的开始执行时间,最低级别以ms为单位)
  • 关于如何标识一个分布式进程,可以参阅:https://blog.csdn.net/qq_41453285/article/details/108427670
  • 例如:

Redis分布式锁的实现_第3张图片

伪代码

// 1.阻塞等待获取锁
while(set("lock", "自己的进程标识", "NX", "EX", 30); == -1)
{
    
}

// 2.操作资源
// ......


// 3.只有当所表示是自己的进程标识才可以释放锁, 否则阻塞等待
while(havekey(key) && getkey(key) == "自己的进程表示")
    del(key);

该方式存在的问题

  • 该方式存在一个明显的问题就是:一个锁存在被多个进程同时拥有的现象
  • 例如上面A的锁被自动释放(而非手动释放)之后,B获取锁,在B获取锁之后,A仍然占用这个锁(因为其仍然拥有del key的功能,虽然其不能立即执行)

八、最终解决方案:使用"Lua事务+锁超时续时"

  • 上面的方案都有错误,下面直接介绍最终的方案了,不再介绍更多的内容了

实现思路

  • ①使用Lua事务封装整个锁操作:
    • 上面的代码set()和del()命令是分开来执行的,也就是说不是原子操作
    • Redis支持事务,可以使用Lua事务封装set()和del(),将这两个操作封装到一个事务中去执行,这样可以保证在命令执行的过程中受到其它事务干扰
  • ②锁超时续时:
    • 上面的实现方案一直都有一个弊端,那就是当一个进程临界区资源还没有操作完的时候,key被系统自动释放了
    • 因此可以在key超时之后为key续时,保证进程在操作临界区资源的时候key一直保持未释放状态
    • 至于如何续时,可以使用其他线程为其续时或者使用其它方案

九、附加知识(如何竞争锁)

  • 上面介绍了分布式锁如何实现,但是没有介绍的一个内容就是,当一个进程释放锁之后,其他进程如何去竞争获取锁
  • 实现的方案一般有2种:
    • 第一种:使用定时查询,查询锁释放被释放
    • 第二种:使用监听机制(http://www.redis.cn/topics/pubsub.html),锁拥有进程释放锁的时候发布key删除事件,同时进程要监听key的超时(http://www.redis.cn/topics/notifications.html)
  • 一般采用第一种

十、附加知识

  • readlock():比较复杂,百度

你可能感兴趣的:(架构师进阶,Redis分布式锁的实现)