讲座
分步骤介绍如何基于Redis构建分布式锁。会从原版本开始,然后根据问题进行调整,最终完成一个更合理的分布式锁。
本文将分布式锁的实现分为两部分,一部分是单机环境,另一部分是Redis锁在集群环境下的实现。在介绍分布式锁的实现之前,我们先了解一些关于分布式锁的信息。
分布式锁是一种控制分布式系统或不同系统共同访问共享资源的锁实现。如果不同系统或同一系统的不同主机共享某个资源,往往需要互斥,防止相互干扰,以保证一致性。
互斥:任何时刻都只有一个客户端持有锁。无死锁:即使持有锁的客户机崩溃或发生其他意外事件,仍然可以获得锁。容错:只要大部分Redis节点还活着,客户端就可以获取和释放锁
数据库Memcached(add命令)Redis(setnx命令)Zookeeper(临时节点)等。
定义常量类公共类LockConstants {
公共静态最终字符串OK=“OK”;
/** NX|XX,NX -仅在密钥不存在时设置密钥。XX -只设置已经存在的密钥。**/
公共静态最终字符串NOT _ EXIST=' NX
公共静态最终字符串EXIST=' XX
/** expx EX|PX,过期时间单位: EX=秒;PX=毫秒**/
公共静态最终字符串秒数=' EX
公共静态最终字符串毫秒=' PX
私有LockConstants() {}
}
Lock的定义抽象类RedisLock实现了java.util.concurrent包下的Lock接口,然后为一些方法提供了默认实现。子类只需要实现lock方法和unlock方法。代码如下所示
公共抽象类RedisLock实现Lock {
受保护的杰迪斯杰迪斯;
受保护的字符串lockKey
public RedisLock(Jedis jedis,String lockKey) {
这个(jedis,lock key);
}
public void sleepBySencond(int sensecond){
尝试{
Thread.sleep(秒* 1000);
} catch (InterruptedException e) {
e . printstacktrace();
}
}
@覆盖
public void lock interruptible(){ }
@覆盖
公共条件newCondition() {
返回null
}
@覆盖
公共布尔tryLock() {
返回false
}
@覆盖
公共布尔tryLock(长时间,时间单位单位){
返回false
}
}
先说最基础的版本。代码如下所示
公共类LockCase1扩展RedisLock {
公共锁箱1(Jedis jedis,字符串名称){
超级(杰德
is, name);}
@Override
public void lock() {
while(true){
String result = jedis.set(lockKey, "value", NOT_EXIST);
if(OK.equals(result)){
System.out.println(Thread.currentThread().getId()+"加锁成功!");
break;
}
}
}
@Override
public void unlock() {
jedis.del(lockKey);
}
}
LockCase1类提供了lock和unlock方法。其中lock方法也就是在reids客户端执行如下命令
SET lockKey value NX
而unlock方法就是调用DEL命令将键删除。好了,方法介绍完了。现在来想想这其中会有什么问题?假设有两个客户端A和B,A获取到分布式的锁。A执行了一会,突然A所在的服务器断电了(或者其他什么的),也就是客户端A挂了。这时出现一个问题,这个锁一直存在,且不会被释放,其他客户端永远获取不到锁。如下示意图
可以通过设置过期时间来解决这个问题
while(true){
String result = jedis.set(lockKey, "value", NOT_EXIST,SECONDS,30);
if(OK.equals(result)){
System.out.println(Thread.currentThread().getId()+"加锁成功!");
break;
}
}
}
类似的Redis命令如下
SET lockKey value NX EX 30
注:要保证设置过期时间和设置锁具有原子性
这时又出现一个问题,问题出现的步骤如下
客户端A获取锁成功,过期时间30秒。客户端A在某个操作上阻塞了50秒。30秒时间到了,锁自动释放了。客户端B获取到了对应同一个资源的锁。客户端A从阻塞中恢复过来,释放掉了客户端B持有的锁。示意图如下
这时会有两个问题
过期时间如何保证大于业务执行时间?如何保证锁不会被误删除?先来解决如何保证锁不会被误删除这个问题。这个问题可以通过设置value为当前客户端生成的一个随机字符串,且保证在足够长的一段时间内在所有客户端的所有获取锁的请求中都是唯一的。
版本2的完整代码:Github地址
public abstract class RedisLock implements Lock {
//...
protected String lockValue;
public RedisLock(Jedis jedis,String lockKey) {
this(jedis, lockKey, UUID.randomUUID().toString()+Thread.currentThread().getId());
}
public RedisLock(Jedis jedis, String lockKey, String lockValue) {
this.jedis = jedis;
this.lockKey = lockKey;
this.lockValue = lockValue;
}
//...
}
加锁代码
public void lock() {
while(true){
String result = jedis.set(lockKey, lockValue, NOT_EXIST,SECONDS,30);
if(OK.equals(result)){
System.out.println(Thread.currentThread().getId()+"加锁成功!");
break;
}
}
}
解锁代码
public void unlock() {
String lockValue = jedis.get(lockKey);
if (lockValue.equals(lockValue)){
jedis.del(lockKey);
}
}
这时看看加锁代码,好像没有什么问题啊。再来看看解锁的代码,这里的解锁操作包含三步操作:获取值、判断和删除锁。这时你有没有想到在多线程环境下的i++操作?
i设置值为0线程A读到i的值为0线程B也读到i的值为0线程A执行了+1操作,将结果值1写入到内存线程B执行了+1操作,将结果值1写入到内存此时i进行了两次i++操作,但是结果却为1在多线程环境下有什么方式可以避免这类情况发生?解决方式有很多种,例如用AtomicInteger、CAS、synchronized等等。这些解决方式的目的都是要确保i++ 操作的原子性。那么回过头来看看解锁,同理我们也是要确保解锁的原子性。我们可以利用Redis的lua脚本来实现解锁操作的原子性。
版本3的完整代码:Github地址
if redis.call("get",KEYS<1>) == ARGV<1> then
return redis.call("del",KEYS<1>)
else
return 0
end
这段Lua脚本在执行的时候要把的lockValue作为ARGV<1>的值传进去,把lockKey作为KEYS<1>的值传进去。现在来看看解锁的java代码
public void unlock() {
// 使用lua脚本进行原子删除操作
String checkAndDelScript = "if redis.call('get', KEYS<1>) == ARGV<1> then " +
"return redis.call('del', KEYS<1>) " +
"else " +
"return 0 " +
"end";
jedis.eval(checkAndDelScript, 1, lockKey, lockValue);
}
好了,解锁操作也确保了原子性了,那么是不是单机Redis环境的分布式锁到此就完成了?别忘了版本2-设置锁的过期时间还有一个,过期时间如何保证大于业务执行时间问题没有解决。
版本4的完整代码:Github地址
public abstract class RedisLock implements Lock {
//...
protected volatile boolean isOpenExpirationRenewal = true;
/**
* 开启定时刷新
*/
protected void scheduleExpirationRenewal(){
Thread renewalThread = new Thread(new ExpirationRenewal());
renewalThread.start();
}
/**
* 刷新key的过期时间
*/
private class ExpirationRenewal implements Runnable{
@Override
public void run() {
while (isOpenExpirationRenewal){
System.out.println("执行延迟失效时间中...");
String checkAndExpireScript = "if redis.call('get', KEYS<1>) == ARGV<1> then " +
"return redis.call('expire',KEYS<1>,ARGV<2>) " +
"else " +
"return 0 end";
jedis.eval(checkAndExpireScript, 1, lockKey, lockValue, "30");
//休眠10秒
sleepBySencond(10);
}
}
}
}
加锁代码在获取锁成功后将isOpenExpirationRenewal置为true,并且调用scheduleExpirationRenewal方法,开启刷新过期时间的线程。
public void lock() {
while (true) {
String result = jedis.set(lockKey, lockValue, NOT_EXIST, SECONDS, 30);
if (OK.equals(result)) {
System.out.println("线程id:"+Thread.currentThread().getId() + "加锁成功!时间:"+LocalTime.now());
//开启定时刷新过期时间
isOpenExpirationRenewal = true;
scheduleExpirationRenewal();
break;
}
System.out.println("线程id:"+Thread.currentThread().getId() + "获取锁失败,休眠10秒!时间:"+LocalTime.now());
//休眠10秒
sleepBySencond(10);
}
}
解锁代码增加一行代码,将isOpenExpirationRenewal属性置为false,停止刷新过期时间的线程轮询。
public void unlock() {
//...
isOpenExpirationRenewal = false;
}
版本5的完整代码:Github地址
public void testLockCase5() {
//定义线程池
ThreadPoolExecutor pool = new ThreadPoolExecutor(0, 10,
1, TimeUnit.SECONDS,
new SynchronousQueue<>());
//添加10个线程获取锁
for (int i = 0; i < 10; i++) {
pool.submit(() -> {
try {
Jedis jedis = new Jedis("localhost");
LockCase5 lock = new LockCase5(jedis, lockName);
lock.lock();
//模拟业务执行15秒
lock.sleepBySencond(15);
lock.unlock();
} catch (Exception e){
e.printStackTrace();
}
});
}
//当线程池中的线程数为0时,退出
while (pool.getPoolSize() != 0) {}
}
测试结果
或许到这里基于单机Redis环境的分布式就介绍完了。但是使用java的同学有没有发现一个锁的重要特性
那就是锁的重入,那么分布式锁的重入该如何实现呢?这里就留一个坑了
获取当前Unix时间,以毫秒为单位。依次尝试从N个实例,使用相同的key和随机值获取锁。在步骤2,当向Redis设置锁时,客户端应该设置一个网络连接和响应超时时间,这个超时时间应该小于锁的失效时间。例如你的锁自动失效时间为10秒,则超时时间应该在5-50毫秒之间。这样可以避免服务器端Redis已经挂掉的情况下,客户端还在死死地等待响应结果。如果服务器端没有在规定时间内响应,客户端应该尽快尝试另外一个Redis实例。客户端使用当前时间减去开始获取锁时间(步骤1记录的时间)就得到获取锁使用的时间。当且仅当从大多数(这里是3个节点)的Redis节点读取到锁,并且使用的时间小于锁失效时间时,锁才算获取成功。如果取到了锁,key的真正有效时间等于有效时间减去获取锁所使用的时间(步骤3计算的结果)。如果因为某些原因,获取锁失败(没有在至少N/2+1个Redis实例取到锁或者取锁时间已经超过了有效时间),客户端应该在所有的Redis实例上进行解锁(即便某些Redis实例根本就没有加锁成功)。
关于RedLock算法,还有一个小插曲,就是Martin Kleppmann 和 RedLock 作者 antirez的对RedLock算法的互怼。 官网原话如下
Martin Kleppmann analyzed Redlock here. I disagree with the analysis and posted my reply to his analysis here.
更多关于RedLock算法这里就不再说明,有兴趣的可以到官网阅读相关文章。