文章目录


一、前言

对于锁大家肯定不会陌生,在Java中synchronized关键字和ReentrantLock可重入锁在我们的代码中是经常见的,一般我们用其在多线程环境中控制对资源的并发访问,但是随着分布式的快速发展,本地的加锁往往不能满足我们的需要,在我们的分布式环境中上面加锁的方法就会失去作用。于是人们为了在分布式环境中也能实现本地锁的效果,也是纷纷各出其招,今天让我们来聊一聊一般分布式锁实现的套路。

二、分布式锁

2.1 为何需要分布式锁

问题:多个系统同时操作(并发)Redis带来的数据问题?并发系统如下:

三仙过海,各显神通_redis

系统A、B、C三个系统(A 下单、B 支付、C 退款),分别去操作Redis的同一个Key,本来顺序是1,2,3是正常的,但是因为系统A网络突然抖动了一下,B,C在他前面操作了Redis,这样数据不就错了么。就好比A下单,B支付,C退款三个顺序你变了,变成B支付、C退款、A下单,订单还没生成你却支付,退款了,明显走不通了,如何解决?

回答:使用分布式锁解决,单体系统中只有一个tomcat,直接用单个锁解决,分布式系统需要使用到分布式锁,把多个系统一起锁住,mysql、redis、zookeeper都可以实现分布式锁。

在传统Java并发问题中,要保证多线程安全,就要实现线程互斥(synchronized和lock)和线程通信(标志位+wait+notify()/notifyAll() 或 标志位+await()+signal()/signalAll())。现在在redis中,使用分布式锁,将各个系统看做各个线程,redis看做服务端程序,Java并发中,各个线程可以访问java程序中的共享变量,即非ThreadLocal变量;在redis中,各个系统可以访问redis中的共享变量,一个意思。

分布式锁保证互斥:某个时刻,多个系统实例都去更新某个 key。可以基于 MySQL/Zookeeper/Redis 实现资源互斥。MySQL的行记录是互斥资源,Zookeeper的节点是互斥资源,Redis的setNx命令保证key胡扯,通过争夺互斥资源确保同一时间,只能有一个系统实例在操作某个 Key,别人都不允许读和写。

分布式锁保证通信:

时间戳保证通信(理论):要写入缓存的数据,都得写入 MySQL 中进行持久化,写入 MySQL 中的时候必须保存一个时间戳,从 MySQL 查出来的时候,时间戳也查出来。

时间戳保证通信(实践):每次要写之前,先判断一下当前这个 Value 的时间戳是否比缓存里的 Value 的时间戳要新,如果是的话,那么可以写,否则,就不能用旧的数据覆盖新的数据;每次读操作,读取时间戳,给下一次写对比数据库中时间戳用的。

分布式锁的两个作用:

第一,效率:使用分布式锁可以避免不同节点重复相同的工作,这些工作会浪费资源。比如用户付了钱之后有可能不同节点会发出多封短信。

第二,正确性:加分布式锁同样可以避免破坏正确性的发生,如果两个节点在同一条数据上面操作,比如多个节点机器对同一个订单操作不同的流程有可能会导致该笔订单最后状态出现错误,造成损失。

2.2 分布式锁五个特点

当我们确定了在不同节点上需要分布式锁,那么我们需要了解分布式锁到底应该有哪些特点,分布式锁的四个特点(和Java单进程锁一样,唯一区别就是同时锁多个进程)

第一,互斥性:和我们本地锁一样互斥性是最基本,但是分布式锁需要保证在不同节点的不同线程的互斥。

第二,可重入性和锁超时:

可重入性:同一个节点上的同一个线程如果获取了锁之后那么也可以再次获取这个锁。

锁超时:和本地锁一样支持锁超时,防止死锁。

第三,高效/高可用:加锁和解锁需要高效,同时也需要保证高可用防止分布式锁失效,可以增加降级。

第四,支持阻塞和非阻塞:和ReentrantLock一样支持lock和trylock以及tryLock(long timeOut)。

第五,支持公平锁和非公平锁(可选):公平锁的意思是按照请求加锁的顺序获得锁,非公平锁就相反是无序的。这个一般来说实现的比较少。

我们了解了一些特点之后,我们一般实现分布式锁有以下三种方式:MySql、Zk、Redis,下面分开介绍一下这些分布式锁的实现原理。

三、Mysql分布式锁

首先来说一下Mysql分布式锁的实现原理,相对来说这个比较容易理解,毕竟数据库和我们开发人员在平时的开发中息息相关。对于分布式锁我们可以创建一个锁表:

三仙过海,各显神通_原力计划_02

3.1 MySQL加锁与解锁

Mysql分布式锁包括lock(),trylock(long timeout),trylock()这几个方法,如下:

lock()阻塞式的获取锁,不获取到锁誓不罢休,死循环

tryLock()是非阻塞获取锁,如果获取不到那么就会马上返回,返回值为boolean

tryLock(long timeout)在tryLock()基础上加上了时间参数,也是带返回值的,返回值为boolean

lock一般是阻塞式的获取锁,意思就是不获取到锁誓不罢休,那么我们可以写一个死循环来执行其操作:

三仙过海,各显神通_java_03

mysqlLock.lcok内部是一个sql,为了达到可重入锁的效果那么我们应该先进行查询,如果有值,那么需要比较node_info是否一致,这里的node_info可以用机器IP和线程名字来表示,如果一致那么就加可重入锁count的值,如果不一致那么就返回false。如果没有值那么直接插入一条数据。伪代码如下:

三仙过海,各显神通_分布式锁_04

需要注意的是mysqlLock.lock()这一段代码需要加事务,必须要保证这一系列操作的原子性。然后,lock(),trylock(long timeout),trylock() 都调用mysqlLock.lock(),即MySQL分布式锁加锁需要自己添加事务,其实unlock()解锁也需要自己维护事务。

tryLock()是非阻塞获取锁,如果获取不到那么就会马上返回,返回值为boolean,代码可以如下:

三仙过海,各显神通_redis_05

tryLock(long timeout)实现如下(在tryLock()基础上加上了时间参数,也是带返回值的):

三仙过海,各显神通_数据库_06

这里调用的mysqlLock.lock和上面是一样,但是要注意的是select … for update这个是阻塞的获取行锁,如果同一个资源并发量较大还是有可能会退化成阻塞的获取锁。

解锁:因为上面的加锁代码支持重入锁,所以解锁的的时候也一样,unlock的话如果这里的count为1那么可以删除,如果大于1那么需要减去1。

三仙过海,各显神通_原力计划_07

3.2 锁超时问题

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

3.3 Mysql分布式锁小结

第一,适用场景: Mysql分布式锁一般适用于资源不存在数据库,如果数据库存在比如订单,那么可以直接对这条数据加行锁,不需要我们上面多的繁琐的步骤,比如一个订单,那么我们可以用select * from order_table where id = ‘xxx’ for update进行加行锁,那么其他的事务就不能对其进行修改。

第二,优点:理解起来简单,不需要维护额外的第三方中间件(比如Redis,Zk)。

第三,缺点:

(1) 虽然容易理解但是实现起来较为繁琐,需要自己考虑锁超时,加事务(指解锁);

(2) 性能受限于数据库,一般对比缓存来说性能较低。对于高并发的场景并不是很适合。

3.4 基于MySQL版本号实现的乐观锁

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

一句话小结数据库乐观锁:

对我们的表加一个版本号字段,那么我们查询出来一个版本号之后,update或者delete的时候需要依赖我们查询出来的版本号,判断当前数据库和查询出来的版本号是否相等,如果相等那么就可以执行,如果不等那么就不能执行。

四、ZooKeeper分布式锁

ZooKeeper也是我们常见的实现分布式锁方法,相比于数据库如果没了解过ZooKeeper可能上手比较难一些。ZooKeeper是以Paxos算法为基础分布式应用程序协调服务。Zk的数据节点和文件目录类似,所以我们可以用此特性实现分布式锁。我们以某个资源为目录,然后这个目录下面的节点就是我们需要获取锁的客户端,未获取到锁的客户端注册需要注册Watcher到上一个客户端,可以用下图表示。

三仙过海,各显神通_分布式锁_08

/lock是我们用于加锁的目录,/resource_name是我们锁定的资源,其下面的节点按照我们加锁的顺序排列。

4.1 ZK加锁与解锁

Curator封装了Zookeeper底层的Api,使我们更加容易方便的对Zookeeper进行操作,并且它封装了分布式锁的功能,这样我们就不需要再自己实现了。

Curator实现了可重入锁(InterProcessMutex),也实现了不可重入锁(InterProcessSemaphoreMutex)。在可重入锁中还实现了读写锁。InterProcessMutex是Curator实现的可重入锁,我们可以通过下面的一段代码实现我们的可重入锁:

三仙过海,各显神通_分布式锁_09

我们调用acquire()方法进行加锁,调用release()方法进行解锁。

acquire()加锁的流程具体如下:

1、首先进行可重入的判定:这里的可重入锁记录在​​ConcurrentMap<Thread, LockData> threadData​​这个Map里面,如果threadData.get(currentThread)是有值的那么就证明是可重入锁,然后记录就会加1。我们之前的Mysql其实也可以通过这种方法去优化,可以不需要count字段的值,将这个维护在本地可以提高性能。

2、然后在我们的资源目录下创建一个节点:比如这里创建一个/0000000002这个节点,这个节点需要设置为EPHEMERAL_SEQUENTIAL也就是临时节点并且有序。

3、获取当前目录下所有子节点,判断自己的节点是否位于子节点第一个:

(1) 如果是第一个,则获取到锁,那么可以返回;

(2) 如果不是第一个,则证明前面已经有人获取到锁了,那么需要获取自己节点的前一个节点,例如:/0000000002的前一个节点是/0000000001,我们获取到这个节点之后,再上面注册Watcher(这里的watcher其实调用的是object.notifyAll(),用来解除阻塞)。注册完成watcher后,调用object.wait(timeout)或object.wait(),进行阻塞等待。

release()方法解锁的具体流程如下:首先进行可重入锁的判定,如果有可重入锁只需要次数减1即可,减1之后加锁次数不为0直接返回;减1之后加锁次数为0的完成善后工作(删除当前节点;删除threadDataMap里面的可重入锁的数据)。

4.2 特殊的读写锁

Curator提供了读写锁,其实现类是InterProcessReadWriteLock,这里的每个节点都会加上前缀:

private static final String READ_LOCK_NAME  = "__READ__";
private static final String WRITE_LOCK_NAME = "__WRIT__";

上述代码根据不同的前缀区分是读锁还是写锁,对于读锁,如果发现前面有写锁,那么需要将watcher注册到和自己最近的写锁。写锁加锁的逻辑和我们之前4.2分析的依然保持不变。

4.3 锁超时问题

Zookeeper不需要配置锁超时,由于我们设置节点是临时节点,我们的每个机器维护着一个ZK的session,通过这个session,ZK可以判断机器是否宕机。如果我们的机器挂掉的话,那么这个临时节点对应的就会被删除,所以我们不需要关心锁超时。

4.4 ZK分布式锁小结

1、优点:

(1) ZK可以不需要关心锁超时问题,实现起来有现成的第三方包,比较方便;

(2) ZK支持读写锁,ZK获取锁会按照加锁的顺序,所以其是公平锁;

(3) 利用ZK集群可以保证高可用。

2、缺点: ZK需要额外维护,增加维护成本,性能和Mysql相差不大,依然比较差。

金手指:zookeeper不用记录超时时间,是因为它是临时的或临时序列的,client断开就没有

五、Redis分布式锁

网上搜索分布式锁,最多的实现就是Redis了,Redis因为其性能好,实现起来简单所以让很多人都对其十分青睐。

问题:为什么选用Redis实现分布式锁?

回答:因为redis分布式锁最适应高并发场景。MySQL实现分布式锁,一共三种方式,悲观锁、乐观锁、时间戳,因为是持久层,所以无法应对高并发。Zookeeper实现分布式锁,创建节点和销毁节点实现分布式锁,代价比较大。Redis实现分布式锁,因为Redis六个设计(C语言实现、丰富的数据结构、内存中运行、单线程没有线程切换开销、IO复用、用VM取代系统调用),更加快速,适应高并发下的分布式锁。

5.1 Redis分布式锁简单实现

5.1.1 Redis分布式锁本质

Redis分布式锁本质上要实现的目标就是在 Redis 里面占一个“茅坑”,当别的进程也要来占时,发现已经有人蹲在那里了,就只好放弃或者稍后再试。占坑一般是使用 setnx(set if not exists) 指令,该命令只允许被一个客户端占坑,先来先占, 占了之后别人再调用setnx命令就会失败,只有当持有者用完了,调用 del 指令释放茅坑,别人才能抢占,从而实现分布式锁。

setnx aobing   // setnx 如果没有别人加锁的话,我加锁
expire aobing // 设置过期时间,这里表示执行中,操作过程
del aobing // 用完了,释放锁

原子性问题:

上面代码,使用Redis的setNx命令实现分布式锁,对于整个redis分布式锁的加锁和解锁过程,第一步和第二步是加锁,第三步是解锁。因为是两步操作,不是原子性的,所以加锁不是原子性的。如果第一步setnx成功,第二步设置失效时间expire的时候失败,则造成加锁了之后如果机器宕机那么这个锁就不会得到释放,所以需要保证setNx和加入过期时间是同一个原子操作。

在Redis2.8之前我们需要使用Lua脚本达到我们的目的,但是Redis 2.8 版本后,作者加入了 set 指令的扩展参数,即redis支持nx和ex操作是同一原子操作,将三步操作变为两步操作,即加锁仅需一步(即一条命令),解锁也仅需一步(即一条命令),如下:

set aobing ture  ex 5 nx   // 加锁  set resourceName value ex 5 nx
del aobing // 解锁

问题: Redis分布式锁介绍?

回答: 先拿setnx来争抢锁,抢到之后,再用expire给锁加一个过期时间防止锁忘记了释放。

问题: 如果在setnx之后执行expire之前进程意外crash或者要重启维护了,那会怎么样?

回答: set指令有非常复杂的参数,这个应该是可以同时把setnx和expire合成一条指令来用的!

5.1.2 Redis四个基础命令

先介绍Redis四个设值命令,这是实现分布式锁的基础,如下:

1、SET key value

含义:将字符串值 value 关联到 key 。如果 key 已经持有其他值, SET 就覆写旧值,无视类型。

返回值:成功返回1,不成功返回0,没有理由不成功,除非redis宕机。

2、SETEX key seconds value

含义: 将值 value 关联到 key ,并将 key 的生存时间设为 seconds (以秒为单位)。 setex 表示 set expire value,是redis的四个set命令中唯一一个可以设置过期时间的命令。

操作1:如果 key 已经存在, SETEX 命令将覆写旧值;

操作2:如果 key 不存在,setex直接设置key值。

成功返回值:设置成功时返回 OK 。

失败返回值:只有当 seconds 参数不合法时,才会失败并返回一个错误。

3、SETNX key value

含义:SETNX 是『SET if Not eXists』(如果不存在,则 SET)的简写。

操作:如果 key 不存在,将 key 的值设为 value ;如果 key 已经存在,则 SETNX 不做任何动作。

成功返回值:设置成功,返回 1 。

失败返回值:设置失败,返回 0 。

4、GETSET key value

含义:将给定 key 的值设为 value ,并返回 key 的旧值(old value)。

返回值1:当 key 存在且是字符串类型,返回给定 key 的旧值。

返回值2:当 key 存在但不是字符串类型时,类型对不上,返回一个错误。

返回值3:当 key 不存在,即 key 没有旧值时,返回 null 。

setNx(set if not exist)方法如果不存在( if not exist)则更新(set),其可以很好的用来实现我们的分布式锁。对于某个资源加锁我们只需要 ​​setNx resourceName value​

5.1.3 Redis实现分布式锁的两个命令

Redis的两个命令(这两个命令是分布式锁实现的关键)

SETNX key value

setnx 是SET if Not eXists(如果不存在,则 SET)的简写。

如果不存在key,就设置成功返回int的1,如果这个key存在了返回0。

三仙过海,各显神通_java_10

SETEX key seconds value

将值 value 关联到 key ,并将 key 的生存时间设为 seconds (以秒为单位)。

如果 key 已经存在,setex命令将覆写旧值。

问题:万一set value 成功 set time失败,那不就傻了么,这啊Redis官网想到了。

setex是一个原子性(atomic)操作,关联值和设置生存时间两个动作会在同一时间内完成。

三仙过海,各显神通_java_11

我设置了10秒的失效时间,ttl命令可以查看倒计时,负的说明已经到期了。

5.2 Jedis客户端

jedis拥有set的四个重载方法(byte[]的四个自动忽略,客户端redission和jedis都是一样的,redission更好)

四个重载方法定义:

// 两个参数key+value
String set(String key, String value)
// 三个参数key+value+nxxx
String set(String key, String value, String nxxx)
// 五个参数key+value+nxxx+expx+time
String set(String key, String value, String nxxx, String expx, int time)
// 五个参数key+value+nxxx+expx+time
String set(String key, String value, String nxxx, String expx, long time)

相同点:功能都是一样的,“Set the string value as value of the key.” 将string类型的value 放到key的value上,返回值都是 String。

不同点:

(1) set两个参数:把key、value set到redis中,隐含覆盖,默认的ttl是-1(永不过期)

(2) set三个参数:根据第三个参数,把key、value set到redis中

nx : not exists, 只有key 不存在时,才把key value set 到redis

xx : is exists ,只有 key 存在时,才把key value set 到redis

(3) set五个参数 和三个参数 就相同,只是多加了个过期时间单位和过期时间数值

第四个参数expx表示过期时间单位,有两个值可选,分别是:ex表示seconds 秒、px表示milliseconds 毫秒,如果使用其他值会抛出异常 : ​​redis.clients.jedis.exceptions.JedisDataException : ERR syntax error​

第五个参数表示过期时间数值,有两种可选的值,int类型的time和long类型time,都是过期时间,Jedis 类的set 方法中 int和long(第五个参数)重载的方法,方法体基本一样:

public String set(final String key, final String value, final String nxxx, final String expx, final int time){
checkIsInMultiOrPipeline();
client.set(key, value, nxxx, expx, time)
return client.getStatusCodeReply();
}

public String set(final String key, final String value, final String nxxx, final String expx, final long time){
checkIsInMultiOrPipeline();
client.set(key, value, nxxx, expx, time)
return client.getStatusCodeReply();
}

对于两个方法,一路源码追下去:Jedis 类 --> Client类 --> BinaryClient 都是一样的,在BinaryClient 的set 方法中,对 int 和 long 的time参数,做了个 toByteArray(time),操作,打开源码,发现:

public static final byte[] toByteArray(final int value){
return SafeEncoder.encode(StringvalueOf(value));
}
public static final byte[] toByteArray(final long value){
return SafeEncoder.encode(StringvalueOf(value));
}

不管是int 还是long,都转成String了,所以jedis的最后两个重载方法,其实是一样的,唯一的区别是就是 expx 参数是px的时候,使用long类型的参数,可以表示更多时间,或者用来满足程序员的使用习惯,用long类型表示毫秒。最后,返回值String,如果写入成功是“OK”,写入失败返回空。

Redission、Jedis这些客户端的底层源码都是通过lua脚本来保证原子性的。

5.3 Redission客户端加锁与解锁

Redission也是Redis的客户端,相比于Jedis功能简单。Jedis简单使用阻塞的I/O和redis交互,Redission通过Netty支持非阻塞I/O。Redission封装了锁的实现,继承java.util.concurrent.locks.Lock的接口,让我们像操作我们的本地Lock一样去操作Redission的Lock,其基本的加锁解锁方法包括:lock() tryLock() tryLockAysnc() unlock()。先看加锁,如下:

三仙过海,各显神通_原力计划_12

lock(): 类似java,阻塞加锁,无返回值

tryLock(): 类似java,非阻塞加锁,返回值boolean,还可以添加超时参数

tryLockAysnc(): 异步加锁

且看tryLock()方法加锁:

1、尝试加锁:首先会尝试进行加锁,使用lua脚本保证操作是原子性,使用的hash结构操作,我们的每一个需要锁定的资源都可以看做是一个HashMap,锁定资源的节点信息是Key,锁定次数是value。通过这种方式可以很好的实现可重入的效果,只需要对value进行加1操作,就能进行可重入锁。当然这里也可以用之前我们说的本地计数进行优化。

2、如果加锁,判断是否超时,如果超时则返回false,表示加锁失败。如果没有超时,那么需要在名字为redisson_lock__channel+lockName的channel上进行订阅,用于订阅解锁消息,然后一直阻塞直到超时,或者有解锁消息。

3、重试步骤1和步骤2,直到最后获取到锁,或者某一步获取锁超时。

unlock()解锁: Redission中,unlock方法比较简单也是通过lua脚本(基于lua脚本实现事务,保证原子性)进行解锁,如果是可重入锁,只是减1。如果是非加锁线程解锁,那么解锁失败。

Redission还可以实现公平锁,对于公平锁其利用了list结构和hashset结构分别用来保存我们排队的节点,和我们节点的过期时间,用这两个数据结构帮助我们实现公平锁,了解即可。

5.4 RedLock实现集群锁

RedLock的结构是三个redis组成的集群:当机器A申请到一把锁之后,如果Redis主宕机了,这个时候从机并没有同步到这一把锁,那么机器B再次申请的时候就会再次申请到这把锁,为了解决这个问题Redis作者提出了RedLock红锁的算法,在Redission中也对RedLock进行了实现,如下:

三仙过海,各显神通_数据库_13

通过上面的代码,我们需要实现多个Redis集群,然后进行红锁的加锁,解锁。具体的步骤如下:

1、首先生成多个Redis集群的Rlock,并将其构造成RedLock。

2、依次循环对三个集群进行加锁,加锁的过程和5.2里面一致。

3、如果循环加锁的过程中加锁失败,那么需要判断加锁失败的次数是否超出了最大值,这里的最大值是根据集群的个数,比如三个那么只允许失败一个,五个的话只允许失败两个,要保证多数成功。

4、加锁的过程中需要判断是否加锁超时,有可能我们设置加锁只能用3ms,第一个集群加锁已经消耗了3ms了。那么也算加锁失败。

5、步骤3、步骤4里面加锁失败的话,那么就会进行解锁操作,解锁会对所有的集群在请求一次解锁。

小结:RedLock基本原理是利用多个Redis集群,用多数的集群加锁成功(多次即超过一半),减少Redis某个集群出故障,造成分布式锁出现问题的概率。

5.4 Redis分布式锁小结

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

缺点:需要维护Redis集群,如果要实现RedLock那么需要维护更多的集群。

六、尾声

本文主要讲了多种分布式锁的实现方法,以及他们的一些优缺点。最后也说了一下有关于分布式锁的安全的问题,对于不同的业务需要的安全程度完全不同,我们需要根据自己的业务场景,通过不同的维度分析,选取最适合自己的方案。