Redis深度历险笔记02 Redis分布式锁

对锁的理解?(待简化)

  • 在单进程的系统中,当存在多个线程可以同时改变某个变量(可变共享变量)时,就需要对变量或代码块做同步,使其在修改这种变量时能够线性执行消除并发修改变量。
  • 而同步的本质是通过锁来实现的。为了实现多个线程在一个时刻同一个代码块只能有一个线程可执行,那么需要在某个地方做个标记,这个标记必须每个线程都能看到,当标记不存在时可以设置该标记,其余后续线程发现已经有标记了则等待拥有标记的线程结束同步代码块取消标记后再去尝试设置标记。这个标记可以理解为锁。
  • 不同地方实现锁的方式也不一样,只要能满足所有线程都能看得到标记即可。如 Java 中 synchronize 是在对象头设置标记,Lock 接口的实现类基本上都只是某一个 volitile 修饰的 int 型变量其保证每个线程都能拥有对该 int 的可见性和原子修改,linux 内核中也是利用互斥量或信号量等内存数据做标记。
  • 除了利用内存数据做锁其实任何互斥的都能做锁(只考虑互斥情况),如流水表中流水号与时间结合做幂等校验可以看作是一个不会释放的锁,或者使用某个文件是否存在作为锁等。只需要满足在对标记进行修改能保证原子性和内存可见性即可。

分布式的 CAP 理论

  • 任何一个分布式系统都无法同时满足一致性(Consistency)、可用性(Availability)和分区容错性(Partition tolerance),最多只能同时满足两项。
  • 目前很多大型网站及应用都是分布式部署的,分布式场景中的数据一致性问题一直是一个比较重要的话题。基于 CAP理论,很多系统在设计之初就要对这三者做出取舍。在互联网领域的绝大多数的场景中,都需要牺牲强一致性来换取系统的高可用性,系统往往只需要保证最终一致性。

为什么使用分布式锁

  • 为了保证一个方法或属性在高并发情况下的同一时间只能被同一个线程执行
  • 单机情况要解决共享资源的访问很容易,因为只有一个JVM在运行我们的代码,Java的API提供了很丰富的解决方案,常见的诸如synchronize,lock,volatile,JUC包等等。
  • 在分布式部署下,会出现一套代码出现在多个服务器的JVM中,请求落在哪一个上面是随机的。这个时候Java的API提供的一些解决机制就没法满足要求,它只能解决当前JVM中能保证顺序访问共享资源,但是不能保证多台机器顺序访问。
  • 这时就需要一种跨JVM的互斥机制来控制共享资源的访问,就要使用分布式锁。

现在分布式锁有三种实现方案

  1. 基于数据库。
  2. 基于缓存环境,redis,memcache等。
  3. 基于zookeeper。

分布式锁需要具备什么特点(条件)

  1. 首先保证在分布式的环境中,同一个方法只能被一个服务器上的一个线程执行。
  2. 锁要可重入,如果获取锁之后如果需要再次获取时发现不能获取了,会造成死锁。(避免死锁)
  3. 锁要可阻塞。这一般只要保证有个超时时间就行。
  4. 高可用的加锁和释放锁功能。
  5. 加锁和释放锁的性能要好。
  6. 具备锁失效机制,防止死锁。

什么是分布式锁?

  • 当在分布式模型下,数据只有一份(或有限制),此时需要利用锁的技术控制某一时刻修改数据的进程数。
  • 与单机模式下的锁不仅需要保证进程可见,还需要考虑进程与锁之间的网络延时等问题。
  • 分布式锁要将标记存在公共环境中,如 利用Redis、Memcache、数据库等做锁,只要保证标记能互斥就行。

数据库分布式锁

一、基于表主键唯一做分布式锁(乐观锁)

思路:

在数据库中创建一个表,表中包含方法名等字段,并在方法名字段上创建唯一索引,想要执行某个方法,就使用这个方法名向表中插入数据,由于主键唯一的特性,如果有多个请求同时提交到数据库的话,数据库会保证只有一个操作可以成功,那么我们就可以认为操作成功的那个线程获得了该方法的锁,当方法执行完毕之后,想要释放锁的话,删除这条数据库记录即可。

在数据库中有一张表,这张表类似一个公共资源池,每个线程都要来这边获取条件,看能不能获取到当前方法的锁。
在这里插入图片描述

  • 获取锁时,只要执行insert语句
insert into lock_table("method_name","time")
  • 释放锁时,执行对应的delete语句就行。

问题:

  1. 这个表中没有设计失效时间,一旦出现加锁成功但是解锁失败的情况,就会导致锁记录一直在数据库中,其他线程无法再获得到锁。
  2. 这把锁是非重入的,同一个线程在没有释放锁之前无法再次获得该锁。因为数据中数据已经存在了。
  3. 这把锁只能是非阻塞的,因为数据的 insert操作,一旦插入失败就会直接报错。没有获得锁的线程并不会进入排队队列,要想再次获得锁就要再次触发获得锁操作。
  4. 数据库是一个单点,一旦数据库挂了,就不能使用了
  5. 这把锁是非公平锁,所有等待锁的线程凭运气去争夺锁。

解决:

  1. 需要在表中新增一列,用于记录失效时间,做一个定时任务,每隔一定时间把数据库中的超时数据清理一遍。代码中在加锁时可以先判断当前记录是不是已经超过最大允许时间,超过了说明已经失效了,先手动释放锁,再加锁。
  2. 在数据库表中加入一个字段记录当前记录当前获得锁的机器的主机信息和线程信息,在再次获取锁的时候,先查询表中机器和线程信息是否和当前机器线程相同,若相同则直接获取锁。
  3. 代码里执行while循环,设置一个允许最大时间,超过了,直接失败。
  4. 使用两个数据库,双机部署、数据同步、主备切换。
  5. 再建一张中间表,将等待锁的线程全记录下来,并根据创建时间排序,只有最先创建的允许获取锁。
二、基于排他锁的实现

在查询语句后面增加for update,数据库会在查询过程中给数据库表增加排他锁也就是写锁。 (注意: InnoDB 引擎在加锁的时候,只有通过索引进行检索的时候才会使用行级锁,否则会使用表级锁。这里我们希望使用行级锁,就要给要执行的方法字段名添加索引,值得注意的是,这个索引一定要创建成唯一索引,否则会出现多个重载方法之间无法同时被访问的问题。重载方法的话建议把参数类型也加上。)当某条记录被加上排他锁之后,其他线程无法再在该行记录上增加排他锁。

我们可以认为获得排他锁的线程即可获得分布式锁,当获取到锁之后,可以执行方法的业务逻辑,执行完方法之后,通过connection.commit()操作来释放锁。

解决了上面提到的无法释放锁和阻塞锁的问题:

  1. 是阻塞的。使用了select * for update时,其他想要获取锁的事务读不出数据,一直阻塞在那儿
  2. 宕机之后就自动释放了。

没解决的问题:

  1. 单点问题,数据库是一个单点,一旦数据库挂了,就不能使用了
  2. 可重入问题。
  3. 虽然我们对方法字段名使用了唯一索引,并且显示使用 for update 来使用行级锁。但是,MySQL 会对查询进行优化,即便在条件中使用了索引字段,但是否使用索引来检索数据是由 MySQL 通过判断不同执行计划的代价来决定的,如果 MySQL 认为全表扫效率更高,比如对一些很小的表,它就不会使用索引,这种情况下 InnoDB 将使用表锁,而不是行锁。如果发生这种情况就悲剧了。。。
  4. 还有一个问题,就是我们要使用排他锁来进行分布式锁的 lock,那么一个排他锁长时间不提交,就会占用数据库连接。一旦类似的连接变得多了,就可能把数据库连接池撑爆。

优点:简单,易于理解

缺点:会有各种各样的问题(如上)操作数据库需要一定的开销,使用数据库的行级锁并不一定靠谱,性能不靠谱)

基于缓存实现(redis分布式锁)

简单来说就是在Redis里占个位置,当别的线程也要来占时,发现已经被占了,只能等待

Redis具有很高的性能;Redis的命令实现起来很方便;

命令介绍:

SETNX

// 当且仅当key不存在时,set一个key为val的字符串,返回1;
// 若key存在,则什么都不做,返回0。
SETNX key val;

存在的问题:如果程序出异常,可能导致del命令没有被调用,就会陷入死锁,锁永远得不到释放。所以要加上一个过期时间。

expire

// 为key设置一个超时时间,单位为second,超过这个时间锁会自动释放,避免死锁。
expire key timeout;

delete

// 删除key
delete key;

我们通过Redis实现分布式锁时,主要通过上面的这三个命令。

通过Redis实现分布式的核心思想为:

  1. 获取锁的时候,使用setnx加锁,并使用expire命令为锁添加一个超时时间,超过该时间自动释放锁,锁的value值为一个随机生成的UUID,通过这个value值,在释放锁的时候进行判断。
  2. 获取锁的时候还设置一个获取的超时时间,若超过这个时间则放弃获取锁。
  3. 释放锁的时候,通过UUID判断是不是当前持有的锁,若是该锁,则执行delete进行锁释放
一、基于 REDIS 的 SETNX()、EXPIRE() 方法做分布式锁

setnx():setnx 的含义就是 SET if Not Exists,其主要有两个参数 setnx(key, value)。该方法是原子的,如果 key 不存在,则设置当前 key 成功,返回 1;如果当前 key 已经存在,则设置当前 key 失败,返回 0。

expire():expire 设置过期时间,要注意的是 setnx 命令不能设置 key 的超时时间,只能通过 expire() 来对 key 设置。

使用步骤:

  1. setnx(lockkey, 1) 如果返回 0,则说明占位失败;如果返回 1,则说明占位成功
  2. expire() 命令对 lockkey 设置超时时间,为的是避免死锁问题。
  3. 执行完业务代码后,可以通过 delete 命令删除 key。

可能出现的问题及解决办法:

问题:使用setnx指令后继续使用expire指令。但是这两部操作必定不是原子性的,如果执行expire失败,会出现死锁的问题。

解决:在Redis2.8 之后,setnx 和 expire命令一起使用了,SET lock_key lock_value NX PX 30000

二、使用set key value NX PX 30000命令加锁
set key value NX PX 30000
  • my_random_value是由客户端生成的一个随机字符串,用于唯一标识锁的持有者。
  • NX表示只有当key值不存在的时候才能SET成功,从而保证只有一个客户端能获得锁,而其它客户端在锁被释放之前都无法获得锁。
  • PX 30000表示这个锁有一个30秒的自动过期时间。(目的是为了防止持有锁的客户端故障后,无法主动释放锁而导致死锁)

锁自动过期存在的隐患:

隐患1. 例如我们有两个线程A、B,此时线程A抢到了锁,且设置自动过期时间为10s钟,因为系统其他原因导致系统A发生阻塞。而此刻10s钟后锁自动过期,线程C获取到了同一个资源的锁,线程A从阻塞中恢复,认为自己仍然持有锁,继续操作同一资源。这样就使得加锁的互斥性失效了。

解决:

让获取锁的线程开启一个守护线程,给线程还没执行完,又快要过期的锁续航。大概是这样的,线程A还没执行完,守护线程每当快过期时,延时expire时间。当线程A执行完,显示关闭守护线程。如果中间宕机,锁超过超时,守护线程也不在了,自动释放锁。

隐患2. 因为有了过期时间,如果一个线程加锁后,执行业务逻辑时间太长,锁超过了30秒过期时间,锁已经过期了,并且已经被别的线程加锁了,然后这个旧线程delete了别人的锁。

解决:为了防止客户端1获得的锁,被客户端2给释放,采用下面的Lua脚本来释放锁,Lua 脚本可 以保证连续多个指令的原子性执行

if redis.call("get",KEYS[1]) == ARGV[1] then
    return redis.call("del",KEYS[1])
else
    return 0
end

在执行这段LUA脚本的时候,KEYS[1]的值为resource_name,ARGV[1]的值为my_random_value。原理就是先获取锁对应的value值,保证和客户端穿进去的my_random_value值相等,这样就能避免自己的锁被其他人释放。

问题:过期时间如何设置

  1. 重入的问题没有解决。
  2. redis中如何保证锁的容错性。需要注意加锁成功,但是设置失效时间时宕机的场景,保证不出现死锁。
  3. 一旦redis挂了,或者如果是主从结构的redis,master节点挂了,锁还没有同步到从节点,然后从节点就被哨兵选举成master节点,这时另一个客户端请求加锁会被批准,就会导致一把锁会两个客户端持有。
三、基于 REDIS 的 SETNX()、GET()、GETSET()方法做分布式锁

主要是在 setnx() 和 expire() 的方案上针对可能存在的死锁问题,做了一些优化。

getset():这个命令主要有两个参数 getset(key,newValue)。该方法是原子的,对 key 设置 newValue 这个值,并且返回 key 原来的旧值。假设 key 原来是不存在的,那么多次执行这个命令,会出现下边的效果:

getset(key, “value1”) 返回 null 此时 key 的值会被设置为 value1
getset(key, “value2”) 返回 value1 此时 key 的值会被设置为 value2

使用步骤:

  1. setnx(lockkey, 当前时间+过期超时时间),如果返回 1,则获取锁成功;如果返回 0 则没有获取到锁,转向 2。
  2. get(lockkey) 获取值 oldExpireTime ,并将这个 value 值与当前的系统时间进行比较,如果小于当前系统时间,则认为这个锁已经超时,可以允许别的请求重新获取,转向 3。
  3. 计算 newExpireTime = 当前时间+过期超时时间,然后 getset(lockkey, newExpireTime) 会返回当前 lockkey 的值currentExpireTime。
  4. 判断 currentExpireTime 与 oldExpireTime 是否相等,如果相等,说明当前 getset 设置成功,获取到了锁。如果不相等,说明这个锁又被别的请求获取走了,那么当前请求可以直接返回失败,或者继续重试。
  5. 在获取到锁之后,当前线程可以开始自己的业务处理,当处理完毕后,比较自己的处理时间和对于锁设置的超时时间,如果小于锁设置的超时时间,则直接执行 delete 释放锁;如果大于锁设置的超时时间,则不需要再锁进行处理。
四、基于 REDLOCK 做分布式锁

使用Redlock时,需要提供多个Redis实例,这些实例之间相互独立没有主从关系,加锁时,会向过半节点发送加锁指令,只要过半节点加锁成功,那就认为加锁成功。释放锁时,需要向所有的节点发送del指令。

Redlock 是 Redis 的作者 antirez 给出的集群模式的 Redis 分布式锁,它基于 N 个完全独立的 Redis 节点(通常情况下 N 可以设置成 5)。

算法的步骤如下:

  1. 客户端获取当前时间,以毫秒为单位。
  2. 客户端尝试获取 N 个节点的锁,(每个节点获取锁的方式和前面说的缓存锁一样),N 个节点以相同的 key 和 value 获取锁。客户端需要设置接口访问超时,接口超时时间需要远远小于锁超时时间,比如锁自动释放的时间是 10s,那么接口超时大概设置 5-50ms。这样可以在有 redis 节点宕机后,访问该节点时能尽快超时,而减小锁的正常使用。
  3. 客户端计算在获得锁的时候花费了多少时间,方法是用当前时间减去在步骤一获取的时间,只有客户端获得了超过 3 个节点的锁,而且获取锁的时间小于锁的超时时间,客户端才获得了分布式锁。
  4. 客户端获取的锁的时间为设置的锁超时时间减去步骤三计算出的获取锁花费时间。
  5. 如果客户端获取锁失败了,客户端会依次删除所有的锁。

使用 Redlock 算法,可以保证在挂掉最多 2 个节点的时候,分布式锁服务仍然能工作,这相比之前的数据库锁和缓存锁大大提高了可用性,由于 redis 的高效性能,分布式缓存锁性能并不比数据库锁差。

基于zk的实现

redis分布式锁,轮询获取锁,比较消耗性能,zk分布式锁,监听回调机制,性能开销较小

zookeeper不会存在,服务挂了,锁永远存在的线程,zookeeper可以创建临时节点,zookeeper感应到服务挂了,会自己删除锁节点。

基于zk实现分布式锁的原理是:创建临时顺序性节点,通过监听机制来实现,监听机制指的是在指定节点上注册一些事件监听器,当节点发生变化时,将事件通知给客户端。假设100个服务器同时发来请求,就创建100个临时顺序性节点,分别编号为001、002,到100,同时每个服务器都会监听自己前面的一个节点。当001节点处理完毕,删除节点,然后002收到通知,去获取锁,开始执行,执行完毕后再删除节点,再003获取锁,以此类推。

临时顺序性节点:临时指的是一旦ZooKeeper客户端断开了连接,ZooKeeper服务端就不再保存这个节点;顺序性指的是在创建节点的时候,ZooKeeper会自动给节点编号,比如0000001,0000002这种。

为什么要选择redis分布式锁?

对比优缺点:
在这里插入图片描述
哪种方式都无法做到完美。就像CAP一样,在复杂性、可靠性、性能等方面无法同时满足,所以,要根据不同的应用场景选择最适合自己的。

从理解的难易程度角度(从低到高)
数据库 > 缓存 > Zookeeper

从实现的复杂性角度(从低到高)
Zookeeper >= 缓存 > 数据库

从性能角度(从高到低)
缓存 > Zookeeper >= 数据库

从可靠性角度(从高到低)
Zookeeper > 缓存 > 数据库

数据库的性能有限,如果在高并发的情况下会频发的访问数据库,对数据库会造成较大的压力。

参考博客:
https://www.cnblogs.com/justuntil/p/10458211.html
https://www.jianshu.com/p/9055ca856aaf
https://blog.csdn.net/wuzhiwei549/article/details/80692278
https://www.jianshu.com/p/5b9041d1f2cd
https://www.cnblogs.com/seesun2012/p/9214653.html
https://www.cnblogs.com/shoshana-kong/p/9581557.html
https://www.cnblogs.com/rjzheng/p/9310976.html
https://www.jianshu.com/p/8bddd381de06

猜你喜欢

转载自blog.csdn.net/weixin_43338519/article/details/105514419