- 简单实现
- RedissonLock
- lock
- tryLock
- unlock
- 参考
简单实现
JDK 中的锁种类繁多、功能齐全,但是有效范围仅仅限于单机 JVM 内,当需要对集群中多个 JVM 中的某个并行的操作全局串行化的时候,就要用到分布式的互斥锁了。当集群中所有节点使用同一份缓存的时候,用缓存实现分布式互斥锁是一种有效的手段。
实现思路:
所有 JVM 约定一样的 key,并尝试在缓存中插入数据。若该 key 已存在,表示该 key 代表的锁已被持有,获取失败,如果该 key 不存在,则插入该 key 的 JVM 获取到锁,进行后续操作。
在此情境下,若持有锁的节点挂掉,则锁永远不会被释放,所以在获取锁的时候,需要自行设置锁的过期时间,以免发生此类死锁的情况。
import redis.clients.jedis.Jedis;
import redis.clients.jedis.JedisPool;
import redis.clients.jedis.params.SetParams;
@Service
public class RedisLockService {
private static final String LOCK_SUCCESS = "OK";
@Autowired
JedisPool jedisPool;
/**
* 尝试加分布式锁
* @param key 锁的 key
* @param requestId 标识加锁客户端的 value
* @return 是否加锁成功
*/
public boolean lock(String key, String requestId, int expireSeconds) {
Jedis jedis = null;
try {
jedis = jedisPool.getResource();
SetParams params = new SetParams();
// 10 秒后过期
params.ex(expireSeconds);
params.nx();
String result = jedis.set(key, requestId, params);
return LOCK_SUCCESS.equals(result);
} finally {
returnToPool(jedis);
}
}
/**
* 尝试释放分布式锁
* @param key 锁的 key
* @param requestId 标识加锁客户端的 value
* @return 是否释放成功
*/
public boolean unlock(String key, String requestId) {
Jedis jedis = null;
try {
jedis = jedisPool.getResource();
// 此段为 Lua 脚本,用来保证原子性:
// 首先获取锁对应的 value 值,检查是否与 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(key), Collections.singletonList(requestId));
return LOCK_SUCCESS.equals(result);
} finally {
returnToPool(jedis);
}
}
}
多线程甚至多个 JVM 竞争同一把锁的时候,只有一个线程能获取到锁,其他线程必须等待或放弃。可以模仿 AQS 中的方式,处理其他线程,也可以采用下面三种简单的方案:
- 直接抛出异常,通知用户,放弃任务
- while 循环,sleep 一段时间,重试获取锁
- 将请求转移到消息队列,过一会再尝试
在笔者的 “限流工具与秒杀系统实现 ” 中,由于单个线程持有锁的时间很短,故采用第二种方案,且不设置 sleep 的时间,仅仅只是自旋。伪代码如下所示:
while (需要执行流程 A) {
if (获取锁 lock 成功) {
try {
if (需要执行流程 A) {
---
执行流程 A
---
} finally {
---
释放锁 unlock
---
}
}
}
}
RedissonLock
Redisson 封装了分布式锁的接口和实现类,下文以可重入锁 RLock 为例。
RLock 主要有以下几个方法:
//加锁操作,锁的有效期默认30秒,到时间自动释放
void lock();
// 可以设置锁有效时间的加锁操作
// leaseTime 表示有效时间,unit 表示时间单位
void lock(long leaseTime, TimeUnit unit);
// 有返回值的加锁操作,成功返回 true,失败(或者锁已被获取)返回 false。
boolean tryLock();
// 指定等待时间和锁有效时间的加锁操作
// waitTime 表示最长等待时间,超过时间就不再等待锁了
boolean tryLock(long waitTime, long leaseTime, TimeUnit unit) throws InterruptedException;
// 指定等待时间的加锁操作
boolean tryLock(long waitTime, TimeUnit unit) throws InterruptedException;
// 释放锁
void unlock();
lock
RedissonLock 实现了 RLock 接口,控制锁的写入和释放的主要有以下全局变量:
// 锁默认释放的时间:30 * 1000,即30秒
protected long internalLockLeaseTime;
// 客户端的唯一标识,只有持有锁的客户端才能释放锁
final String id;
// 订阅者模式,当释放锁的时候,其他客户端能够知道锁已经被释放的消息,并让队列中的第一个消费者获取锁。使用 PUB/SUB 消息机制的目的:减少申请锁时的等待时间、安全、 锁带有超时时间、锁的标识唯一、防止死锁。
protected final LockPubSub pubSub;
lock 方法主要有两个参数,lease 表示最晚释放锁的时间(防止死锁),interruptible 表示等待的线程是否响应中断。
// 没有返回值。
// leaseTime 表示最晚释放锁的时间,unit 是时间的格式,interruptibly 表示是否响应中断。
private void lock(long leaseTime, TimeUnit unit, boolean interruptibly) throws InterruptedException {
// 线程 ID
long threadId = Thread.currentThread().getId();
// 尝试加锁
Long ttl = this.tryAcquire(leaseTime, unit, threadId);
// 如果 ttl 不等于 null ,说明锁已经被获取了
if (ttl != null) {
// 订阅锁
RFuture<RedissonLockEntry> future = this.subscribe(threadId);
// 相应中断或不响应中断
if (interruptibly) {
this.commandExecutor.syncSubscriptionInterrupted(future);
} else {
this.commandExecutor.syncSubscription(future);
}
try {
// 进入自旋
while(true) {
// 每次自旋都尝试获取锁
ttl = this.tryAcquire(leaseTime, unit, threadId);
// 获取成功
if (ttl == null) {
return;
}
// 等待锁被释放
if (ttl >= 0L) {
try {
((RedissonLockEntry)future.getNow()).getLatch().tryAcquire(ttl, TimeUnit.MILLISECONDS);
} catch (InterruptedException var13) {
if (interruptibly) {
throw var13;
}
((RedissonLockEntry)future.getNow()).getLatch().tryAcquire(ttl, TimeUnit.MILLISECONDS);
}
} else if (interruptibly) {
((RedissonLockEntry)future.getNow()).getLatch().acquire();
} else {
((RedissonLockEntry)future.getNow()).getLatch().acquireUninterruptibly();
}
}
} finally {
// 取消订阅
this.unsubscribe(future, threadId);
}
}
}
tryAcquire 方法里使用到了 lua 脚本访问 Redis,保证操作的原子性,如下所示:
-- 检查 key 是否被占用,如果没有则设置超时时间和未必表示,初始化 value 等于 1
if (redis.call('exists', KEYS[1]) == 0) then redis.call('hset', KEYS[1], ARGV[2], 1); redis.call('pexpire', KEYS[1], ARGV[1]); return nil; end;
-- 如果锁重入,且锁的 key field 都一直的情况下 value 加一
-- 而且要重新设置超时时间
if (redis.call('hexists', KEYS[1], ARGV[2]) == 1) then redis.call('hincrby', KEYS[1], ARGV[2], 1); redis.call('pexpire', KEYS[1], ARGV[1]); return nil; end;
-- 返回剩余的时间
return redis.call('pttl', KEYS[1]);
可以看出大致思路和上一节中简单的实现差不多。使用了发布订阅功能使获取锁更及时,也节约了计算资源;使用 redis 中哈希结构的 value 的值表示重入的次数,使其具有可重入锁的功能。
tryLock
tryLock 也是尝试获取锁的函数,但需要设置等待时间,如果等待时间已经耗尽但是还没有获取到锁,则返回 false,表示失败,反之,返回 true 表示锁获取成功。
public boolean tryLock(long waitTime, long leaseTime, TimeUnit unit) throws InterruptedException {
long time = unit.toMillis(waitTime);
long current = System.currentTimeMillis();
long threadId = Thread.currentThread().getId();
// 尝试获取锁
Long ttl = this.tryAcquire(leaseTime, unit, threadId);
// 获取成功,直接返回 true
if (ttl == null) {
return true;
} else {
// 经过上面的流程之后,计算还剩多长的等待时间
time -= System.currentTimeMillis() - current;
// 没有等待时间了,返回 false
if (time <= 0L) {
this.acquireFailed(threadId);
return false;
} else {
// 订阅监听 Redis 消息,
current = System.currentTimeMillis();
RFuture<RedissonLockEntry> subscribeFuture = this.subscribe(threadId);
// 阻塞等待 subscribe 的 future 的结果对象。
// 如果 await 返回 false,说明 subscribe 方法调用超过了 time,说明已经超过客户端设置的最大等待时间,返回 false, 取消订阅,不再继续申请锁。
if (!subscribeFuture.await(time, TimeUnit.MILLISECONDS)) {
if (!subscribeFuture.cancel(false)) {
subscribeFuture.onComplete((res, e) -> {
if (e == null) {
this.unsubscribe(subscribeFuture, threadId);
}
});
}
this.acquireFailed(threadId);
return false;
} else {
try {
// 再次检查是否超过时间
time -= System.currentTimeMillis() - current;
if (time <= 0L) {
this.acquireFailed(threadId);
boolean var20 = false;
return var20;
} else {
// 没有超过等待时间,通过 while 一直获取锁,直到成功或失败
boolean var16;
do {
long currentTime = System.currentTimeMillis();
ttl = this.tryAcquire(leaseTime, unit, threadId);
// 成功
if (ttl == null) {
var16 = true;
return var16;
}
time -= System.currentTimeMillis() - currentTime;
// 等待时间耗尽,获取失败
if (time <= 0L) {
this.acquireFailed(threadId);
var16 = false;
return var16;
}
currentTime = System.currentTimeMillis();
if (ttl >= 0L && ttl < time) {
((RedissonLockEntry)subscribeFuture.getNow()).getLatch().tryAcquire(ttl, TimeUnit.MILLISECONDS);
} else {
((RedissonLockEntry)subscribeFuture.getNow()).getLatch().tryAcquire(time, TimeUnit.MILLISECONDS);
}
time -= System.currentTimeMillis() - currentTime;
} while(time > 0L);
this.acquireFailed(threadId);
var16 = false;
return var16;
}
} finally {
this.unsubscribe(subscribeFuture, threadId);
}
}
}
}
}
unlock
unlock 的主要逻辑是下面这一段 lua 脚本:
-- 如果锁存在但值不匹配,说明锁不是自己的,无权释放,返回空
if (redis.call('hexists', KEYS[1], ARGV[3]) == 0) then return nil;end;
-- 如果锁存在,且值匹配,将重入次数 -1,如果重入次数大于 0,更新锁的过期时间,不能释放,返回 0
local counter = redis.call('hincrby', KEYS[1], ARGV[3], -1);
if (counter > 0) then redis.call('pexpire', KEYS[1], ARGV[2]); return 0;
-- 删除 key 并释放锁,然后发布锁已释放的消息
else redis.call('del', KEYS[1]); redis.call('publish', KEYS[2], ARGV[1]); return 1; end; return nil;
当且仅当释放锁客户端和加锁客户端匹配且重入次数降为 0 时才能释放锁。