本章内容
分布式锁
分布式锁是一种在分布式系统中用于控制并发访问的机制。在分布式系统中,多个客户端同时对同一个资源进行访问时,容易出现数据不一致的问题。分布式锁的主要作用就是确保同一时刻只有一个客户端能够对某个资源进行访问,以避免数据不一致的问题。
分布式锁常见的实现方案有两种:
- 基于Redis实现分布式锁。
- 基于Zookeeper实现分布式锁。
应用场景
分布式锁的主要应用场景:
- 数据库并发控制:在分布式数据库中,多个线程同时对某张表进行操作时,可能会出现并发冲突问题,使用分布式锁可以确保同一时刻只有一个线程能够对该表进行操作,避免并发冲突。
- 分布式缓存:在分布式缓存中,多个线程同时对某个缓存进行操作时,可能会出现缓存数据不一致的问题。使用分布式锁可以确保同一时刻只有一个线程能够对该缓存进行操作,保证缓冲数据的一致性。
- 分布式任务调度:在分布式任务调度中,多个线程同时执行某个任务,可能出现任务被重复执行的问题,使用分布式锁可以确保同一时刻只有一个线程能够执行该任务,避免任务被重复执行。
基于Redis分布式锁实现方案
本文主要分析基于Redis的分布式锁实现方案,实现方式有:
- 基于setnx+expire实现分布式锁。
- 基于set ex px nx实现分布式锁。
- 基于set ex px nx+lua脚本实现分布式锁。
- Redission分布式锁。
- MutiLock锁(联锁)。
- RedLock(红锁)。
基于setnx+expire实现分布式锁
实现原理
如图所示:
其中:
- setnx(set if not exists)命令:当且仅当key不存在时,将key的值设为value,若给定的key已经存在,则不做任何处理。返回值:
- 1:设值成功。
- 0:设值失败。
处理流程:
- 1)通过执行setnx命令尝试获取分布式锁:
- 获取成功,执行expire命令设置分布式锁过期时长,执行业务处理,处理完成后执行del命令释放分布式锁。
- 获取失败,执行get命令获取分布式锁过期时间,判断返回值是否为空,为空则重新尝试获取分布式锁;不为空则判断分布式锁是否已过期:
- 分布式锁未过期,则等待指定时长后重新获取分布式锁。
- 分布式锁已过期,则执行getset命令尝试获取分布式锁,判断返回值是否为空,为空则重新尝试获取分布式锁;不为空则判断返回值是否为旧值。是则执行expire命令设置分布式锁过期时长,执行业务处理,执行完成后执行del命令释放分布式锁;否则等待指定时长后重新获取分布式锁。
特别说明:
- 获取分布式锁失败后,需要获取过期时间进行处理的目的是为了防止锁永不过期:setnx和expire命令不是原子性操作,假如在执行setnx命令后,服务器突然宕机,会因无法正常执行expire命令而导致锁永不过期。
- 防止锁永不过期的其他解决方案:可以将setnx命令和expire命令放入一个lua脚本中执行,保证setnx和expire命令的原子性。
代码实现
/**
* 基于setNx+expire实现分布式锁
*/
@SneakyThrows
@Override
public void lockBySetNx() {
// 分布式锁过期时间
long expire = System.currentTimeMillis() + LOCK_EXPIRE;
// 尝试获取分布式锁,返回值:
// true-获取分布式锁成功;
// false-获取分布式锁失败,通过判断分布式锁过期时间再次尝试获取分布式锁(原因:setnx命令和expire命令不是原子性操作。如:在执行setnx命令后,服务器突然宕机,会因无法正常执行expire命令而导致锁永远无法释放)。
if(redisUtil.setNx(LOCK_KEY, expire)){
log.info("**获取分布式锁成功");
getLockSuccess();
log.info("分布式锁已释放");
} else {
log.info("获取分布式锁失败");
// 获取分布式锁的过期时间
Object oldExpire = redisUtil.get(LOCK_KEY);
if(Objects.nonNull(oldExpire)){
long datetime = System.currentTimeMillis();
// 根据设置的过期时间判断分布式锁是否已过期:过期时间小于当前时间,则说明锁已过期,重新尝试获取分布式锁
if(Long.parseLong(String.valueOf(oldExpire)) < datetime){
expire = System.currentTimeMillis() + LOCK_EXPIRE;
// 通过getSet方法尝试获取分布式锁
Object expireTime = redisUtil.getSet(LOCK_KEY, expire);
log.info("分布式锁旧值:" + oldExpire+",getSet方法返回值:" + expireTime);
if(Objects.nonNull(expireTime)){
// 如果返回结果为旧值,则获取获取分布式锁成功
if(Long.parseLong(String.valueOf(expireTime)) == Long.parseLong(String.valueOf(oldExpire))){
log.info("##获取分布式锁成功");
getLockSuccess();
log.info("分布式锁已释放");
}else {
log.info("其他线程获得分布式锁,等待指定时长后重新尝试获取分布式锁");
}
}else {
log.info("**分布式锁已过期,重新尝试获取分布式锁");
}
}else {
log.info("##分布式锁未过期,等待指定时长后重新尝试获取分布式锁");
}
}else {
log.info("分布式锁已释放,重新尝试获取分布式锁");
}
}
}
/**
* 获取分布式锁成功,执行业务处理
*/
@SneakyThrows
private void getLockSuccess(){
// 设置分布式锁过期时长
redisUtil.setExpire(LOCK_KEY, LOCK_EXPIRE);
// 执行业务处理(模拟业务处理时长:2s)
log.info("执行业务处理");
Thread.sleep(2000);
// 处理完成,释放分布式锁
redisUtil.del(LOCK_KEY);
}
存在的缺陷
- 锁失效与锁误删:线程1获得分布式锁锁,执行业务处理,由于业务处理时间过长(超过了锁设定的过期时长),导致其他线程可以获得分布式锁。同时,其他锁获得分布式锁后,执行业务处理,在锁的有效期内线程1业务处理完成,删除分布式锁,此时删除的是其他线程获得的分布式锁。
基于set ex px nx实现分布式锁
实现原理
如图所示:
其中:
- SET key value[EX seconds][PX milliseconds][NX|XX]
- NX :表示key不存在时才能设值成功,即:保证只有第一个客户端请求才能获得锁,而其他客户端请求只能等其释放锁后才能获取锁。
- EX seconds :设定key的过期时间,时间单位是秒。
- PX milliseconds: 设定key的过期时间,单位为毫秒。
- XX: 仅当key存在时设置值。
处理流程:
- 1)通过执行set lockKey threadId ex px nx命令尝试获取分布式锁,获取成功,执行业务处理,处理完成后执行get命令获取分布式锁设置的值(threadId),如果返回值为当前threadId,则执行del命令释放分布式锁。
- 2)获取失败,则等待指定时长后重新获取分布式锁。
代码实现
/**
* 基于set ex px nx实现分布式锁
*/
@SneakyThrows
@Override
public void lockBySetExNx() {
// 获取当前线程ID
long currentThreadId = Thread.currentThread().getId();
log.info("当前线程ID:" + currentThreadId);
try {
// 尝试获取分布式锁
// true-获取分布式锁成功;
// false-获取分布式锁失败。
if (redisUtil.set(LOCK_KEY, currentThreadId, LOCK_EXPIRE, TimeUnit.MILLISECONDS)) {
log.info("**获取分布式锁成功");
log.info("执行业务处理");
Thread.sleep(2000);
} else {
log.info("##获取分布式锁失败,等待指定时长后重新尝试获取分布式锁");
}
}finally {
Object expireTime = redisUtil.get(LOCK_KEY);
log.info("删除时持有分布式锁的线程ID:" + expireTime);
if (Objects.nonNull(expireTime) && Long.parseLong(String.valueOf(expireTime)) == currentThreadId) {
// 处理完成,释放分布式锁
redisUtil.del(LOCK_KEY);
log.info("分布式锁已释放");
}
}
}
存在的缺陷
- 锁失效:线程1获得分布式锁锁,执行业务处理,由于业务处理时间过长(超过了锁设定的过期时长),导致其他线程可以获得分布式锁。
- 锁误删:线程1获得分布式锁锁,执行业务处理,处理完成后执行del命令释放锁,如果在执行del命令前出现卡顿(卡顿时长超过分布式锁设定的过期时长),其他线程获得分布式锁锁,执行业务处理,此时线程1恢复并继续执行del操作,删除的是其他线程获得的分布式锁。
基于set ex px nx+lua脚本实现分布式锁
实现原理
基于set ex px nx+lua脚本实现分布式锁的处理流程与基于set ex px nx实现分布式锁的处理流程一致,只是在业务处理完成后释放分布式锁时,将get和del命令放入一个lua脚本中执行,保证get和del命令的原子性。
代码实现
/**
* 基于set ex px nx+lua脚本实现分布式锁
*/
@SneakyThrows
@Override
public void lockBySetExNx() {
// 获取当前线程ID
long currentThreadId = Thread.currentThread().getId();
log.info("当前线程ID:" + currentThreadId);
try {
// 尝试获取分布式锁
// true-获取分布式锁成功;
// false-获取分布式锁失败。
if (redisUtil.set(LOCK_KEY, currentThreadId, LOCK_EXPIRE, TimeUnit.MILLISECONDS)) {
log.info("**获取分布式锁成功");
log.info("执行业务处理");
Thread.sleep(2000);
} else {
log.info("##获取分布式锁失败,等待指定时长后重新尝试获取分布式锁");
}
}finally {
// 释放分布式锁
delLock(LOCK_DEL_LUA,LOCK_KEY,currentThreadId);
}
}
/**
* 释放分布式锁lua脚本
*/
private static String LOCK_DEL_LUA;
static {
final StringBuilder stringBuilder = new StringBuilder();
stringBuilder.append("if(redis.call('get',KEYS[1])==ARGV[1])then");
stringBuilder.append(" redis.call('del',KEYS[1]);");
stringBuilder.append("end;");
LOCK_DEL_LUA = stringBuilder.toString();
}
/**
* 释放分布式锁
* @param lua lua脚本
* @param key 分布式锁key
* @param threadId 线程id
*/
private void delLock(String lua, String key, long threadId){
RedisScript<Long> redisScript = new DefaultRedisScript<>(lua, Long.class);
// 执行lua脚本
redisUtil.executeLua(redisScript, Collections.singletonList(key), threadId);
}
存在的缺陷
- 锁失效:某个线程获得分布式锁,执行业务处理,由于业务处理时间过长(超过了锁设定的过期时长),导致其他线程可以获得分布式锁。
Redission分布式锁
基于set ex px nx+lua脚本实现分布式锁方案虽然解决了锁永不过期和锁误删问题,但该分布式锁不支持可重入功能,当有线程执行时间超过分布式锁设置的过期时间时,仍然会存在问题。
Redission分布式锁引入了看门狗(watch dog)机制,很好的解决了锁重入问题。即:Redission分布式锁是可重入锁。
实现原理
Redisson使用key-hashMap来实现分布式锁,key为锁标识,value为hashMap结构,hashMap也是key-value组合,key为持有锁的线程,value为锁重入次数。
获取锁的lua脚本:
local key = KEYS[1]; -- 锁的key
local threadId = ARGV[1]; -- 线程唯一标识
local releaseTime = ARGV[2]; -- 锁的自动释放时间
-- 判断锁是否已存在
if(redis.call('exists', key) == 0) then
-- 锁不存在, 则获取锁
redis.call('hset', key, threadId, '1');
-- 设置锁的有效期
redis.call('expire', key, releaseTime);
return 1; -- 返回结果
end;
-- 锁已经存在,判断持有锁的threadId是否为当前threadId
if(redis.call('hexists', key, threadId) == 1) then
-- 是当前threadId, 获取锁并设置重入次数+1
redis.call('hincrby', key, threadId, '1');
-- 设置锁的有效期
redis.call('expire', key, releaseTime);
return 1; -- 返回结果
end;
return 0; -- 持有锁的threadId不是当前线程,获取锁失败
释放锁的lua脚本:
local key = KEYS[1]; -- 锁的key
local threadId = ARGV[1]; -- 线程唯一标识
local releaseTime = ARGV[2]; -- 锁的自动释放时间
-- 判断是否为当前线程持有锁
if (redis.call('HEXISTS', key, threadId) == 0) then
-- 不是当前线程持有锁,则直接返回
return nil;
end;
-- 是当前线程持有锁,则重入次数-1
local count = redis.call('HINCRBY', key, threadId, -1);
-- 判断锁计数是否为0
if (count > 0) then
-- 大于0,则重置有效期
redis.call('EXPIRE', key, releaseTime);
return nil;
else
-- 等于0,则释放锁
redis.call('DEL', key);
return nil;
end;
整体流程,如图所示:
处理流程:
- 1)尝试获取分布式锁,如果锁不存在(即:锁未被占用),则获取锁成功并添加线程标识(表示当前线程持有锁),设置锁计数为1(初始值为0),设置锁过期时间。
- 2)如果锁已经存在(即:锁被占用),判断锁标记是否为当前线程,是当前线程,则锁计数+1(即:重入),设置锁过期时间;不是当前线程,则等待一定时长后重试。
- 3)执行业务处理,处理完成后开始执行锁释放流程。
- 4)判断锁持有的线程是否为当前线程,是则锁计数-1,判断锁计数是否为0,为0则释放锁,否则使用看门狗续期锁过期时间(续期时长默认30秒,每隔10秒(30秒/3)异步续期)并继续执行业务处理,直到锁计数为0,释放锁。
- 5)如果锁持有的线程不是当前线程,则说明锁已释放。
注意:Redission分布式锁加锁和解锁均采用lua脚本的方式执行,保证原子性。
使用示例
@SneakyThrows
@Override
public void redissionLock() {
Config config = new Config();
config.useSingleServer().setAddress("redis://127.0.0.1:6379").setPassword("xxxxxx");
RedissonClient redissonClient = Redisson.create(config);
RLock lock = redissonClient.getLock(LOCK_KEY);
// 尝试获取锁
boolean isLock = lock.tryLock();
if (!isLock) {
log.error("获取锁失败");
return;
}
try {
log.info("获取锁成功");
// 执行业务处理
Thread.sleep(200);
log.info("业务处理完成");
} finally {
log.warn("释放锁");
lock.unlock();
}
// 等待执行完成
Thread.sleep(5000);
redissonClient.shutdown();
}
Redission看门狗机制
Redission看门狗机制实现原理,如图所示:
处理流程:
- 1)尝试获取分布式锁,如果设置自定义有效期,则获取锁成功返回空,获取锁失败返回锁的剩余有效期。
- 2)如果没有设置自定义有效期,获取锁失败,返回锁的剩余有效期;获取锁成功,判断是否重入锁(判断依据是持有锁的线程是否为当前线程),是则按已经存在的续约机制进行续约;否则设置锁计数为1,设置锁的有效期,开启看门狗续约。
- 3)获取锁对象(ExpirationEntry),并将锁对象封装成一个延时任务Task(线程)以10(默认有效期30秒/3)秒/次的频率对锁进行续约,如果续约失败(即:续约时发生异常),则等待锁有效期满后释放锁。
- 4)业务处理完成,取消续约延时任务并释放锁。
MutiLock(联锁)
在Redis使用过程中,为了提高可用性,一般会搭建Redis集群或基于哨兵机制的主从集群。
当线程向Redis集群写入锁信息时,会先将锁信息写入集群中的主节点,主节点写入完成后再将数据同步给该主节点下的其他从节点,如果在进行数据同步的过程中主节点突然宕机,集群从其他从节点中选举出一个新的主节点,由于数据未同步到新的主节点,因此会导致锁信息丢失,此时其他线程就可以获得该锁并进行业务处理,造成锁失效。
为此,redission引入了MutiLock锁,MutiLock锁中每个节点都是平等节点(非主从),加锁时需要将加锁逻辑写入每个节点中,只有所有节点都写入成功才表示加锁成功。
如果线程向所有节点写入锁信息时,某个节点突然宕机,节点重新启动后其他线程尝试获取该锁,由于该锁已经存在于其他节点中,因此,该线程因无法将锁信息写入其他节点而获取锁失败。
实现原理
如图所示:
处理流程:
- 1)初始化每个节点对应的锁。
- 2)创建RedissonMultiLock对象,并将各节点对应的锁加入到RedissonMultiLock对象的locks集合中。
- 3)遍历locks集合,执行集合中每个节点对应锁的tryLock()方法尝试获取锁:
- 获取成功,则将锁加入到RedissonMultiLock对象的acquiredLocks集合中,判断RedissonMultiLock是否设置了有效期(即:自定义锁的过期时长):
- 是则判断是否超时,超时则释放acquiredLocks集合中已经获取成功的锁,返回获取锁失败;不超时则继续遍历locks集合中的锁。
- 否则继续遍历locks集合中的锁。
- 获取失败,则释放acquiredLocks集合中已经获取成功的锁,判断RedissonMultiLock是否设置了等待时长(waitTime):
- 是则清空acquiredLocks集合并从头开始遍历locks集合进行重试。
- 否则返回获取锁失败。
- 获取成功,则将锁加入到RedissonMultiLock对象的acquiredLocks集合中,判断RedissonMultiLock是否设置了有效期(即:自定义锁的过期时长):
- 4)全部加锁成功,重置每个节点对应锁的有效期,返回获取锁成功。
使用示例
@SneakyThrows
@Override
public void mutiLock() {
Config config1 = new Config();
config1.useSingleServer().setAddress("redis://127.0.0.1:6379").setPassword("xxxxxx");
RedissonClient redissonClient1 = Redisson.create(config1);
Config config2 = new Config();
config2.useSingleServer().setAddress("redis://127.0.0.1:6380").setPassword("xxxxxx");
RedissonClient redissonClient2 = Redisson.create(config2);
Config config3 = new Config();
config3.useSingleServer().setAddress("redis://127.0.0.1:6381").setPassword("xxxxxx");
RedissonClient redissonClient3 = Redisson.create(config3);
RLock lock1 = redissonClient1.getLock("lock-order");
RLock lock2 = redissonClient2.getLock("lock-order");
RLock lock3 = redissonClient3.getLock("lock-order");
RLock multiLock = redissonClient1.getMultiLock(lock1, lock2, lock3);
try {
multiLock.lock();
log.info("获取锁成功");
// 执行业务处理
Thread.sleep(200);
log.info("业务处理完成");
} finally {
log.warn("释放锁");
multiLock.unlock();
}
// 等待执行完成
Thread.sleep(5000);
redissonClient1.shutdown();
redissonClient2.shutdown();
redissonClient3.shutdown();
}
RedLock
RedLock继承了RedissonMultiLock,并重写RedissonMultiLock的failedLocksLimit()方法,是一种特殊的MultiLock锁,它要解决的问题与MultiLock锁类似,防止Redis集群中因主节点异常宕机而导致锁失效。
RedLock锁的实现原理与MultiLock基本一致,MultiLock加锁时需要所有节点获取锁成功才表示加锁成功,而RedLock加锁时只需超过半数节点获取锁成功就表示加锁成功。
注意:RedLock目前已经标注为@Deprecated,即:已经被废弃。
使用示例:
@SneakyThrows
@Override
public void redLock() {
Config config1 = new Config();
config1.useSingleServer().setAddress("redis://127.0.0.1:6379").setPassword("xxxxxx");
RedissonClient redissonClient1 = Redisson.create(config1);
Config config2 = new Config();
config2.useSingleServer().setAddress("redis://127.0.0.1:6380").setPassword("xxxxxx");
RedissonClient redissonClient2 = Redisson.create(config2);
Config config3 = new Config();
config3.useSingleServer().setAddress("redis://127.0.0.1:6381").setPassword("xxxxxx");
RedissonClient redissonClient3 = Redisson.create(config3);
RLock lock1 = redissonClient1.getLock("lock-order");
RLock lock2 = redissonClient2.getLock("lock-order");
RLock lock3 = redissonClient3.getLock("lock-order");
RedissonRedLock redLock = new RedissonRedLock(lock1, lock2, lock3);
try {
redLock.lock();
log.info("获取锁成功");
// 执行业务处理
Thread.sleep(200);
log.info("业务处理完成");
} finally {
log.warn("释放锁");
redLock.unlock();
}
// 等待执行完成
Thread.sleep(5000);
redissonClient1.shutdown();
redissonClient2.shutdown();
redissonClient3.shutdown();
}
【作者简介】
一枚热爱技术和生活的老贝比,专注于Java领域,关注【南秋同学】带你一起学习成长~