美文网首页
分布式锁

分布式锁

作者: 范柏柏 | 来源:发表于2022-03-10 15:48 被阅读0次

    1、为什么要使用分布式锁

    与分布式锁对应的是【单机锁】,我们在写多线程程序时,避免同时操作一个共享变量而产生数据问题,通常会使用一把锁来实现【互斥】,其使用范围是在【同一个进程中】。(同一个进程内存是共享的,以争抢同一段内存,来判断是否抢到锁)。

    如果是多个进程,如何互斥呢。就要引入【分布式锁】来解决这个问题。想要实现分布式锁,必须借助一个外部系统,所有进程都去这个系统上去【申请加锁】。

    而这个外部系统,必须要实现【互斥】的能力,即两个请求同时进来,只会给一个进程返回成功,另一个返回失败(或等待)。

    这个外部系统,可以是 MySQL,也可以是 Redis 或 Zookeeper。但为了追求更好的性能,我们通常会选择使用 Redis 或 Zookeeper 来做。

    2、mysql实现

    依赖mysql的行锁 select for update。

    几个前提:

    RC(读已提交)隔离级别:

    • 只会加行锁,没有间隙锁的概念。
    • 查询不到不加锁。

    RR(可重复度)隔离界别:

    • 会加行锁和间隙锁。
    • 查不到加间隙锁。
    • 查询条件没有用到索引锁表!!(默认给所有间隙都上锁)

    命中主键索引,只会给主键索引加锁。其他二级索引不加锁。

    命中二级索引,会给使用到的二级索引和主键索引加锁。间隙锁只加在所有二级索引中,主键索引只加行锁。(因为间隙锁是为了解决幻读,插入的数据如果加锁语句不会查出来,那么就不会加锁)

    RR隔离级别。
    id = 1 name = A
    id = 3 name = C
    
    select * from test where name = 'B' for update。
    
    这个时候,name索引,A-C会加间隙锁。主键索引不加锁。
    
    插入 id = 2 name = B。阻塞。因为name加了间隙锁。
                            (加锁语句可以查到插入的数据,必定是会加锁的)
    插入 id = 2 name = D。不阻塞。因为name的间隙锁不作用于D,主键也没有间隙锁。
                            (加锁语句可以查不到插入的数据,必定是没有锁的)
    
    

    只有访问到的对象才会加锁。使用了索引A,就只给加锁,虽然数据也会存在二级索引B...B没有被访问到,不会加锁的。

    只有访问到的行才会加锁。没有访问到的行,不会加锁。

    一个特例,唯一索引。唯一索引是找到了就直接停止遍历,非唯一索引还会向后遍历一行。移步第八个case。

    建表

    CREATE TABLE `book` (
      `id` int(11) unsigned NOT NULL,
      `num` varchar(10) NOT NULL DEFAULT '',
      `name` varchar(20) NOT NULL DEFAULT '',
      `score` decimal(2,1) NOT NULL,
      PRIMARY KEY (`id`),
      UNIQUE KEY `uk_num` (`number`),
      KEY `idx_name` (`name`)
    ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
    
    mysql> select * from student;
    +----+-------+--------+-------+
    | id |  num  | name   | score |
    +----+-------+--------+-------+
    | 10 | N0001 | Bob    |   3.4 |
    | 18 | N0002 | Alice  |   7.7 |
    | 25 | N0003 | Jim    |   5.0 |
    | 30 | N0004 | Eric   |   9.1 |
    | 41 | N0005 | Tom    |   2.2 |
    | 49 | N0006 | Tom    |   8.3 |
    | 60 | N0007 | Rose   |   8.9 |
    +----+-------+--------+-------+
    
    

    现在的索引:

    image.png

    一、主键索引命中

    image.png

    分析:

    单条命中只会加行锁,不加间隙锁。所以RC/RR是一样的。

    事务1对id=10这条记录加行锁。所以场景1会锁等待,场景2不会锁等待

    二、主键索引未命中

    image.png

    分析:

    RC隔离级别:

    事务1未命中,不会加任何锁。所以场景1,场景2,场景3都不会锁等待

    RR隔离级别:

    事务1未命中,会加间隙锁。因为主键查询,只会对主键加锁。

    在10和18加间隙锁。

    间隙锁和查询不冲突。场景1不会锁等待

    间隙锁和插入冲突。场景2和场景3会锁等待

    image.png

    三、二级唯一索引命中

    image.png

    分析

    单条命中只会加行锁,不加间隙锁。所以RC/RR是一样的。

    事务1对二级索引和主键索引加行锁。事务1和事务2都会发生锁等待

    image.png

    四、二级唯一索引未命中

    image.png image.png

    分析

    RC隔离级别:

    事务1未命中,不会加任何锁。所以场景1,场景2,场景3都不会锁等待

    RR隔离级别:

    事务1对二级索引N0007到正无穷上间隙锁,主键索引不上锁。场景1会锁等待,场景2不会锁等待

    五、二级非唯一索引命中

    image.png

    分析:

    RC隔离级别:

    只会加行锁。场景1场景2会锁等待场景3不会发生锁等待

    image.png

    RR隔离级别:

    会加行锁和间隙锁。场景1场景2场景3都会锁等待

    ps: 如果是唯一索引,只会加行锁。非唯一才会加间隙锁。

    image.png

    六、二级非唯一索引未命中

    image.png

    RC隔离级别:

    事务1未命中,不会加任何锁。所以场景1,场景2都不会锁等待

    RR隔离级别:

    事务1未命中,会加间隙锁。间隙锁与查询不冲突,场景1不会发生锁等待场景2会发生锁等待

    image.png

    七、主键索引范围查询

    image.png

    分析

    RC隔离级别:

    事务1加了三个行锁。场景1会锁等待。场景2,场景3不会发生锁等待

    image.png

    RR隔离级别:

    事务1加个三个行锁和间隙锁。场景1,场景3会发生锁等待。间隙锁与查询不冲突,场景2不会锁等待。

    image.png

    八、二级非唯一索引范围查询

    image.png

    分析:

    RC隔离级别:

    事务1加的都是行锁。场景1会发生锁等待场景2,场景3不会发生锁等待

    image.png

    RR隔离级别:

    事务1会对二级索引加行锁和间隙锁,对主键索引加行锁。

    场景1,场景3会发生锁等待。间隙锁与查询不冲突,场景2不会锁等待。

    image.png

    这么看,二级索引和唯一索引没什么区别。

    那如果是 select * from book where name < 'Jim' for update; 呢

    如果name是唯一索引。因为找到jim就不会向后遍历了,所以jim和rose之间不会有间隙锁。

    image.png

    九、无索引命中

    image.png

    分析:

    RC隔离级别:

    由于没有走索引,所以只能全表扫描。在命中的主键索引上加行锁。场景1会锁等待,场景2不会锁等待

    RR隔离级别:

    不开启innodb_locks_unsafe_for_binlog。会发生锁表

    开启innodb_locks_unsafe_for_binlog。和RC隔离级别一样。

    image.png

    十、无索引未命中

    image.png

    RC隔离级别:

    未命中不加锁。场景1,场景2都不会锁等待

    RR隔离级别:

    未命中,锁表

    在RR隔离级别下,where条件没有索引,都会锁表。

    3、redis实现

    1、加锁实现 SETNX

    加锁命令:

    127.0.0.1:6379> SETNX lock 1
    (integer) 1     // 客户端1,加锁成功
    127.0.0.1:6379> SETNX lock 1
    (integer) 0     // 客户端2,加锁失败
    
    

    释放锁命令:

    127.0.0.1:6379> DEL lock // 释放锁
    (integer) 1
    
    

    这里存在问题,当释放锁之前异常退出了。这个锁就永远不会被释放了。

    怎么解决呢?加一个超时时间。

    27.0.0.1:6379> SETNX lock 1    // 加锁
    (integer) 1
    127.0.0.1:6379> EXPIRE lock 10  // 10s后自动过期
    (integer) 1
    
    

    还有问题,不是原子操作。

    redis 2.6.12之后,redis天然支持了

    // 一条命令保证原子性执行
    127.0.0.1:6379> SET lock 1 EX 10 NX
    OK
    
    

    来看一下还有什么问题:

    试想这样一个场景

    1. 客户端 1 加锁成功,开始操作共享资源

    2. 客户端 1 操作共享资源的时间,「超过」了锁的过期时间,锁被「自动释放」

    3. 客户端 2 加锁成功,开始操作共享资源

    4. 客户端 1 操作共享资源完成,释放锁(但释放的是客户端 2 的锁)

    看到了么,这里存在两个严重的问题:

    释放别人的锁:客户端 1 操作共享资源完成后,却又释放了客户端 2 的锁

    锁过期:客户端 1 操作共享资源耗时太久,导致锁被自动释放,之后被客户端 2 持有

    2、释放了别人的锁怎么办

    解决办法是:客户端在加锁时,设置一个只有自己知道的【唯一标识】进去。

    例如,可以是自己的线程id,也可以是一个uuid

    // 锁的VALUE设置为UUID
    127.0.0.1:6379> SET lock $uuid EX 20 NX
    OK
    
    

    在释放锁时,可以这么写:

    // 锁是自己的,才释放
    if redis.get( lock ) == $uuid:
        redis.del( lock )
    
    

    问题来了,还不是原子的。redis没有原生命令了。这里需要使用lua脚本

    // 判断锁是自己的,才释放
    if redis.call( GET ,KEYS[1]) == ARGV[1]
    then
        return redis.call( DEL ,KEYS[1])
    else
        return 0
    end
    
    

    3、锁过期时间不好评估怎么办

    锁的过期时间如果评估不好,这个锁就会有「提前」过期的风险,一般的妥协方案是,尽量「冗余」过期时间,降低锁提前过期的概率。

    其实可以有比较好的方案:

    加锁时,先设置一个过期时间,然后我们开启一个「守护****线程****」,定时去检测这个锁的失效时间,如果锁快要过期了,操作共享资源还未完成,那么就自动对锁进行「续期」,重新设置过期时间。

    这个守护线程我们一般把他叫做【看门狗】线程。

    image

    4、当发生主从切换时,这个分布式锁就不安全了

    我们在使用 Redis 时,一般会采用主从集群 + 哨兵的模式部署,这样做的好处在于,当主库异常宕机时,哨兵可以实现「故障自动切换」,把从库提升为主库,继续提供服务,以此保证可用性。

    试想这样的场景:

    1. 客户端 1 在主库上执行 SET 命令,加锁成功

    2. 此时,主库异常宕机,SET 命令还未同步到从库上(主从复制是异步的)

    3. 从库被哨兵提升为新主库,这个锁在新的主库上,丢失了!

    为此,Redis 的作者提出一种解决方案,就是我们经常听到的 Redlock(红锁)

    5、红锁也不安全

    现在我们来看,Redis 作者提出的 Redlock 方案,是如何解决主从切换后,锁失效问题的。

    Redlock 的方案基于 2 个前提:

    1. 不再需要部署从库哨兵实例,只部署主库

    2. 但主库要部署多个,官方推荐至少 5 个实例

    也就是说,想用使用 Redlock,你至少要部署 5 个 Redis 实例,而且都是主库,它们之间没有任何关系,都是一个个孤立的实例。

    注意:不是部署 Redis Cluster,就是部署 5 个简单的 Redis 实例。

    image

    Redlock 具体如何使用呢?

    整体的流程是这样的,一共分为 5 步:

    1. 客户端先获取「当前时间戳T1」

    2. 客户端依次向这 5 个 Redis 实例发起加锁请求(用前面讲到的 SET 命令),且每个请求会设置超时时间(毫秒级,要远小于锁的有效时间),如果某一个实例加锁失败(包括网络超时、锁被其它人持有等各种异常情况),就立即向下一个 Redis 实例申请加锁

    3. 如果客户端从 >=3 个(大多数)以上 Redis 实例加锁成功,则再次获取「当前时间戳T2」,如果 T2 - T1 < 锁的过期时间,此时,认为客户端加锁成功,否则认为加锁失败

    4. 加锁成功,去操作共享资源(例如修改 MySQL 某一行,或发起一个 API 请求)

    5. 加锁失败,向「全部节点」发起释放锁请求(前面讲到的 Lua 脚本释放锁)

    有 4 个重点:

    1. 客户端在多个 Redis 实例上申请加锁

    2. 必须保证大多数节点加锁成功

    3. 大多数节点加锁的总耗时,要小于锁设置的过期时间

    4. 释放锁,要向全部节点发起释放锁请求

    1) 为什么要在多个实例上加锁?

    本质上是为了「容错」,部分实例异常宕机,剩余的实例加锁成功,整个锁服务依旧可用。

    2) 为什么大多数加锁成功,才算成功?

    多个 Redis 实例一起来用,其实就组成了一个「分布式系统」。

    在分布式系统中,总会出现「异常节点」,所以,在谈论分布式系统问题时,需要考虑异常节点达到多少个,也依旧不会影响整个系统的「正确性」。

    这是一个分布式系统「容错」问题,这个问题的结论是:如果只存在「故障」节点,只要大多数节点正常,那么整个系统依旧是可以提供正确服务的。

    3) 为什么步骤 3 加锁成功后,还要计算加锁的累计耗时?

    因为操作的是多个节点,所以耗时肯定会比操作单个实例耗时更久,而且,因为是网络请求,网络情况是复杂的,有可能存在延迟、丢包、超时等情况发生,网络请求越多,异常发生的概率就越大。

    所以,即使大多数节点加锁成功,但如果加锁的累计耗时已经「超过」了锁的过期时间,那此时有些实例上的锁可能已经失效了,这个锁就没有意义了。

    4) 为什么释放锁,要操作所有节点?

    在某一个 Redis 节点加锁时,可能因为「网络原因」导致加锁失败。

    例如,客户端在一个 Redis 实例上加锁成功,但在读取响应结果时,网络问题导致读取失败,那这把锁其实已经在 Redis 上加锁成功了。

    所以,释放锁时,不管之前有没有加锁成功,需要释放「所有节点」的锁,以保证清理节点上「残留」的锁。

    好了,明白了 Redlock 的流程和相关问题,看似 Redlock 确实解决了 Redis 节点异常宕机锁失效的问题,保证了锁的「安全性」。

    分布式专家 Martin 对于 Relock 的质疑

    在martin的文章中,主要阐述了4个论点:

    分布式锁的目的是什么

    第一:效率

    使用分布式锁的互斥能力,是避免不必要地做同样的工作两次。如果锁失效,并不会带来「恶性」的后果,例如发了 2 次邮件等,无伤大雅。

    第二:正确性

    使用锁用来防止并发进程相互干扰。如果锁失败,会造成多个进程同时操作同一条数据,产生的后果是数据严重错误、永久性不一致、数据丢失等恶性问题,后果严重。

    他认为,如果你是为了前者——效率,那么使用单机版redis就可以了,即使偶尔发生锁失效(宕机、主从切换),都不会产生严重的后果。而使用redlock太重了,没必要。

    而如果是为了正确性,他认为redlock根本达不到安全性的要求,也依旧存在锁失效的问题!

    锁在分布式系统中会遇到的问题

    一个分布式系统,存在着你想不到的各种异常。这些异常场景主要包括三大块,这也是分布式系统会遇到的三座大山:NPC

    • N:Network Delay,网络延迟

    • P:Process Pause,进程暂停(GC)

    • C:Clock Drift,时钟漂移

    martin用一个进程暂停(GC)的例子,指出了redlock安全性的问题:

    1. 客户端1请求锁定节点A、B、C、D、E

    2. 客户端1拿到锁后,进入GC(时间比较久)

    3. 所有redis节点上的锁都过期了

    4. 客户端2获取到了A、B、C、D、E的锁

    5. 客户端1GC结束,认为成功获取了锁

    6. 客户端2也认为获取到了锁。这时候发生了【冲突】

    假设时钟是正确的,这个假设是不合理的

    又或者,当多个Redis节点时钟发生了问题时,也会导致redlock锁失效。

    1. 客户端1获取节点A、B、C上的锁,但由于网络问题,无法访问D和E

    2. 节点C上的时钟向前跳跃,导致锁到期。

    3. 客户端2获取节点C、D、E上的锁,由于网络问题,无法访问A和B

    4. 客户端1和客户端2现在都相信他们自己持有了锁。这时候发生了【冲突】

    在混乱的分布式系统中,你不能假设系统时钟就是对的。

    提出 tencing token的方案,保证正确性

    1. 客户端在获取锁时,锁服务提供一个【递增】的token

    2. 客户端拿着这个token去操作共享资源

    3. 共享资源可以根据这个token拒绝后来者的请求。

    个人理解,相当于在业务层再做一层乐观锁。

    一个好的分布式锁,无论 NPC 怎么发生,可以不在规定时间内给出结果,但并不会给出一个错误的结果。也就是只会影响到锁的「性能」(或称之为活性),而不会影响它的「正确性」。

    Martin 的结论:

    1、Redlock 不伦不类:它对于效率来讲,Redlock 比较重,没必要这么做,而对于正确性来说,Redlock 是不够安全的。

    2、时钟假设不合理:该算法对系统时钟做出了危险的假设(假设多个节点机器时钟都是一致的),如果不满足这些假设,锁就会失效。

    3、无法保证正确性:Redlock 不能提供类似 fencing token 的方案,所以解决不了正确性的问题。为了正确性,请使用有「共识系统」的软件,例如 Zookeeper。

    好了,以上就是 Martin 反对使用 Redlock 的观点,看起来有理有据。

    下面我们来看 Redis 作者 Antirez 是如何反驳的。

    Redis作者Antirez的反驳

    解释时钟问题

    首先,Redis 作者一眼就看穿了对方提出的最为核心的问题:时钟问题

    Redis 作者表示,Redlock 并不需要完全一致的时钟,只需要大体一致就可以了,允许有「误差」。

    例如要计时 5s,但实际可能记了 4.5s,之后又记了 5.5s,有一定误差,但只要不超过「误差范围」锁失效时间即可,这种对于时钟的精度的要求并不是很高,而且这也符合现实环境。

    对于对方提到的「时钟修改」问题,Redis 作者反驳到:

    1. 手动修改时钟:不要这么做就好了,否则你直接修改 Raft 日志,那 Raft 也会无法工作…

    2. 时钟跳跃:通过「恰当的运维」,保证机器时钟不会大幅度跳跃(每次通过微小的调整来完成),实际上这是可以做到的

    解释网络延迟、GC问题

    Redis 作者继续论述,如果对方认为,发生网络延迟、进程 GC 是在客户端确认拿到了锁,去操作共享资源的途中发生了问题,导致锁失效,那这不止是 Redlock 的问题,任何其它锁服务例如 Zookeeper,都有类似的问题,这不在讨论范畴内

    这里我举个例子解释一下这个问题:

    1. 客户端通过 Redlock 成功获取到锁(通过了大多数节点加锁成功、加锁耗时检查逻辑)

    2. 客户端开始操作共享资源,此时发生网络延迟、进程 GC 等耗时很长的情况

    3. 此时,锁过期自动释放

    4. 客户端开始操作 MySQL(此时的锁可能会被别人拿到,锁失效)

    Redis 作者这里的结论就是:

    • 客户端在拿到锁之前,无论经历什么耗时长问题,Redlock 都能够检测出来

    • 客户端在拿到锁之后,发生 NPC,那 Redlock、Zookeeper 都无能为力

    所以,Redis 作者认为 Redlock 在保证时钟正确的基础上,是可以保证正确性的。

    质疑fencing token 机制

    这个方案必须要求要操作的「共享资源服务器」有拒绝「旧 token」的能力。

    例如,要操作 MySQL,从锁服务拿到一个递增数字的 token,然后客户端要带着这个 token 去改 MySQL 的某一行,这就需要利用 MySQL 的「事物隔离性」来做。

    但如果操作的不是 MySQL 呢?例如向磁盘上写一个文件,或发起一个 HTTP 请求,那这个方案就无能为力了,这对要操作的资源服务器,提出了更高的要求。

    也就是说,大部分要操作的资源服务器,都是没有这种互斥能力的。

    再者,既然资源服务器都有了「互斥」能力,那还要分布式锁干什么?

    4、zookeeper实现

    利用 zookeeper 的同级节点的唯一性特性,在需要获取排他锁时,所有的客户端试图通过调用 create() 接口,在 /exclusive_lock 节点下创建临时子节点 /exclusive_lock/lock,最终只有一个客户端能创建成功,那么此客户端就获得了分布式锁。同时,所有没有获取到锁的客户端可以在 /exclusive_lock 节点上注册一个子节点变更的 watcher 监听事件,以便重新争取获得锁。

    锁释放依赖心跳。集群中占用锁的客户端失联时,锁能够被有效释放。一旦占用Znode锁的客户端与ZooKeeper集群服务器失去联系,这个临时Znode也将自动删除

    zookeeper的高可用依赖zab。简单的说就是写入时,半数follower ack,写入成功。

    zk是100%安全的么:

    分析一个例子:

    1. 客户端 1 创建临时节点 /lock 成功,拿到了锁

    2. 客户端 1 发生长时间 GC

    3. 客户端 1 无法给 Zookeeper 发送心跳,Zookeeper 把临时节点「删除」

    4. 客户端 2 创建临时节点 /lock 成功,拿到了锁

    5. 客户端 1 GC 结束,它仍然认为自己持有锁(冲突)

    所以,得出一个结论:一个分布式锁,在极端情况下,不一定是安全的。

    5、我对分布式锁的理解

    1、redis有什么问题

    • 单机情况下,有高可用问题。

    • 集群情况下,有主从延迟问题。写主成功,主挂了,从库没有锁,从库升级为主库后,锁丢失。

    • redlock,5实例网络延迟问题。

    • 锁过期时间不好评估问题。

    • redis依赖锁过期时间,会存在时钟跃迁问题。

    • 客户端1拿到锁,sleep/GC到锁自动过期,此时客户端2也可以拿到锁,客户端1也认为自己有锁。锁冲突问题。

    2、机器时钟发生偏移是不可避免的

    3、redlock尽量不要使用,复杂度太高,用一个问题解决另一个问题,这本身就是问题。

    redlock运维成本也比较高。单机有高可用问题。所以还是主从+哨兵这样的部署方式会好一些。

    4、工作时如何使用分布式锁

    redis的缺点是:不是100%可靠。

    mysql的缺点是:扛不住高流量请求。

    可以二者结合,先用redis做分布式锁,扛住大部分流量缓解mysql压力。最后一定要用mysql做兜底保证100%的正确性。

    相关文章

      网友评论

          本文标题:分布式锁

          本文链接:https://www.haomeiwen.com/subject/zfyfdrtx.html