其中加锁的方式又分为很多种,一般可以分为两类:悲观锁 和 乐观锁。
悲观锁: 认为线程安全问题一定会发生,因此在操作数据之前会使得当前线程获取到锁,确保线程串行执行。例如:java中的Synchronized、Lock都属于悲观锁。它的优点是实现起来简单粗暴,但是性能一般。
乐观锁:认为线程安全问题不一定会发生,因此只是在更新数据时去判断,之前的获取到的数据和此时要更新的数据的值有没有被修改。1 如果没有修改则认为时安全的,自己才更新数据。 2 如果已经被其它线程修改,则说明发生了安全问题,此时就需要进行重试或者抛出异常。例如:Java中的CAS机制就是乐观锁的思想。乐观锁的优点是性能较好,但是存在成功率低的问题。
我们之前所使用的锁,都是在单机上使用的锁,在JAVA里也就是单个JVM里对象加锁,这样的话,当我们的系统采用集群方式进行部署时,此时,相当于与拥有了多个JVM,这样原本要求在整个系统中具有互斥性的锁,在集群模式下,如果使用JVM中的锁,比如Synchronized进行加锁时,这样每一个JVM中的其中的一个线程都能获取到一个在其JVM的锁对象,并不能达到在整个系统级别上的锁的互斥性,如此加锁应用到分布式系统中必定会出现问题,所以需要分布式锁在整个系统级别实现互斥锁,即多个JVM使用同一把锁。
分布式锁:满足分布式系统或集群模式下多进程可见并且互斥的锁。
我们可以利用Mysql或者Redis来实现分布式锁。Mysql可以利用其本身的互斥机制,Redis可以利用setnx命令来实现。
使用redis实现锁,只要满足锁的基本特性互斥性就可以很简单地实现锁。
我们可以使用redis
中提供的setnx
命令实现互斥锁,setnx
命令的set
命令的另一种版本,命令如下所示:
# 添加锁,利用setnx的互斥特性
setnx lock t1
# 添加过期时间,避免宕机引起的死锁
expire lock 10
意思是创建键为lock
,值为t1
的数据,如果键lock
不存在的话,则创建成功,如果存在则创建失败。如果创建失败则说明其他的线程已经获得了lock
这把锁,通过这条命令就可以达到锁的护互斥效果。另外我们还要给锁添加过期时间,避免宕机引起的死锁。
另外,我们上边的代码是两行,不能保证操作的原子性,也就是setnx lock t1
执行完之后,redis如果宕机,那么就没法去执行第二句expire lock 10
, 所以在创建锁的时候需要使用如下的命令如实现上边命令的功能。
set lock t1 nx ex 10
del key
expire lock 10
整个redis实现锁的过程如下:
使用Redis实现分布式锁,我们只要在前边 Redis实现互斥锁 上进行设计,使其满足分布式锁的基本要求即可。
我们只需要考虑如何设计命令set lock t1 nx ex 10
中的锁名称lock
,以及锁的值t1
,即可。
一般来讲,我们每一个业务都需要一把锁,所以锁名称lock就可以根据业务名称+用户id
来命名。
那么锁的值一般设置为当前线程的标识可以为名称或者ID。
目前来讲,已经设计好了一个简单的Redis的分布式锁,但是如此设置会在释放锁的时候出现问题。下来将分情况进行说明并给出解决方法。
如上图所示,多个线程同时获取同一把锁的情况。
首先线程1获取到了Redis锁,但是因为业务阻塞花费了很长的时间,这个时间超过了之前设置的锁的过期时间,导致线程1业务未完成,它获取到的锁就已经被释放掉了。此时,线程2尝试获取锁获取到了,并开始执行业务,在这期间,线程1业务执行完成了,然后执行了释放锁的操作,相当于误删了线程2所获取到的锁(因为线程1的锁,超时过期了),此时线程3来获取锁,发现可以获取到锁,就获取锁,执行和线程2相似的业务,注意,本来在加锁的前提下,线程2和线程3的执行是不能同时发生的,而此时在并发情况下同时发生了,就会出现并发读写问题。这就是锁误删的情况。
解决方法:线程在释放锁的时候需要根据锁中的值,判断一下是否是自己的锁,如果是则进行释放,从而避免发生误删的情况。
改进的情况就如下图所示。另外,为了在集群模式下,线程区分是不是自己的锁,之前的想法是将线程的ID或者名字放入锁的值中,但是不同的JVM可能会存在相同的值,因为可以使用uuid+线程标识
作为锁的值,来在集群中唯一确定锁的拥有线程。
通过上一部分的分析,我们可以看到,我们可以通过使线程在释放锁之前进行判断标识的情况之后进行删除,如果是自己的,则进行释放,如果不是自己的,则不释放,从而防止误删。
但是,这其中还是存在问题,因为经过改进之后的释放锁的操作变成了两步,即1.判断锁标识 2.释放锁
,这两步操作并不是原子性的,所以在有些情况下还是会出现锁误删的情况,如下图所示:
线程1获取锁,执行业务,业务执行完毕之后,需要释放锁,其先进行了锁标识的判断,在其将要进行释放锁操作时,出现了阻塞,比如说发生了gc,并且时间较长,在阻塞的这段时间里,线程1的锁因为超时自动释放掉了。此时线程2尝试获取锁,并且成功获取,开始执行业务,在这期间,线程1阻塞结束,执行释放锁操作,因为线程1的锁已经被释放掉了,线程1也经过了锁标识的判断,所以线程1就直接进行释放锁的操作,如此就会释放掉线程2的锁,这样在线程2还未执行完业务时,线程3可以获得锁了,就可以和线程2同时执行,这样又发生了并发读写问题。
导致上述问题的原因在于:1.判断锁标识 2.释放锁
这两个操作不是原子性的,我们可以通过编写Lua脚本来实现。
Redis提供了Lua脚本功能,在一个脚本中编写多条Redis命令,确保多条命令执行时的原子性。
-- 这里的 KEYS[1] 就是锁的key,这里的ARGV[1] 就是当前线程标示
-- 获取锁中的标示,判断是否与当前线程标示一致
if (redis.call('GET', KEYS[1]) == ARGV[1]) then
-- 一致,则删除锁
return redis.call('DEL', KEYS[1])
end
-- 不一致,则直接返回
return 0
在实际使用的时候可以通过Java调用该脚本实现安全释放锁的功能。
Redisson是一个在Redis的基础上实现的Java驻内存数据网格(In-Memory Data Grid)。它不仅提供了一系列的分布式的Java常用对象,还提供了许多分布式服务,其中就包含了各种分布式锁的实现。