Redis 实现分布式锁可没有你想象的那么简单

前言

上篇又说到就是最近不写 Redis 系列相关的了,但是由于在纠结选择Sping或者Netty。一时间就想起来还有这一篇相关的东西没有弄,也当成是一个过渡期。讲道理,这一篇不管是面试还是实际工作的试用都是非常有必要了解并且弄懂得,但是要看懂这篇的话需要的基础是懂好多我以前弄的一些知识点,如果好哥哥们在看的过程中有不熟悉的点可以看 Redis 高级进阶知识点大全,基本上 Reids 的东西都有了。觉得不错的话给猛男我点个赞加下关注啥的。
关注

普通锁

首先锁是置于可启闭的器物上,以钥匙或暗码开启,像门锁、密码锁等。从我们日常生活中看,正常每个门都会有一把锁(不正常的就不要杠了),如果没有配置锁的话,什么样的人都可以进去(天下大同就是这种情况吧)。很显然这样是很不安全的,所以在这里引出了锁。

那在程序中锁是个什么样子呢?在程序中,锁一般出现在多线程的环境中。当多个线程访问同一个共享资源时,需要某种机制来保证只有满足某个条件(获取锁成功)的线程才能访问到资源,而不满足条件(获取锁失败)的线程只能等待,在下一轮竞争中来获取锁才能访问资源。所以锁是一种用来解决多个执行线程,访问共享资源时出现错误或数据不一致问题的工具。

实现方式

在这里需要注意的是,下面所有说的实现方式都是用Java实现,当然也不会对实现方式进行原理解析(后面可以弄)。

  1. 使用synchronized 关键字。synchronized是一个隐式锁,因为其解锁和锁定的操作是由 JVM 通过对象的 monitor 监视器锁自动完成的,我们也无法插手整个上锁和解锁的过程。
  2. 使用java.util.concurrent.locks.Lock的实现类,例如java.util.concurrent.locks.ReentrantLock。这种方式的话就是显示锁了,需要手动上锁和解锁。
  3. 使用CAS(Compare And Swap) 无锁机制,这个的话实际上属于乐观锁机制,核心概念就是比较并交换。在执行CAS操作的时候,将内存位置的值与预期原值比较,如果相匹配,那么处理器会自动将该位置值更新为新值。否则,处理器不做任何操作。

分布式锁

首先在分布式、微服务架构中,我们的项目通常是以下图模式来部署的,当然网关nginx也都是存在集群的。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-EoMqryKo-1610934526990)(https://p3-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/a2f0bde2531b4eb2bafbcaf2862a5d70~tplv-k3u1fbpfcp-zoom-1.image)]
上图可以看到,变量A存在三个服务器内存中(这个变量A主要体现是在一个类中的一个成员变量,是一个有状态的对象)。假设我们使用的是普通锁,由于三台服务器之间变量互不可见,当多个请求调用时,可能就会出现操作三个不同内存区域的数据,读取的数据也可能会与设值得不一致。

基于以上问题,就出现了所谓的分布式锁(也不知道这个名字的作者是谁)的概念。其实简单点来说,就是在请求我们的接口时加上一个额外的步骤,只是这个步骤需要一个中间的媒介,比如像RedisZooKeeper 甚至使用Mysql。这个中间媒介的作用就是相当于一个标记,标记了当前是谁获取到了这个锁。整体的过程就是请求来了,先调用判断中间媒介是否存在,不存在则尝试获取,获取成功或者失败后则和普通锁处理逻辑一样。

实现方式

实现方式这一块上面也说了,主要需要一个中间的媒介。

  1. 基于Redis,不熟悉 Redis 的可以看 你不知道的 Redis 高级进阶知识点大全,也是今天的主题。
  2. 基于数据库,一般数据都有实现悲观锁,当然也可以用版本号或者其他乐观锁来实现,悲观锁的话主要还是 for update 关键字
  3. 基于Zookeeper,原理就是利用了Zookeeper的临时节点来实现。

推荐的话使用Zookeeper来实现分布式锁,主要是因为Zookeeper相关的特性非常适合用来做分布式锁,后续再来讲这一块的东西。当然,前提是项目中有使用到Zookeeper,不然就光为了实现一个分布式锁而搭建一套Zookeeper的集群那就得不偿失了。Redis则不一样,基本上的项目都会使用,当时使用Redis会有一些问题,所以才推荐使用Zookeeper

Redis 实现

唯一性

使用 Redis 来实现锁的唯一性话主要是使用SETNX(SET if Not eXists)命令,这个命令的话就是当指定的 key 不存在时,为 key 设置指定的值。设置成功则返回 1 。 设置失败则返回 0

哎,这固然解决了上面的说的做个标记,当多个请求来时只有一个能通过SETNX(SET if Not eXists)设置成功。但是的但是,如果这个请求报错了、死循环了或者整个项目直接宕掉了,那后面的所有线程是不是都不能设置成功了呢? 这里的话就引出了第二个问题点,怎么防止死锁。

锁超时

在 Redis 中防止死锁可以使用设置key的超时时间。讲道理,这个超时时间是必须要设置的,Redis 也提供了expire 命令来对key设置过期时间。那这样是不是就可以解决了呢?

答案是否定的,为什么呢?如果好哥哥们有看过我的关于 Redis 系列的文章都知道一个点,那就是 Reids 是单线程模型,关于这个这里的话就不详细说了,可以看 你不知道的 Redis 高级进阶知识点大全。这篇里面有个目录,翻一翻里面的文章就知道了。由于这个模型所以导致了 Redis 整个命令的执行都是要放到队列里面去的,然后依次排队执行。这也就是说当执行setnxexpire时,由于是两条命令,所以会有两次请求与响应的步骤,这个时候就会产生原子性的问题。比如设置完setnx后程序直接宕掉,这是不是又回到了上一个问题。那怎么解决两条命令的原则性问题呢? 好哥哥们往下看。

命令原子性

解决上述问题最简单的办法那就是将两条命令合成一条,那 Redis 支持这种模式吗?答案是肯定的,我们可以使用 Pipeline Redis 事务 Lua 脚本这三种方式来实现。但是PipelineRedis 事务都是不具备原子性的(这个点文章进去看看明白了),所以我们只能使用Lua 脚本了。有不熟悉 Redis 怎么使用Lua 脚本的建议先看完上面的文章。

这里贴一个加锁的Lua,这里实际上是一个字符串。

"if (redis.call('exists', KEYS[1]) == 0) then " +
                        "redis.call('hincrby', KEYS[1], ARGV[2], 1); " +
                        "redis.call('pexpire', KEYS[1], ARGV[1]); " +
                        "return nil; " +
                        "end; " +
                        "if (redis.call('hexists', KEYS[1], ARGV[2]) == 1) then " +
                        "redis.call('hincrby', KEYS[1], ARGV[2], 1); " +
                        "redis.call('pexpire', KEYS[1], ARGV[1]); " +
                        "return nil; " +
                        "end; " +
                        "return redis.call('pttl', KEYS[1]);"

总的来说就是使用Lua 脚本来保证多条命令的原子性问题。那上面死锁的解决了,是不是就完美了呢?并没有想象的那么完美,好哥哥们想这么一个问题,假如给锁加的超时时间是5秒钟,但是我们在做逻辑操作时,可能因为需要调用别人的接口或者查询数据库出现慢查询超过了5秒,正常逻辑是当前线程还需要持有这把锁。然后这时锁过期失效了,其他线程就能竞争获取锁,是不是又出现了另外一个线程不安全的问题?

Redisson 原理解析

要解决上面逻辑还未执行完而锁已经过期失效的问题需要借助Redisson这个框架来解决,实际上 Redis 并没有对这一块提供相关的支持,因为做不到。只能通过应用程序编写对应的业务逻辑来实现,像上面锁超时、唯一性和原子性的问题,Redisson已经有整合过了。在我们平常选择用 Redis 来做分布式锁的解决方案的话建议还是使用这个框架,不然的话需要考虑很多分布式环境并发下的问题。

原理

整个Redisson加锁的过程如下:
redisson执行原理
整个流程就不用文字来表达了,基本上看图就能明白了。这里的话列举一下需要注意的点,如下:

  1. 加锁机制浴巾上面提到的SETNX指令和Lua脚本来实现
  2. watch dog自动延期机制,就是会启动一个后台线程,定时比如说10秒检测当前线程是否还持有了锁,是的话就延期。这个的话就是解决上面逻辑还未执行完而锁已经过期失效的问题。但是是不建议开启的,这个还是很耗资源和性能的。
  3. 可重入性的话Redisson存储锁的数据类型是 Hash 类型,并且Hash数据类型的key值包含了当前线程信息。
  4. 互斥性是通过 Redis 数据结构来保证分布式锁的唯一性。
  5. 避免死锁是通过 Redis 对key设置超时时间来保证。

存在的问题

是的,到这里还没有完。通过Redisson是实现分布式锁依然会存在单点/多点,这里主要是关于部署方式上。使用单点的话 Redis 宕机了,那就没什么说的了,直接加不了锁咯,这个不管用哪个中间媒介都解决不了。

多点的话主要是哨兵和Cluster集群(不熟悉的也可以从 Redis 高级进阶知识点大全 中找找)。假设客户端 1 向某个master节点写入了Redisson锁,此时会异步复制给对应的 slave节点。但是这个过程中一旦发生 master节点宕机,主备切换,slave节点从变为了 master节点。这时客户端 2 来尝试加锁的时候,由于客户端 1 已经宕机的master节点加锁成功,而客户端 2 在新的master节点上也能加锁,此时就会导致多个客户端对同一个分布式锁完成了加锁。

解决方案

  1. 不解决,不要打我(手动狗头),解决不了我就加入他。这个还真的不好弄,目前没有想到合适的方式来处理。
  2. Zookeeper实现,抛弃它(使用这种方式的都是渣男)。这也是我推荐使用Zookeeper来实现分布式锁的原因,好哥哥们是不是也发现了,使用 Redis 来实现分布式锁是真的复杂,各类问题太多了。

应用场景

  1. 避免不同节点重复的执行某一块逻辑,比如说我们的定时任务,不加锁的情况下在某一时间点这个定时任务可能会执行多次。
  2. 防止表单重复提交,这个其实也是会有问题。
  3. 避免破坏数据的正确性,当多个线程同时某一块逻辑时,可能会出现数据的错乱或者不一致。

本期就到这啦,欢迎好哥哥们在评论区留言,另外求关注、求点赞

扫描二维码,关注安安酱,打开不一样的程序世界。安安酱,一个热衷于分享技术干货的公众号。
公众号

猜你喜欢

转载自blog.csdn.net/qq_34090008/article/details/112762288