Redis高级:分布式锁
1 分布式锁概述
1.1 分布式锁引入
为什么需要分布式锁?
在单体项目中,我们可以使用syn锁为程序加锁来防止出现并发问题。那么java底层是怎样实现锁互斥的呢?其实是依靠jvm中的锁监视器,当有线程拿到syn锁时,锁监视器就会记录该行为,将该线程的id和锁对象都记录下来,此时如果有其他线程试图拿到相同锁对象的syn锁时,就会因为锁监视器中已经有记录而失败,这样就实现了锁互斥
但是在集群环境下,每台服务器都有自己独立的jvm,在这种情况下如果我们仍然使用syn锁为程序代码加锁就会出现问题,因为无论是锁对象还是锁监视器都是依赖于jvm的,在不同的jvm中,哪怕运行的代码完全一致,它们的锁对象和锁监视器也不会是同一个,这样就会导致有多少台服务器中就能有多少条线程拿到syn锁,进而出现线程安全问题
基于上述出现的问题,我们希望"锁监视器"应该是独立于jvm之外的,在集群环境下也仍然只有一条线程能够拿到互斥锁,在这种情况下,我们需要使用分布式锁来为代码加锁。
1.2 分布式锁概述
分布式锁是满足分布式系统或集群模式下多进程可见并且互斥的锁。简单点来说,分布式锁的核心思想就是让不同服务器中的线程都能够使用同一把锁。分布式锁应该具有以下特征:
-
可见性:不同服务器里的多个线程都能看到相同的结果,例如锁对象的获取和锁对象的释放,注意这里的可见性并不是指内存可见性
-
互斥:互斥是分布式锁的最基本的条件,必须保证一把锁在同一时刻只能被一条线程拿到,使程序串行执行
-
高可用:程序不易崩溃,时时刻刻都保证较高的可用性,即绝大部分情况下获取锁都是成功的
-
高性能:由于加锁本身就让性能降低,所有对于分布式锁本身需要他就较高的加锁性能和释放锁性能
-
安全性:在获取锁的时候应当考虑一些异常情况,比如服务器宕机导致未释放,死锁问题等
常见的分布式锁有三种
-
MySQL:MySQL 本身具备事务机制,在执行写操作的时候,MySQL 会为正在变动的数据分配行锁,进而保证在同一时刻只有一个事务操作一组数据,最终实现事务之间的锁互斥。我们可以基于上述原理来实现分布式锁。但是这种方式会受限于MySQL的性能。
-
Redis:利用 SETNX 互斥命令,当使用 SETNX 命令向 Redis 中存储数据时,只有该数据的 key 不存在时才能存储成功,如果已经存在,就会存储失败,我们可以通过这种方式表示锁的获取,释放锁时,只需要将 key 删除即可。Redis 支持主从模式、集群模式,可用性高,且在性能方面也远远高于 MySQL。
需要注意的是我们在Redis中使用 SETNX 命令存储数据时,一定要设置过期时间,这样即使Redis 服务宕机,锁最后也会得到释放,但是到底设置多长时间比较合适,需要我们好好考虑。如果过期时间设置的过长,那么锁的无效等待时间就会比较多,如果设置过短,有可能导致业务没有执行结束就将锁释放掉。
-
Zookeeper:Zookeeper 实现锁的原理是基于它内部的节点机制。Zookeeper 内部可以创建数据节点,而节点具有唯一性和有序性,另外,Zookeeper 还可以创建临时节点。所谓唯一性就是在创建节点时,节点不能重复;所谓有序性是指每创建一个节点,节点的id是自增的。那么就可以利用节点的有序性来实现互斥。
当有大量线程来获取互斥锁时,每个线程就会创建一个节点,而每个节点的 id 是单调递增的,如果我们约定 id 最小的那个获取锁成功,这样就可以实现互斥。当然,也可以利用唯一性,让所有线程去创建节点,但是节点名称相同,这样就会只能有一个线程创建成功。一般情况下,会使用有序性来实现互斥。想要释放锁,则只需要将节点删除即可,一旦将最小节点删除,那么剩余节点中 id 最小的那个节点就会获取锁成功。
由于Zookeeper 本身支持集群,所以其可用性很好。而Zookeeper 的集群强调节点之间的强一致性,而这种强一致性就会导致主从之间在进行数据同步时会消耗一定的时间,其性能相较于 Redis 而言会差一点。安全性方面,Zookeeper 一般创建的是临时节点,一旦服务出现故障,Zookeeper 就会自动断开连接,锁就会自动释放掉。
在本篇文章中,会介绍利用redis实现分布式锁
2 基于Redis实现分布式锁
基于Redis实现分布式锁的基本思路如下:
利用redis中String类型的setnx方法,这个方法与普通的set方法相比有一个特性,就是只有当redis中key不存在时才能插入成功,如果key存在则插入失败,而我们可以用插入key来表示获取锁的过程,插入key成功表示锁获取成功,插入key失败则表示锁获取失败,这样也就实现了锁互斥,当我们需要释放锁时,只需要将该数据从redis中移除就好,当然为了防止redis宕机时出现死锁的现象,我们最好为该数据设置一条过期时间。
实现分布式锁时需要实现以下两个基本方法:
-
获取锁:
- 互斥:确保只能有一个线程获取锁
- 非阻塞:尝试一次,成功返回true,失败返回false
-
释放锁:
- 手动释放
- 超时释放:获取锁时添加一个超时时间
2.1 分布式锁初步实现
编写以下接口作为锁的基本接口
/**
* 分布式锁父接口
*/
public interface ILock {
/**
* 尝试获取锁
* @param timeoutSec 锁持有的超时时间,过期后锁自动释放
* @return 返回true表示锁获取成功,返回false表示锁获取失败
*/
boolean tryLock(long timeoutSec);
/**
* 释放锁
*/
void unlock();
}
编写SimpleRedisLock实现ILock
利用setnx方法进行加锁,同时增加过期时间,防止死锁,此方法可以保证加锁和增加过期时间具有原子性
public class SimpleRedisLock implements ILock {
/**
* 业务名称,由调用者创建对象时传入,目的是保证在不同的业务中有不同的key作为锁
*/
private String name;
private StringRedisTemplate stringRedisTemplate;
/**
* 锁前缀
*/
private static final String KEY_PREFIX = "lock:";
public SimpleRedisLock(String name, StringRedisTemplate stringRedisTemplate) {
this.name = name;
this.stringRedisTemplate = stringRedisTemplate;
}
/**
* 获取锁
* @param timeoutSec 锁持有的超时时间,过期后锁自动释放
* @return
*/
@Override
public boolean tryLock(long timeoutSec) {
//获取当前线程标识
long id = Thread.currentThread().getId();
/*
* 获取锁
* 由于可能会出现setnx命令执行完但是过期时间还未设置,redis就已经宕机的情况
* 这里要保证setnx和设置过期时间两条命令是同时成功同时失败的,因此需要使用setIfAbsent方法
*/
Boolean success = stringRedisTemplate.opsForValue().setIfAbsent(
KEY_PREFIX + name,
id + "",
timeoutSec,
TimeUnit.MINUTES
);
return Boolean.TRUE.equals(success);
}
/**
* 释放锁
*/
@Override
public void unlock() {
stringRedisTemplate.delete(KEY_PREFIX+name);
}
}
使用SimpleRedisLock
//创建锁对象,这里第一个参数根据业务传入,保证在不同的业务中都能使用不同的锁,例如我这里需要保证order业务中的线程安全
SimpleRedisLock redisLock = new SimpleRedisLock("order", stringRedisTemplate);
//获取锁对象
if(!redisLock.tryLock(10l)){
System.out.println("执行获取锁失败时的业务逻辑")
}
try {
System.out.println("执行获取锁成功时的业务逻辑")
} finally {
//释放锁对象
redisLock.unlock();
}
2.2 锁误删问题
以上的分布式锁在实际情况中可能出现以下现象:
假设线程 1 获取互斥锁且获取成功,拿到锁后,线程 1 开始执行业务,但是由于某种原因,线程 1 的业务发生了阻塞,这样就会导致线程 1 持有锁的周期就会变长,如果当其持有的锁到期了,线程 1 的业务仍未执行完毕,锁就会被自动释放。
既然锁已经被释放了,那么其他线程就会尝试获取锁,假如此时线程2抢到了锁,然后开始执行业务。就在此时,线程1结束了阻塞并完成了自己的业务,然后线程1就会执行释放锁操作,但是此时的锁是由线程2持有的,由于此时我们编写的释放锁的代码只是简单的删除数据,因此线程1也可以直接释放线程2的锁,但是线程2此时的业务是仍然会继续执行的
既然锁已经被释放了,那么其他线程就会尝试获取锁,假如此时线程3抢到了锁,然后开始执行业务。此时就会出现两个线程并行执行业务代码的情况,就很有可能出现线程安全问题
还记得我们之前编写的获取锁代码中的setnx保存的值是线程ID吗?我们可以利用这点来解决上述问题
每个线程在释放锁的时候,先判断一下锁的线程ID与线程ID是否相等,如果相等则说明当前的锁对象是由当前线程持有的,就可以进行锁的释放,如果不相等,说明当前锁对象已经由别的线程持有了,则不进行锁的释放,这样就可以解决上述问题
当然还有一点问题是我们不能直接基于线程ID来判断锁对象是否是当前线程所持有,因为线程ID是由jvm递增生成的,每有一条线程,线程ID就加一,这样就会导致不同的服务器上会出现线程ID相等的现象,在集群环境下容易出现问题。我们可以在不同的服务器的线程ID前面带上一个不相同的前缀,这个前缀最好是随机生成的
基于上述分析,改进SimpleRedisLock如下:
public class SimpleRedisLock implements ILock {
/**
* 业务名称,由调用者创建对象时传入,目的是保证在不同的业务中都有不同的key作为锁
*/
private String name;
private StringRedisTemplate stringRedisTemplate;
/**
* 锁前缀
*/
private static final String KEY_PREFIX = "lock:";
/**
* 随机生成的线程ID前缀,这里用final保证在同一个jvm上面前缀是一致的
*/
private static final String ID_PREFIX = UUID.randomUUID().toString(true) + "-";
public SimpleRedisLock(String name, StringRedisTemplate stringRedisTemplate) {
this.name = name;
this.stringRedisTemplate = stringRedisTemplate;
}
/**
* 获取锁
* @param timeoutSec 锁持有的超时时间,过期后锁自动释放
* @return
*/
@Override
public boolean tryLock(long timeoutSec) {
//获取当前线程标识
String id = ID_PREFIX+Thread.currentThread().getId();
//获取锁
Boolean success = stringRedisTemplate.opsForValue().setIfAbsent(
KEY_PREFIX + name,
id,
timeoutSec,
TimeUnit.MINUTES
);
return Boolean.TRUE.equals(success);
}
/**
* 释放锁
*/
@Override
public void unlock() {
//获取当前线程标识
String id = ID_PREFIX+Thread.currentThread().getId();
//获取当前锁的线程标识,并与当前线程进行对比
if(id.equals(stringRedisTemplate.opsForValue().get(KEY_PREFIX + name)){
stringRedisTemplate.delete(KEY_PREFIX+name);
}
}
}
2.3 原子性问题
上述的代码已经保证了锁误删的情况基本不会发生,为什么要说基本呢?因为还有更极端的情况。
在更极端的情况下,假设线程 1 获取互斥锁且获取成功,拿到锁后,线程 1 开始执行业务,执行结束后,线程 1 的准备释放锁,并且经过判断也确认了当前锁是自己所持有的,整准备执行stringRedisTemplate.delete(KEY_PREFIX+name);
时,线程1发生了阻塞(这种情况是有可能发生的,例如jvm垃圾回收就会阻塞所有的代码),而在线程1阻塞的过程中,锁因为过期自动释放了,这时线程2就会抢到锁,然后执行自己的业务。而正当线程2执行业务逻辑时,线程1结束了阻塞并将锁释放,到了这一步,后面会发生的事就很明显了,由于线程2持有的锁被线程1释放了,但是其业务还没执行完,此时线程3抢到了锁,并于线程2并行执行,线程安全问题出现
之所以会出现上述现象,是因为判断和删除锁是两个动作,既然是两个动作,那么中间就可能会出现阻塞,进而导致出现问题
if(id.equals(stringRedisTemplate.opsForValue().get(KEY_PREFIX + name)){
stringRedisTemplate.delete(KEY_PREFIX+name);
}
那我们思考一下,有没有什么办法可以让这两个动作变成一个动作呢
Redis提供了Lua脚本功能,我们可以在一个脚本中编写多条Redis命令,确保多条命令执行时的原子性。Lua是一种编程语言,它的基本语法可以参考网站:https://www.runoob.com/lua/lua-tutorial.html
这里重点介绍Redis提供的调用函数,语法如下:
redis.call('命令名称', 'key', '其它参数', ...)
例如,我们要执行set name jack,则脚本是这样:
# 执行 set name jack
redis.call('set', 'name', 'jack')
再例如,我们需要先执行set name Rose,再执行get name,则脚本如下:
# 先执行 set name jack
redis.call('set', 'name', 'Rose')
# 再执行 get name
local name = redis.call('get', 'name')
# 返回
return name
写好脚本以后,需要用Redis命令来调用脚本,调用脚本的常见命令如下:
例如,我们要执行redis.call('set', 'name', 'jack')
这个脚本,语法如下:
脚本中的key、value也可以通过参数来传递。key类型参数会放入KEYS数组,其它参数会放入ARGV数组,在脚本中可以从KEYS和ARGV数组获取这些参数,这里需要注意,lua脚本的数组角标是从1开始的
接下来我们来回一下我们释放锁的逻辑:
- 获取锁中的线程标示
- 判断是否与指定的标示(当前线程标示)一致
- 如果一致则释放锁(删除)
- 如果不一致则什么都不做
如果用Lua脚本来表示则是这样的:
-- 这里的 KEYS[1] 就是锁的key,这里的ARGV[1] 就是当前线程标示
-- 获取锁中的标示,判断是否与当前线程标示一致
if (redis.call('GET', KEYS[1]) == ARGV[1]) then
-- 一致,则删除锁
return redis.call('DEL', KEYS[1])
end
-- 不一致,则直接返回
return 0
接下来我们就可以改造我们之前的代码,使用lua脚本来释放锁
在resources新建一个unlock.lua,将上面的lua脚本复制进去
改造SimpleRedisLock代码:
public class SimpleRedisLock implements ILock {
/**
* 业务名称,由调用者创建对象时传入,目的是保证在不同的业务中都有不同的key作为锁
*/
private String name;
private StringRedisTemplate stringRedisTemplate;
/**
* 锁前缀
*/
private static final String KEY_PREFIX = "lock:";
/**
* 随机生成的线程ID前缀,这里用final保证在同一个jvm上面前缀是一致的
*/
private static final String ID_PREFIX = UUID.randomUUID().toString(true) + "-";
/**
* 脚本对象,这里的泛型为脚本返回值类型
*/
private static final DefaultRedisScript<Long> UNLOCK_SCRIPT;
/**
* 使用静态代码块加载lua脚本,在类启动的时候就将脚本加载进内存
*/
static {
UNLOCK_SCRIPT = new DefaultRedisScript<>();
//设置脚本路径
UNLOCK_SCRIPT.setLocation(new ClassPathResource("unlock.lua"));
//设置脚本返回值类型。这里随意即可
UNLOCK_SCRIPT.setResultType(Long.class);
}
public SimpleRedisLock(String name, StringRedisTemplate stringRedisTemplate) {
this.name = name;
this.stringRedisTemplate = stringRedisTemplate;
}
/**
* 获取锁
* @param timeoutSec 锁持有的超时时间,过期后锁自动释放
* @return
*/
@Override
public boolean tryLock(long timeoutSec) {
//获取当前线程标识
String id = ID_PREFIX+Thread.currentThread().getId();
//获取锁
Boolean success = stringRedisTemplate.opsForValue().setIfAbsent(
KEY_PREFIX + name,
id,
timeoutSec,
TimeUnit.MINUTES
);
return Boolean.TRUE.equals(success);
}
/**
* 释放锁
*/
@Override
public void unlock() {
//调用lua脚本执行锁释放操作
stringRedisTemplate.execute(
UNLOCK_SCRIPT,
Collections.singletonList(KEY_PREFIX + name), //需要释放的锁的id
ID_PREFIX + Thread.currentThread().getId() //需要进行判断的线程标识
);
}
}
这样我们就保证了标志判断到锁释放这整个过程的原子性。
3 基于Redisson实现分布式锁
3.1 Redisson引入
基于setnx实现的分布式锁已经能够满足我们实际开发中的绝大部分需求,但是如果业务中有一些“特殊”的需求,那仅靠setnx可能就有些不太够用了。目前基于setnx的实现的分布式锁主要有以下几点问题:
不可重入:锁的重入是指获得锁的线程可以再次进入到相同的锁的代码块中,即一个线程可以多次重复的去获取同一把锁。可重入锁的意义在于防止死锁,例如在HashTable中,所有的方法都是使用synchronized修饰的,当我们在HashTable的一个方法内去调用另一个方法时,由于前后会获取两次锁,而且锁对象是同一个(当前类对象),如果锁是不能重入的,那么就会出现死锁现象。所以我们经常使用的synchronized和Lock锁都是可重入的。
不可重试:目前的分布式锁在获取锁失败后会直接返回false,即获取锁这个过程只能尝试一次,我们认为合理的情况是:当线程在获得锁失败后,它应该在一段时间内能再次尝试获得锁。
超时释放:我们目前编写的代码只能防止锁被误删的问题,如果因为业务执行时间较长导致锁超时释放,那么最终还是会出现两个线程并行执行业务的现象
主从一致性:如果 Redis 提供了主从集群,由于主从同步时存在延迟,假如某个线程从主节点中获取到了锁,但是尚未同步给从节点,而恰巧主节点在这个时候宕机。此时就会有一个从节点被选举成为新的主节点,而由于主节点并未将锁同步给其他节点,那么此时就会有另外一个线程在新的主节点上获取一把新的锁,这样就出现了两个线程分别拿到两把锁的情况。当然,由于redis主从同步延迟极低,因此这是在极端情况下才会出现的安全问题。
如果要解决上述问题,那么就需要更加复杂的编码,那么在市面上有没有什么现成的技术可以使用呢?答案就是Redisson。
Redisson是一个在Redis的基础上实现的Java驻内存数据网格(In-Memory Data Grid)。它不仅提供了一系列的分布式的Java常用对象,还提供了许多分布式服务,其中就包含了各种分布式锁的实现。
Redission提供了分布式锁的多种多样的功能
3.2 Redission快速使用
引入依赖:
<dependency>
<groupId>org.redisson</groupId>
<artifactId