在分布式系统中,有些业务场景会用到分布式锁,实现分布式锁的方式有很多,本篇主要讲根据Redis如何来实现。
首先我们要知道分布式锁的一些基本特点:
下边我们通过几个例子来说明分布式锁为什么需要以上3个特点。
/**
* 使用jedis客户端实现分布式锁
* @Author: maomao
* @Date: 2021-04-27 08:42
*/
public class DistLock {
private static final String LOCK_SUCCESS = "OK";
private static final String SET_IF_NOT_EXIST = "NX";
private static final String SET_WITH_EXPIRE_TIME = "PX";
private static final Long RELEASE_SUCCESS = 1L;
/**
* 尝试获取分布式锁
* @param jedis Redis客户端
* @param lockKey 锁
* @param value 值
* @param expireTime 超期时间
* @return 是否获取成功
*/
public static boolean tryGetDistributedLock(Jedis jedis, String value, String requestId, int expireTime) {
// set支持多个参数 NX(not exist) XX(exist) EX(seconds) PX(million seconds)
String result = jedis.set(lockKey, value, SET_IF_NOT_EXIST, SET_WITH_EXPIRE_TIME, expireTime);
if (LOCK_SUCCESS.equals(result)) {
return true;
}
return false;
}
/**
* 直接删除解锁,未判断客户端ID,会导致其他客户端把锁释放
* @param jedis
* @param lockKey
*/
public static void releaseLock1(Jedis jedis, String lockKey) {
jedis.del(lockKey);
}
}
上边代码是一个不验证客户端的例子,加锁是没有问题的,但在解锁时会有很大的问题。
通过上图可以看到,因为没有校验客户端逻辑,Thread B可以直接解锁,而Thread A程序还未执行完,但已被解锁,造成锁失效。如果此时有其他客户端加锁是可以加锁成功的。
那我们可以在代码中增加一个客户端校验不就可以了?
/**
* 尝试获取分布式锁
* @param jedis Redis客户端
* @param lockKey 锁
* @param requestId 请求标识-修改此处为客户端唯一标致
* @param expireTime 超期时间
* @return 是否获取成功
*/
public static boolean tryGetDistributedLock(Jedis jedis, String lockKey, String requestId, int expireTime) {
// set支持多个参数 NX(not exist) XX(exist) EX(seconds) PX(million seconds)
String result = jedis.set(lockKey, requestId, SET_IF_NOT_EXIST, SET_WITH_EXPIRE_TIME, expireTime);
if (LOCK_SUCCESS.equals(result)) {
return true;
}
return false;
}
/**
* 增加锁判断,但因判断与删除不是原子操作,在并发场景时,会导致错误删除
* @param jedis
* @param lockKey
* @param requestId
*/
public static void releaseLock2(Jedis jedis, String lockKey, String requestId) {
// 判断加锁与解锁是不是同一个客户端
if (requestId.equals(jedis.get(lockKey))) {
// 若在此时,这把锁突然不是这个客户端的,则会误解锁
// 两个操作不能保证原子性
jedis.del(lockKey);
}
}
在解锁代码中可以看到,我们也增加了客户端标志校验应该可以解决客户端校验问题了吧?其实并没有,我们要知道对redis来说,每个命令都是原子的,你的get与del方法是两个命令,无法保证原子操作。也就是我们多线程中常见的i++;操作,其实他是由3个操作执行。
那我们如何确保get与del的原子操作呢?我们可以使用lua脚本来实现。上述代码我们可以调整为一个lua脚本。
/**
* 释放分布式锁,使用lua脚本删除,可确保判断与删除的原子操作
* @param jedis Redis客户端
* @param lockKey 锁
* @param requestId 请求标识
* @return 是否释放成功
*/
public static boolean releaseDistributedLock(Jedis jedis, String lockKey, String requestId) {
String script = "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end";
Object result = jedis.eval(script, Collections.singletonList(lockKey), Collections.singletonList(requestId));
if (RELEASE_SUCCESS.equals(result)) {
return true;
}
return false;
}
通过增加客户端校验与解锁的原子性就可以实现安全的解锁。
有了上边的方式是不是就可以确保分布式锁的全部问题了?并不是,还有一种场景没有考虑到。
如果我们的加锁程序执行时间超出锁过期时间时,就会导致分布式锁失效。此时其他客户端是可以获得到锁的。如下图:
那么这种问题如何解决呢?
Redisson是一个在Redis的基础上实现的Java驻内存数据网格(In-Memory Data Grid),提供了分布式和可扩展的Java数据结构,比如分布式对象,分布式集合(Map、List、Queue、Set),分布式锁等等功能,不需要自己去运行一个服务实现。
Redission是由一个中国人与俄罗斯人共同发起的,所以中文文档比较详细。
使用Redission可以很简单的实现分布式锁,代码如下:
public static void main(String[] args) throws InterruptedException {
//设定锁标志
//会在redis中创建一个Hash,Key是客户端UUID,value是锁重入次数
RLock rLock = redissonClient.getLock("lockKey");
// 最多等待100秒、上锁10s以后自动解锁
if(rLock.tryLock(100,10, TimeUnit.SECONDS)){
System.out.println("获取锁成功,此时可以查看redis中的数据!");
}
//线程等待后可在redis中查到
Thread.sleep(20000);
rLock.unlock();
}
Redission不只可以实现独占锁,还可以实现如:可重入锁、公平锁、联锁、红锁、读写锁等等。
redission实现分布式锁的逻辑基本与上边我们讲的原理差不多,它还解决了我们最后一个问题,程序执行时间超出锁过期时间的问题。
他使用了一个《看门狗》的概念来实现自动续期。默认最大续期时间30s,也就是说如果业务超出30秒还未执行会自动解锁。