一、简单的分布式锁
实现方式:setnx 命令,依据返回值为 0 或 1 判断是否抢到了资源。
# redis全局添加一个key为lock的元素,该关键字不可再被使用
setnx lock 1
# 释放锁,删除对该关键字的占用
del lock
在redis中对某个资源加锁,可以控制其他系统访问这个资源的权限,于是实现了分布式锁。
二、死锁问题
如果客户端的应用程序发生异常或者直接挂掉,不能及时释放锁会产生死锁问题。聪明的我当然想到给锁设置过期时间。
# 占用锁
setnx lock 1
# 设置过期时间 10s
expire lock 10
但是,这里发现是需要两条命令才能实现锁过期时间,不具备原子性,还是有可能发生第一条命令执行成功,第二条命令执行失败的情况。
可以使用一条命令实现加锁设置过期时间吗?当然可以。
set lock 1 ex 10 nx
redis 2.6 之后的版本支持使用单条命令实现加锁设置过期时间。
三、锁被其他客户端释放了怎么办?
这里有一个简单的权限校验方法,uuid。
以下是参杂着java代码的实现逻辑,核心思想是这样;
// 生成一个uuid
String uuid = UUID.randomUUID().toString();
// redis中保存lock的value为这个uuid
set lock uuid;
// 判断保存的uuid是否和当前客户端拥有的uuid相同
if(lock.value == uuid) {
del lock;
}
但是缺点也很明显,不但参杂着java代码的逻辑,而且校验和删除在redis中是两步操作,不保证原子性;
这时我们想到可以使用Lua脚本实现权限校验和删除锁的操作:
if (redis.call("GET", KEYS[1]) == ARGV[1])
then
return redis.call("DEL", KEYS[1])
else
return 0
end
四、在Java代码中实现锁
1、单机锁
public class AloneLock implements Lock {
// 维护一个原子对象,内部是Thread
AtomicReference<Thread> owner = new AtomicReference<>();
// 维护一个阻塞队列
LinkedBlockingQueue<Thread> waiter = new LinkedBlockingQueue<>();
/**
* 加锁
*/
@Override
public void lock() {
// 判断当前线程是否能进入原子对象
while (!owner.compareAndSet(null, Thread.currentThread())) {
// 如果不能进入
// 就进入阻塞队列
waiter.add(Thread.currentThread());
// 并且阻塞当前线程
LockSupport.park();
// 线程唤醒后移出阻塞队列,重新争抢资源
waiter.remove(Thread.currentThread());
}
}
/**
* 解锁
*/
@Override
public void unlock() {
// 将原子对象内部的线程替换成null -> 原子对象内部的线程就是当前线程
if (owner.compareAndSet(Thread.currentThread(), null)) {
// 如果释放成功
// 唤醒阻塞队列中所有的线程
for (Thread thread : waiter) {
LockSupport.unpark(thread);
}
}
}
}
可以看到,在单应用的情况下加锁只需要在Java程序中控制资源的占有和释放即可。
2、分布式锁
分布式锁用到了redis中的共享资源,多个应用客户端共同争抢redis中的同一个资源,我们先配置Jedis,再操作redis中的资源。
配置文件:
# redis
spring.redis.host=127.0.0.1
spring.redis.port=6379
spring.redis.database=0
spring.redis.timeout=5000
# jedisPool
spring.redis.jedis.pool.max-active=8
spring.redis.jedis.pool.max-idle=8
spring.redis.jedis.pool.min-idle=0
spring.redis.jedis.pool.max-wait=3000
spring.redis.JmxEnabled=true
spring.redis.blockWhenExhausted=true
RedisConfig:
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.PropertySource;
import redis.clients.jedis.JedisPool;
import redis.clients.jedis.JedisPoolConfig;
/**
* Jedis 配置
*
* @Date: 2024/07/22/14:37
*/
@Configuration
@PropertySource("classpath:application.properties")
public class RedisConfig {
@Value("${spring.redis.host}")
private String host;
@Value("${spring.redis.port}")
private int port;
@Value("${spring.redis.timeout}")
private int timeout;
@Value("${spring.redis.jedis.pool.max-idle}")
private int maxIdle;
@Value("${spring.redis.jedis.pool.max-wait}")
private int maxWaitMillis;
@Value("${spring.redis.blockWhenExhausted}")
private Boolean blockWhenExhausted;
@Value("${spring.redis.JmxEnabled}")
private Boolean JmxEnabled;
@Bean
public JedisPool jedisPool() {
JedisPoolConfig jedisPoolConfig = new JedisPoolConfig();
jedisPoolConfig.setMaxIdle(maxIdle);
jedisPoolConfig.setMaxWaitMillis(maxWaitMillis);
// 连接耗尽时是否阻塞, false报异常,true阻塞直到超时, 默认true
jedisPoolConfig.setBlockWhenExhausted(blockWhenExhausted);
// 是否启用pool的jmx管理功能, 默认true
jedisPoolConfig.setJmxEnabled(JmxEnabled);
JedisPool jedisPool = new JedisPool(jedisPoolConfig, host, port, timeout);
return jedisPool;
}
}
实现简单的分布式锁:
@Component
@Data
public class RedisDistLock implements Lock {
// 设置锁过期时间
private static final Long LOCK_TIME = 5 * 1000L; // 5s
// 设置redis锁名称前缀
private String lockName = "lock";
// 设置解锁用的Lua脚本
private static final String RELEASE_LOCK_LUA =
"if redis.call('get', KEYS[1]) == ARGV[1]\n" +
"then\n" +
" return redis.call('del', KEYS[1])\n" +
"else\n" +
" return 0\n" +
"end";
// 保存每个线程的uuid值
private ThreadLocal<String> lockerId = new ThreadLocal<>();
// 保存锁对象,处理锁重入
private Thread ownerThread;
@Resource
private JedisPool jedisPool;
/**
* 加锁
*/
@Override
public void lock() {
while (!tryLock()) {
try {
Thread.sleep(100);
} catch (Exception e) {
e.printStackTrace();
}
}
}
/**
* 尝试获取锁
*/
@Override
public boolean tryLock() {
// 获取当前线程对象
Thread thread = Thread.currentThread();
// 前置校验
if (ObjectUtils.nullSafeEquals(ownerThread, thread)) {
// 如果当前线程持有锁 - 处理锁重入
return true;
} else if (!ObjectUtils.nullSafeEquals(ownerThread, null)){
// 如果其他线程持有锁 - 抢锁失败
return false;
}
// 获取jedis
Jedis jedis = jedisPool.getResource();
try {
// 获取当前线程唯一校验密码
String lockValue = UUID.randomUUID().toString();
// 组装redis命令参数
SetParams redisParams = new SetParams();
redisParams.px(LOCK_TIME); // 设置过期时间
redisParams.nx(); // 设置redis - key为不可修改
// 同步代码块实现抢锁
synchronized (this) {
// 两层 if 增强可读性
// 进一步校验锁持有状态
if (ObjectUtils.nullSafeEquals(ownerThread, null)) {
// 给redis加锁
if ("OK".equals(jedis.set(lockName, lockValue, redisParams))) {
// 如果在redis中加锁成功
lockerId.set(lockValue); // 记录当前线程的密码
setOwnerThread(thread); // 记录当前线程对象,给本地加锁
return true;
}
return false;
}
}
} catch (RuntimeException e) {
throw new RuntimeException("分布式锁尝试加锁失败!");
} finally {
jedis.close();
}
return false;
}
/**
* 解锁
*/
@Override
public void unlock() {
// 校验当前线程是否有权限解锁
Thread thread = Thread.currentThread();
if (!ObjectUtils.nullSafeEquals(ownerThread, thread)) {
throw new RuntimeException("当前线程无权限解锁");
}
// 获取jedis
Jedis jedis = jedisPool.getResource();
try {
// 允许Lua脚本实现解锁 - 在redis中校验密码
Long result = (Long) jedis.eval(RELEASE_LOCK_LUA, Arrays.asList(lockName), Arrays.asList(lockerId.get()));
// 校验是否解锁成功
if (0L != result) {
// 如果成功了
System.out.println("redis锁已释放!");
} else {
System.out.println("redis锁释放失败!");
}
} catch (RuntimeException e) {
throw new RuntimeException("锁释放失败");
} finally {
if (!ObjectUtils.isEmpty(jedis)) jedis.close();
lockerId.remove(); // 不记录当前线程密码
setOwnerThread(null); // 不记录当前线程对象
System.out.println("本地锁释放成功!");
}
}
@Override
public void lockInterruptibly() throws InterruptedException {
}
@Override
public boolean tryLock(long time, TimeUnit unit) throws InterruptedException {
return false;
}
@Override
public Condition newCondition() {
return null;
}
}
在分布式环境下,由于Java程序只能在自己的范围内控制资源加锁,不能控制redis这种共享数据源的资源。所以加锁操作使用了一个Jedis提供的类SetParams实现资源的加锁校验和加锁,而解锁操作则使用到了Lua脚本实现锁资源的校验和解锁。
加锁/解锁 = 校验 + 操作。
五、锁过期时间如何评估?
1、如果业务处理时间超过锁过期时间怎么办?
这里有两个办法:
① 设置一段很长的过期时间。
② 锁快要过期时还没有完成业务逻辑,给锁延期。
明显是第二个办法更好,我们叫这种办法为看门狗续期。
2、看门狗模型 - 使用守护线程实现可延期分布式锁
如图,获取锁的线程可以开启一个守护线程替主线程定时监测锁的状态,假如主线程一直没有主动放弃锁,那么守护线程则帮助主线程给锁续期,直到主线程主动放弃锁。
如果主线程出现异常停掉,那么守护线程也会随之消亡,不会继续给锁续期,锁达到过期时间后自动释放,不会出现死锁问题。
下面来看手写代码实现:
/**
* 包装一个延迟队列的实体类
* 维护一个到期时刻字段,比标准的delay的实现要提前一点时间
* 存放一个data,保存redis中锁的key-value
*
* @Date: 2024/07/02/9:06
*/
public class ItemVO<T> implements Delayed {
private long activeTime; // 到期时刻
private T data; // 具体业务数据
/**
* 构造方法
* @param expirationTime 过期时长 -> 需要转换为到期时间后,再稍微提前一点
* @param data 业务数据
*/
public ItemVO(long expirationTime, T data) {
super();
this.activeTime = expirationTime + System.currentTimeMillis() - 100;
this.data = data;
}
public long getActiveTime() {
return activeTime;
}
public T getData() {
return data;
}
/**
* 返回当前元素到激活时刻的剩余存活时长
* @param unit the time unit
* @return 剩余存活时长
*/
@Override
public long getDelay(TimeUnit unit) {
return unit.convert(this.activeTime - System.currentTimeMillis(), unit);
}
/**
* 按剩余存活时长排序
* @param o the object to be compared.
* @return 0-相等,-1-当前元素存活时间短,1-入参元素存活时间短
*/
@Override
public int compareTo(Delayed o) {
long d = (getDelay(TimeUnit.MILLISECONDS) - o.getDelay(TimeUnit.MILLISECONDS));
if (d == 0) {
return 0;
} else {
if (d < 0) {
return -1;
} else {
return 1;
}
}
}
}
/**
* 延迟队列实际元素
* Redis的key-value结构
*
* @Date: 2024/07/02/9:05
*/
public class LockItem {
private final String key;
private final String value;
public LockItem(String key, String value) {
this.key = key;
this.value = value;
}
public String getKey() {
return key;
}
public String getValue() {
return value;
}
}
/**
* redis 续期分布式锁
*
* @Date: 2024/06/27/8:42
*/
@Component
public class RedisDistLockWithDog implements Lock {
// 记录本系统当前获取锁的线程
private Thread ownerThread;
// 设置锁自动释放时长
private static final int LOCK_TIME = 1 * 1000;
// 设置锁名称
private static final String LOCK_NAME = "lock";
// 保存每个线程的uuid - 密码
private ThreadLocal<String> lockerId = new ThreadLocal<>();
// 共享的看门狗线程
private Thread dogThread;
// 创建一个延迟队列 避免无用的轮询,减少看门狗轮询次数
private static DelayQueue<ItemVO<LockItem>> delayQueue = new DelayQueue<>();
// redis续锁Lua脚本
private static final String DELAY_LOCK_LUA =
"if redis.call('get', KEYS[1]) == ARGV[1]\n" +
" then\n" +
" return redis.call('pexpire', KEYS[1],ARGV[2])\n" +
" else \n" +
" return 0 \n" +
" end";
private static final String RELEASE_LOCK_LUA =
"if redis.call('get', KEYS[1]) == ARGV[1] \n" +
" then\n" +
" return redis.call('del', KEYS[1])\n" +
" else\n" +
" return 0\n" +
" end";
// 注入JedisPool
@Resource
private JedisPool jedisPool = new JedisPool();
public void setOwnerThread(Thread ownerThread) {
this.ownerThread = ownerThread;
}
@Override
public void lock() {
while (!tryLock()) {
try {
Thread.sleep(100);
} catch (Exception e) {
e.printStackTrace();
}
}
}
@Override
public boolean tryLock() {
Thread thread = Thread.currentThread();
// 校验本线程是否已持有锁
if (thread == ownerThread) {
return true;
} else if (null != ownerThread) {
// 如果是别的线程持有锁
return false;
}
// 如果没有任何线程持有锁 - 开始加锁
Jedis jedis = null;
try {
// 获取jedis实例
jedis = jedisPool.getResource();
// 获取一个唯一校验密码
String uuid = UUID.randomUUID().toString();
// 设置redis加锁参数
SetParams params = new SetParams();
params.nx();
params.px(LOCK_TIME);
// 同步代码块实现抢锁
synchronized (this) {
// 再次判断锁是否被占用 & 在redis中加上分布式锁
if (null == ownerThread && "OK".equals(jedis.set(LOCK_NAME, uuid, params))) {
// 如果加锁成功
// 记录当前线程的uuid
lockerId.set(uuid);
// 记录获取锁的线程实例
setOwnerThread(thread);
// 启动看门狗线程
if (null == dogThread) {
dogThread = new Thread(null, new DogTask(), "dogTask");
dogThread.setDaemon(true); // 设置为守护线程
dogThread.start(); // 启动线程,执行其run方法
}
// 向延迟阻塞队列中加入元素,让看门狗在锁过期时间之前一点的时间去做锁的续期
delayQueue.add(new ItemVO<>(Integer.valueOf(LOCK_TIME), new LockItem(LOCK_NAME, uuid)));
System.out.println("已获取锁---");
return true;
} else {
System.out.println("无法获取锁---");
return false;
}
}
} catch (Exception e) {
throw new RuntimeException("分布式锁加锁失败", e);
} finally {
if (null != jedis) jedis.close();
}
}
// 看门狗线程实体类
private class DogTask implements Runnable {
@Override
public void run() {
System.out.println("看门狗线程已启动...");
// 创建一个循环,只要守护线程存活就一直执行
while (!Thread.currentThread().isInterrupted()) {
try {
Jedis jedis = null;
// 只有元素快到期了才能take到,否则一直阻塞,直到take到元素
LockItem data = delayQueue.take().getData();
try {
jedis = jedisPool.getResource();
Long result = (Long) jedis.eval(DELAY_LOCK_LUA,
Arrays.asList(data.getKey()),
Arrays.asList(data.getValue(), String.valueOf(LOCK_TIME)));
if (result == 0L) {
System.out.println("Redis上的锁已释放,无需续期!");
} else {
delayQueue.add(new ItemVO<>(Integer.valueOf(LOCK_TIME), new LockItem(data.getKey(), data.getValue())));
System.out.println("Redis上的锁已续期:" + LOCK_TIME);
}
} catch (Exception e) {
throw new RuntimeException("锁续期失败!", e);
} finally {
if (jedis != null) jedis.close();
}
} catch (Exception e) {
System.out.println("看门狗线程被中断!");
break;
}
}
System.out.println("看门狗线程准备关闭...");
}
}
/**
* 解锁
*/
@Override
public void unlock() {
if (ownerThread != Thread.currentThread()) {
throw new RuntimeException("试图释放无所有权的锁!");
}
Jedis jedis = null;
try {
jedis = jedisPool.getResource();
Long result = (Long) jedis.eval(RELEASE_LOCK_LUA, Arrays.asList(LOCK_NAME), Arrays.asList(lockerId.get()));
if (result != 0L) {
System.out.println("redis上的锁已释放");
} else {
System.out.println("redis上的锁释放失败");
}
} catch (Exception e) {
throw new RuntimeException("锁释放失败!");
} finally {
if (null != jedis) jedis.close();
lockerId.remove();
setOwnerThread(null);
}
}
@Override
public void lockInterruptibly() throws InterruptedException {
throw new UnsupportedOperationException("不支持可中断获取锁!");
}
@Override
public boolean tryLock(long time, TimeUnit unit) throws InterruptedException {
throw new UnsupportedOperationException("不支持等待尝试获取锁!");
}
@Override
public Condition newCondition() {
return null;
}
}
使用看门狗实现锁的续期完美解决了其他线程释放本线程的锁、死锁、锁过期时间不好设置的问题,但缺点是实现过于复杂,写代码不友好;
六、使用Redisson实现分布式锁
前面提到的手写分布式锁代码就是Redisson看门狗的大概执行流程,但是手写代码太复杂,不适合在生产环境使用,下面我们用Redisson感受一下分布式锁的使用:
首先需要配置RedissonConfig,设置一些重要的参数,注意看注释:
import org.redisson.Redisson;
import org.redisson.api.RedissonClient;
import org.redisson.config.Config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
/**
* RedisClient 配置
*
* @Date: 2024/07/05/7:54
*/
@Configuration
public class RedissonConfig {
/**
* 所有业务代码都使用RedissonClient
*/
@Bean(destroyMethod = "shutdown")
public RedissonClient redissonClient() {
// 创建配置 - Redisson.Config
Config config = new Config();
// 连接到本地redis数据库
config.useSingleServer().setAddress("redis://127.0.0.1:6379");
// 设置主线程的锁过期时间,单位(毫秒)
// Redisson默认的超时时间是30s
// 如果主线程还在执行,那么看门狗线程就会在 1/3 * outTime 的时刻刷新锁过期时间,所以默认每10s刷新一次
config.setLockWatchdogTimeout(10000L); // 这里的过期时间是10s,每3.3s刷新一次锁过期时间
// 返回redissonClient
return Redisson.create(config);
}
}
配置好RedissonConfig后,我们来写测试代码:
import lock.config.RedisConfig;
import lock.config.RedissonConfig;
import org.junit.jupiter.api.Test;
import org.redisson.api.RLock;
import org.redisson.api.RedissonClient;
import org.springframework.boot.test.context.SpringBootTest;
import redis.clients.jedis.Jedis;
import redis.clients.jedis.JedisPool;
import javax.annotation.Resource;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
/**
* 测试redissonLock
*
* @Date: 2024/07/05/8:24
*/
@SpringBootTest(classes = {RedissonConfig.class, RedisConfig.class})
public class TestRedissonLock {
@Resource
private RedissonClient redissonClient;
@Resource
private JedisPool jedisPool;
private static final String LOCK_NAME = "lock";
// 共享资源
private int count = 0;
@Test
public void testRedissonLock() throws InterruptedException {
// 线程池线程数量
int clientCount = 3;
// .getLock() 获取一个锁,key在redis里面叫"lock"
RLock redissonLock = redissonClient.getLock(LOCK_NAME);
// 创建一个线程池
ExecutorService executorService = Executors.newFixedThreadPool(clientCount);
// 使用CountDownLatch让线程同时运行,模拟并发
CountDownLatch countDownLatch = new CountDownLatch(clientCount);
// 获取jedis
Jedis jedis = jedisPool.getResource();
// 执行每个线程的逻辑
for (int i = 0; i < clientCount; i++) {
executorService.execute(() -> {
try {
// 调用.lock()方法获取分布式锁
// 不传任何参数,启用看门狗线程,自动过期时间就是在RedissonConfig配置的10s
redissonLock.lock();
System.out.println("======================================================");
System.out.println(Thread.currentThread().getName() + "开始执行");
for (int j = 0; j < 10; j++) {
Thread.sleep(1000);
System.out.println(Thread.currentThread().getName() + ":" + j);
System.out.println("分布式锁剩余存活时间:" + jedis.ttl(LOCK_NAME));
System.out.println("==================");
}
// 共享资源++
count++;
} catch (Exception e) {
e.printStackTrace();
} finally {
System.out.println(Thread.currentThread().getName() + "执行结束");
// 判断锁是否属于当前线程,避免释放掉其他线程的锁
// 如果不手动释放锁,且当前线程一直存活,那么就会死锁
if (redissonLock != null && redissonLock.isHeldByCurrentThread()) {
System.out.println(redissonLock);
redissonLock.unlock();
}
// 关闭redis连接
if (jedis != null) jedis.close();
System.out.println("======================================================");
}
// 开始执行
countDownLatch.countDown();
});
}
countDownLatch.await();
System.out.println(count);
}
}
可以看到相比手写看门狗分布式锁,使用Redisson可以简化很多代码,只需要注意触发看门狗的条件即可;
执行结果部分截图:
结论:
1、在RedissonConfig中配置锁过期时间;
2、使用.lock()方法触发看门狗线程;
3、释放锁需要判断锁是否属于本线程,否则会抛异常;
4、每隔 1/3 * outTime 的时间后刷新过期时间;
5、如果不手动释放锁,线程执行结束后,那么看门狗也随之消亡,redis中的锁会在到期后自动释放,不会死锁;
七、Redis集群下的分布式锁
以上Redis分布式锁的知识还只是基于单机Redis实现的,那么如果在Redis集群情况下,又该如何实现呢?
如果在主从结构中,我们可以把锁加在主节点上,如果主节点挂掉,会自动选出一个从节点转变成主节点,锁对应的信息会自动同步到新的主节点,从而实现故障转移,降低锁丢失的风险。但是这样就万事大吉了吗?显然不是。
因为在哨兵集群中,主从的切换是使用一个异步线程实现数据的转移,具有一定的延迟,而且集群节点之间的通信依赖网络,如果网络不好也会造成延迟,任然具有一定的锁丢失风险。那我们想要更加完善一点该怎么办呢?
——红锁。
1、红锁 - RedLock
RedLock实现的整体流程:
客户端先获取【当前时间戳T1】
客户端依次向这5台Redis实例(非集群)发起加锁请求
如果>=3个成功(大部分)成功,且当前时间戳 T2-T1 < 锁的过期时间则加锁成功
加锁成功,操作共享资源
加锁失败/释放锁,向所有Redis节点发起释放锁的请求
RLock lock1 = redissonInstance1.getLock("lock1");
RLock lock2 = redissonInstance1.getLock("lock2");
RLock lock3 = redissonInstance1.getLock("lock3");
RLock lock4 = redissonInstance1.getLock("lock4");
RLock lock5 = redissonInstance1.getLock("lock5");
RedissonRedLock lock = new RedissonRedLock(lock1, lock2, lock3, lock4, lock5);
// 同时加5个锁
// 大部分成功就算加锁成功
lock.lock();
... 业务代码 ...
lock.unlock();
2、红锁的一些问题
为什么使用最少5台redis节点?
提高容错,5台redis同时宕机的概率小。
为什么要超过一半的节点加锁成功才算加上锁了?
在分布式系统中,可能会出现异常节点,比如没宕机但是响应慢,这时只要满足超过一半节点加锁成功就不必等待慢响应。
为什么要计算加锁时间?
因为可能出现网络延迟,比如设置的加锁时间是10s,但是接收到加锁成功的信号时已经到了第11s,在这台redis中的锁已经过期了,所以只能算作加锁失败。
RedLock的NPC问题:
N:NetWork Delay 网络延迟
P:Process Pause 进程暂停
C:Clock Drift 时钟漂移
N和P的问题可以通过计算时间来解决,但是C的时钟漂移问题没法解决,在一个分布式集群中,没办法保证所有服务器的系统时间一致,这就没法判断在某个redis节点加上的锁是否已经过期。
RedLock的性能不高:由于加锁访问服务器很多,且加锁判断步骤太长,解锁也需要向所有redis节点发送解锁请求,所以RedLock的性能不高。
3、使用redis分布式锁的建议
在大部分的业务场景下,使用看门狗+主从+哨兵基本可以满足分布式锁的功能,即使出现问题也是个小概率事件,可以容忍少量的锁丢失问题。