美文网首页
Redis-分布式锁

Redis-分布式锁

作者: 爱钓鱼的码农 | 来源:发表于2022-02-23 10:34 被阅读0次

    分布式锁的由来

    现在的业务应用通常都是微服务架构,这也意味着一个应用会部署多个进程,那这多个进程如果需要修改 MySQL 中的同一行记录时,为了避免操作乱序导致数据错误,此时,我们就需要引入「分布式锁」来解决这个问题了。想要实现分布式锁,必须借助一个外部系统,所有进程都去这个系统上申请「加锁」。而这个外部系统,必须要实现「互斥」的能力,即两个请求同时进来,只会给一个进程返回成功,另一个返回失败(或等待)。这个外部系统,可以是 MySQL,也可以是 Redis 或 Zookeeper。但为了追求更好的性能,我们通常会选择使用 Redis 或 Zookeeper 来做。
    

    如何实现分布式锁

    想要实现分布式锁,必须要求 Redis 有「互斥」的能力,我们可以使用 SETNX 命令,这个命令表示SET if Not eXists,即如果 key 不存在,才会设置它的值,否则什么也不做。两个客户端进程可以执行这个命令,达到互斥,就可以实现一个分布式锁。
    
    客户端 1 申请加锁,加锁成功:
    127.0.0.1:6379> SETNX lock 1
    (integer) 1     // 客户端1,加锁成功
    
    客户端 2 申请加锁,因为它后到达,加锁失败:
    127.0.0.1:6379> SETNX lock 1
    (integer) 0     // 客户端2,加锁失败
    
    此时,加锁成功的客户端,就可以去操作「共享资源」,例如,修改 MySQL 的某一行数据,或者调用一个 API 请求。操作完成后,还要及时释放锁,给后来者让出操作共享资源的机会。如何释放锁呢?也很简单,直接使用 DEL 命令删除这个 key 即可:
    
    127.0.0.1:6379> DEL lock // 释放锁
    (integer) 1
    

    但是,它存在一个很大的问题,当客户端 1 拿到锁后,如果发生下面的场景,就会造成「死锁」:
    - 程序处理业务逻辑异常,没及时释放锁
    - 进程挂了,没机会释放锁

    避免死锁的方案

     在申请锁时,给这把锁设置一个「租期」。在 Redis 中实现时就是给这个 key 设置一个「过期时间」。这里我们假设操作共享资源的时间不会超过 10s,那么在加锁时,给这个 key 设置 10s 过期即可:
    
    127.0.0.1:6379> SETNX lock 1    // 加锁
    (integer) 1
    127.0.0.1:6379> EXPIRE lock 10  // 10s后自动过期
    (integer) 1
    
    这样一来,无论客户端是否异常,这个锁都可以在 10s 后被「自动释放」,其它客户端依旧可以拿到锁。如果这样操作的话加锁、设置过期是 2 条命令,有没有可能只执行了第一条,第二条却「来不及」执行的情况发生呢?例如:
    - SETNX 执行成功,执行 EXPIRE 时由于网络问题执行失败
    - SETNX 执行成功,Redis 异常宕机,EXPIRE 没有机会执行
    - SETNX 执行成功,客户端异常崩溃,EXPIRE 也没有机会执行
    总之,这两条命令不能保证是原子操作(一起成功),就有潜在的风险导致过期时间设置失败,依旧发生「死锁」问题。
    

    在 Redis 2.6.12 版本之前,我们需要想尽办法,保证 SETNX 和 EXPIRE 原子性执行,还要考虑各种异常情况如何处理。但在 Redis 2.6.12 之后,Redis 扩展了 SET 命令的参数,用这一条命令就可以了:

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

    这样就解决了死锁问题,也比较简单。
    试想这样一种场景:
    客户端 1 加锁成功开始操作共享资源,客户端 1 操作共享资源的时间,「超过」了锁的过期时间,锁被「自动释放」
    客户端 2 加锁成功,开始操作共享资源
    客户端 1 操作共享资源完成,释放锁(但释放的是客户端 2 的锁)
    这里存在两个严重的问题:
    - 锁过期:客户端 1 操作共享资源耗时太久,导致锁被自动释放,之后被客户端 2 持有
    - 释放别人的锁:客户端 1 操作共享资源完成后,却又释放了客户端 2 的锁

    第一个问题,可能是我们评估操作共享资源的时间不准确导致的。
    例如,操作共享资源的时间「最慢」可能需要 15s,而我们却只设置了 10s 过期,那这就存在锁提前过期的风险。过期时间太短,那增大冗余时间,例如设置过期时间为 20s,这样总可以了吧?这样确实可以「缓解」这个问题,降低出问题的概率,但依旧无法「彻底解决」问题。原因在于,客户端在拿到锁之后,在操作共享资源时,遇到的场景有可能是很复杂的,例如,程序内部发生异常、网络请求超时等等。既然是「预估」时间,也只能是大致计算,除非你能预料并覆盖到所有导致耗时变长的场景,但这其实很难。
    
    第二个问题在于,一个客户端释放了其它客户端持有的锁。
    重点在于,每个客户端在释放锁时,都是「无脑」操作,并没有检查这把锁是否还「归自己持有」,所以就会发生释放别人锁的风险,这样的解锁流程,很不「严谨」!
    

    如何解决锁的唯一性

    解决办法是:客户端在加锁时,设置一个只有自己知道的「唯一标识」进去。例如,可以是自己的线程 ID,也可以是一个 UUID(随机且唯一)。
    
    // 锁的VALUE设置为UUID
    // 假设 20s 操作共享时间完全足够,先不考虑锁自动过期的问题。
    127.0.0.1:6379> SET lock $uuid EX 20 NX
    OK
    
    在释放锁时,要先判断这把锁是否还归自己持有再释放锁操作。
    if redis.get("lock") == $uuid:
        redis.del("lock")
    

    这里释放锁使用的是 GET + DEL 两条命令,这时又会遇到我们前面讲的原子性问题了。
    - 客户端 1 执行 GET,判断锁是自己的
    - 客户端 2 执行了 SET 命令,强制获取到锁(虽然发生概率比较低,但我们需要严谨地考虑锁的安全性模型)
    - 客户端 1 执行 DEL,却释放了客户端 2 的锁
    由此可见,这两个命令还是必须要原子执行才行。
    我们可以把这个逻辑,写成 Lua 脚本,让 Redis 来执行。因为 Redis 处理每一个请求是「单线程」执行的,在执行一个 Lua 脚本时,其它请求必须等待,直到这个 Lua 脚本处理完成,这样一来,GET + DEL 之间就不会插入其它命令了。

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

    基于 Redis 实现的分布式锁,一个严谨的的流程如下:
    - 加锁:SET lock_key unique_id EXexpire_time NX
    - 操作共享资源
    - 释放锁:Lua 脚本,先 GET 判断锁是否归属自己,再 DEL 释放锁

    如何评估锁过期的时间

    相关文章

      网友评论

          本文标题:Redis-分布式锁

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