MySQL和Redis实现分布式锁

MySQL和Redis实现分布式锁

  • 1. 为何需要分布式锁
  • 2. 分布式锁的一些特点
  • 3. 常见的分布式锁
  • 4. Mysql实现分布式锁
    • 4.1 lock()
    • 4.2 tryLock()和tryLock(long timeout)
    • 4.3 unlock()
    • 4.4 锁超时
    • 4.5 MySQL实现方式小结
    • 4.6 乐观锁
  • 5. Redis实现分布式锁
    • 5.1 使用setnx实现
    • 5.2 使用Redission来实现
    • 5.3 RedLock
    • 5.4 Redis小结

在Java中synchronized关键字和ReentrantLock可重入锁在我们的代码中是经常见的,一般我们用其在多线程环境中控制对资源的并发访问,但是随着分布式的快速发展,本地的加锁往往不能满足我们的需要,在我们的分布式环境中上面加锁的方法就会失去作用。

1. 为何需要分布式锁

  • 效率:使用分布式锁可以避免不同节点重复相同的工作,这些工作会浪费资源。比如用户付了钱之后有可能不同节点会发出多封短信。
  • 正确性:加分布式锁同样可以避免破坏正确性的发生,如果两个节点在同一条数据上面操作,比如多个节点机器对同一个订单操作不同的流程有可能会导致该笔订单最后状态出现错误,造成损失。

2. 分布式锁的一些特点

当我们确定了在不同节点上需要分布式锁,那么我们需要了解分布式锁到底应该有哪些特点:

  • 互斥性:和我们本地锁一样互斥性是最基本,但是分布式锁需要保证在不同节点的不同线程的互斥。
  • 可重入性:同一个节点上的同一个线程如果获取了锁之后那么也可以再次获取这个锁。
  • 锁超时:和本地锁一样支持锁超时,防止死锁。
  • 高效,高可用:加锁和解锁需要高效,同时也需要保证高可用防止分布式锁失效,可以增加降级。
  • 支持阻塞和非阻塞:和ReentrantLock一样支持lock和trylock以及tryLock(long timeOut)。
  • 支持公平锁和非公平锁(可选):公平锁的意思是按照请求加锁的顺序获得锁,非公平锁就相反是无序的。这个一般来说实现的比较少。

为了确保分布式锁可用,我们至少要确保锁的实现同时满足以下四个条件: 1互斥性。在任意时刻,只有一个客户端能持有锁。 2不会发生死锁。即使有一个客户端在持有锁的期间崩溃而没有主动解锁,也能保证后续其他客户端能加锁。 3具有容错性。只要大部分的Redis节点正常运行,客户端就可以加锁和解锁。 4解铃还须系铃人。加锁和解锁必须是同一个客户端,客户端自己不能把别人加的锁给解了。

3. 常见的分布式锁

我们了解了一些特点之后,我们一般实现分布式锁有以下几个方式:

  • MySql
  • Zk
  • Redis
  • 自研分布式锁:如谷歌的Chubby。

下面介绍一下使用MySQL和Redis实现的分布式锁。

4. Mysql实现分布式锁

首先来说一下Mysql分布式锁的实现原理,相对来说这个比较容易理解,毕竟数据库和我们开发人员在平时的开发中息息相关。对于分布式锁我们可以创建一个锁表:
MySQL和Redis实现分布式锁_第1张图片
前面我们所说的lock(),trylock(long timeout),trylock()这几个方法可以用下面的伪代码实现。

4.1 lock()

lock一般是阻塞式的获取锁,意思就是不获取到锁誓不罢休,那么我们可以写一个死循环来执行其操作:
MySQL和Redis实现分布式锁_第2张图片
mysqlLock.lcok内部是一个sql,为了达到可重入锁的效果那么我们应该先进行查询,如果有值,那么需要比较node_info是否一致,这里的node_info可以用机器IP和线程名字来表示,如果一致那么就加可重入锁count的值,如果不一致那么就返回false。如果没有值那么直接插入一条数据。伪代码如下:
MySQL和Redis实现分布式锁_第3张图片
需要注意的是这一段代码需要加事务,必须要保证这一系列操作的原子性。

4.2 tryLock()和tryLock(long timeout)

tryLock()是非阻塞获取锁,如果获取不到那么就会马上返回,代码可以如下:
MySQL和Redis实现分布式锁_第4张图片
tryLock(long timeout)实现如下:
MySQL和Redis实现分布式锁_第5张图片

4.3 unlock()

unlock的话如果这里的count为1那么可以删除,如果大于1那么需要减去1。
MySQL和Redis实现分布式锁_第6张图片

4.4 锁超时

我们有可能会遇到我们的机器节点挂了,那么这个锁就不会得到释放,我们可以启动一个定时任务,通过计算一般我们处理任务的一般的时间,比如是5ms,那么我们可以稍微扩大一点,当这个锁超过20ms没有被释放我们就可以认定是节点挂了然后将其直接释放。

4.5 MySQL实现方式小结

  • 适用场景: Mysql分布式锁一般适用于资源不存在数据库,如果数据库存在比如订单,那么可以直接对这条数据加行锁,不需要我们上面多的繁琐的步骤,比如一个订单,那么我们可以用select * from order_table where id = ‘xxx’ for update进行加行锁,那么其他的事务就不能对其进行修改。
  • 优点:理解起来简单,不需要维护额外的第三方中间件(比如Redis,Zk)。
  • 缺点:虽然容易理解但是实现起来较为繁琐,需要自己考虑锁超时,加事务等等。性能局限于数据库,一般对比缓存来说性能较低。对于高并发的场景并不是很适合。

4.6 乐观锁

前面我们介绍的都是悲观锁,这里想额外提一下乐观锁,在我们实际项目中也是经常实现乐观锁,因为我们加行锁的性能消耗比较大,通常我们会对于一些竞争不是那么激烈,但是其又需要保证我们并发的顺序执行使用乐观锁进行处理,我们可以对我们的表加一个版本号字段,那么我们查询出来一个版本号之后,update或者delete的时候需要依赖我们查询出来的版本号,判断当前数据库和查询出来的版本号是否相等,如果相等那么就可以执行,如果不等那么就不能执行。这样的一个策略很像我们的CAS(Compare And Swap),比较并交换是一个原子操作。这样我们就能避免加select * for update行锁的开销。

5. Redis实现分布式锁

5.1 使用setnx实现

利用Redis的setnx命令。此命令同样是原子性操作,只有在key不存在的情况下,才能set成功。

最简单的方法是使用setnx命令。key是锁的唯一标识,按业务来决定命名。比如想要给一种商品的秒杀活动加锁,可以给key命名为 “lock_sale_商品ID” 。而value设置成什么呢?我们可以姑且设置成1。加锁的伪代码如下:
setnx(key,1,timeout);
当一个线程执行setnx返回1,说明key原本不存在,该线程成功得到了锁;当一个线程执行setnx返回0,说明key已经存在,该线程抢锁失败。

显示释放锁的方式 del “lock_sale_商品ID”;

因为直接加锁后可能会存在服务宕机的情况,那么需要设置过期时间。但是不适合先setnx,成功之后再执行expire操作,因为加锁和设置过期时间不是原子操作,并且存在极端情况在加锁成功后设置过期时间之前服务宕机,那么会造成死锁的情况。所以在获取锁时就添加过期时间。

架设A线程获取到了锁,但是设置的过期时间之内,A的任务还没有执行完,没有显示声明释放锁,若此时redis删除了过期的锁,那么其他线程将也会获取到锁,这里与锁的排他性相违背。

此时可以使用一个守护线程,在线程A获取到锁的时候,开启守护线程,假设获取锁时设置的过期时间为30s,那么在29s的时候守护线程询问A线程释放执行完了任务,没有执行完的话则将锁的过期时间延长20s,并且在过19s后来询问A线程是否执行完任务。

此处来讨论value的存值。因为还需要尽量实现可重入锁,那么在value中我们可以存上加锁的线程信息和节点信息以及加锁的次数,在setnx失败时,先查看是否是本线程加的锁,若是,将加锁次数加1,对应的释放锁时将加锁次数减1,直至减为0时显示删除锁 。当然上述操作查询和修改不在同一个操作中完成,我们可以借助Lua脚本来实现。

5.2 使用Redission来实现

在Java中,Jedis是Redis的Java实现的客户端,其API提供了比较全面的Redis命令的支持。Redission也是Redis的客户端,相比于Jedis功能简单。Jedis简单使用阻塞的I/O和redis交互,Redission通过Netty支持非阻塞I/O。Jedis最新版本2.9.0是2016年的快3年了没有更新,而Redission最新版本是2018.10月更新。
Redission封装了锁的实现,其继承了java.util.concurrent.locks.Lock的接口,让我们像操作我们的本地Lock一样去操作Redission的Lock,下面介绍一下其如何实现分布式锁。
MySQL和Redis实现分布式锁_第7张图片
Redission不仅提供了Java自带的一些方法(lock,tryLock),还提供了异步加锁,对于异步编程更加方便。 由于内部源码较多,就不贴源码了,这里用文字叙述来分析他是如何加锁的,这里分析一下tryLock方法:

  1. 尝试加锁:首先会尝试进行加锁,由于保证操作是原子性,那么就只能使用lua脚本,相关的lua脚本如下:
    MySQL和Redis实现分布式锁_第8张图片
    可以看见他并没有使用我们的sexNx来进行操作,而是使用的hash结构,我们的每一个需要锁定的资源都可以看做是一个HashMap,锁定资源的节点信息是Key,锁定次数是value。通过这种方式可以很好的实现可重入的效果,只需要对value进行加1操作,就能进行可重入锁。当然这里也可以用之前我们说的本地计数进行优化。
  2. 如果尝试加锁失败,判断是否超时,如果超时则返回false。
  3. 如果加锁失败之后,没有超时,那么需要在名字为redisson_lock__channel+lockName的channel上进行订阅,用于订阅解锁消息,然后一直阻塞直到超时,或者有解锁消息。
  4. 重试步骤1,2,3,直到最后获取到锁,或者某一步获取锁超时。

对于我们的unlock方法比较简单也是通过lua脚本进行解锁,如果是可重入锁,只是减1。如果是非加锁线程解锁,那么解锁失败。
MySQL和Redis实现分布式锁_第9张图片
Redission还有公平锁的实现,对于公平锁其利用了list结构和hashset结构分别用来保存我们排队的节点,和我们节点的过期时间,用这两个数据结构帮助我们实现公平锁,这里就不展开介绍了,有兴趣可以参考源码。

5.3 RedLock

我们想象一个这样的场景当机器A申请到一把锁之后,如果Redis主宕机了,这个时候从机并没有同步到这一把锁,那么机器B再次申请的时候就会再次申请到这把锁,为了解决这个问题Redis作者提出了RedLock红锁的算法,在Redission中也对RedLock进行了实现。
MySQL和Redis实现分布式锁_第10张图片
通过上面的代码,我们需要实现多个Redis集群,然后进行红锁的加锁,解锁。具体的步骤如下:

首先生成多个Redis集群的Rlock,并将其构造成RedLock。
依次循环对三个集群进行加锁,加锁的过程和5.2里面一致。
如果循环加锁的过程中加锁失败,那么需要判断加锁失败的次数是否超出了最大值,这里的最大值是根据集群的个数,比如三个那么只允许失败一个,五个的话只允许失败两个,要保证多数成功。
加锁的过程中需要判断是否加锁超时,有可能我们设置加锁只能用3ms,第一个集群加锁已经消耗了3ms了。那么也算加锁失败。
3,4步里面加锁失败的话,那么就会进行解锁操作,解锁会对所有的集群在请求一次解锁。

可以看见RedLock基本原理是利用多个Redis集群,用多数的集群加锁成功,减少Redis某个集群出故障,造成分布式锁出现问题的概率。

5.4 Redis小结

  • 优点:对于Redis实现简单,性能对比ZK和Mysql较好。如果不需要特别复杂的要求,那么自己就可以利用setNx进行实现,如果自己需要复杂的需求的话那么可以利用或者借鉴Redission。对于一些要求比较严格的场景来说的话可以使用RedLock。
  • 缺点:需要维护Redis集群,如果要实现RedLock那么需要维护更多的集群。

整理:
https://juejin.im/post/5b16148a518825136137c8db
https://juejin.im/post/5bbb0d8df265da0abd3533a5#heading-19

你可能感兴趣的:(并发编程,中间件,Redis)