当我们确定了在不同节点上需要分布式锁,那么我们需要了解分布式锁到底应该有哪些特点:
为了确保分布式锁可用,我们至少要确保锁的实现同时满足以下四个条件: 1互斥性。在任意时刻,只有一个客户端能持有锁。 2不会发生死锁。即使有一个客户端在持有锁的期间崩溃而没有主动解锁,也能保证后续其他客户端能加锁。 3具有容错性。只要大部分的Redis节点正常运行,客户端就可以加锁和解锁。 4解铃还须系铃人。加锁和解锁必须是同一个客户端,客户端自己不能把别人加的锁给解了。
我们了解了一些特点之后,我们一般实现分布式锁有以下几个方式:
下面介绍一下使用MySQL和Redis实现的分布式锁。
首先来说一下Mysql分布式锁的实现原理,相对来说这个比较容易理解,毕竟数据库和我们开发人员在平时的开发中息息相关。对于分布式锁我们可以创建一个锁表:
前面我们所说的lock(),trylock(long timeout),trylock()这几个方法可以用下面的伪代码实现。
lock一般是阻塞式的获取锁,意思就是不获取到锁誓不罢休,那么我们可以写一个死循环来执行其操作:
mysqlLock.lcok内部是一个sql,为了达到可重入锁的效果那么我们应该先进行查询,如果有值,那么需要比较node_info是否一致,这里的node_info可以用机器IP和线程名字来表示,如果一致那么就加可重入锁count的值,如果不一致那么就返回false。如果没有值那么直接插入一条数据。伪代码如下:
需要注意的是这一段代码需要加事务,必须要保证这一系列操作的原子性。
tryLock()是非阻塞获取锁,如果获取不到那么就会马上返回,代码可以如下:
tryLock(long timeout)实现如下:
unlock的话如果这里的count为1那么可以删除,如果大于1那么需要减去1。
我们有可能会遇到我们的机器节点挂了,那么这个锁就不会得到释放,我们可以启动一个定时任务,通过计算一般我们处理任务的一般的时间,比如是5ms,那么我们可以稍微扩大一点,当这个锁超过20ms没有被释放我们就可以认定是节点挂了然后将其直接释放。
前面我们介绍的都是悲观锁,这里想额外提一下乐观锁,在我们实际项目中也是经常实现乐观锁,因为我们加行锁的性能消耗比较大,通常我们会对于一些竞争不是那么激烈,但是其又需要保证我们并发的顺序执行使用乐观锁进行处理,我们可以对我们的表加一个版本号字段,那么我们查询出来一个版本号之后,update或者delete的时候需要依赖我们查询出来的版本号,判断当前数据库和查询出来的版本号是否相等,如果相等那么就可以执行,如果不等那么就不能执行。这样的一个策略很像我们的CAS(Compare And Swap),比较并交换是一个原子操作。这样我们就能避免加select * for update行锁的开销。
利用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脚本来实现。
在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,下面介绍一下其如何实现分布式锁。
Redission不仅提供了Java自带的一些方法(lock,tryLock),还提供了异步加锁,对于异步编程更加方便。 由于内部源码较多,就不贴源码了,这里用文字叙述来分析他是如何加锁的,这里分析一下tryLock方法:
对于我们的unlock方法比较简单也是通过lua脚本进行解锁,如果是可重入锁,只是减1。如果是非加锁线程解锁,那么解锁失败。
Redission还有公平锁的实现,对于公平锁其利用了list结构和hashset结构分别用来保存我们排队的节点,和我们节点的过期时间,用这两个数据结构帮助我们实现公平锁,这里就不展开介绍了,有兴趣可以参考源码。
我们想象一个这样的场景当机器A申请到一把锁之后,如果Redis主宕机了,这个时候从机并没有同步到这一把锁,那么机器B再次申请的时候就会再次申请到这把锁,为了解决这个问题Redis作者提出了RedLock红锁的算法,在Redission中也对RedLock进行了实现。
通过上面的代码,我们需要实现多个Redis集群,然后进行红锁的加锁,解锁。具体的步骤如下:
首先生成多个Redis集群的Rlock,并将其构造成RedLock。
依次循环对三个集群进行加锁,加锁的过程和5.2里面一致。
如果循环加锁的过程中加锁失败,那么需要判断加锁失败的次数是否超出了最大值,这里的最大值是根据集群的个数,比如三个那么只允许失败一个,五个的话只允许失败两个,要保证多数成功。
加锁的过程中需要判断是否加锁超时,有可能我们设置加锁只能用3ms,第一个集群加锁已经消耗了3ms了。那么也算加锁失败。
3,4步里面加锁失败的话,那么就会进行解锁操作,解锁会对所有的集群在请求一次解锁。
可以看见RedLock基本原理是利用多个Redis集群,用多数的集群加锁成功,减少Redis某个集群出故障,造成分布式锁出现问题的概率。
整理:
https://juejin.im/post/5b16148a518825136137c8db
https://juejin.im/post/5bbb0d8df265da0abd3533a5#heading-19