当资源在分布式环境下需要共享时,比如秒杀等场景,减库存等。redis的客户端redission提供了一种基于LUA脚本的分布式锁实现方案来解决分布式环境下资源共享问题。
1.redission使用
public class RedissonUtil {
public RedissonClient getConnetion(){
Config config = new Config();
// 单台服务器
config.useSingleServer().setAddress("172.0.0.1:6379");
return Redisson.create(config);
}
/**
* 获取锁
* @param key
* @return
*/
public boolean lock(String key){
RLock lock = getConnetion().getLock("payOrder");
//
try {
//最多等待100s,上锁10s后自动释放锁
if (lock.tryLock(100, 10, TimeUnit.SECONDS)) {
//获取锁成功
System.out.println("获取锁成功了!");
}
} catch (InterruptedException e) {
e.printStackTrace();
}
return false;
}
2. 原理分析
来看看redission是如何实现的:
tryLock(long waitTime, long leaseTime, TimeUnit unit)源码如下:
@Override
public boolean tryLock(long waitTime, long leaseTime, TimeUnit unit) throws InterruptedException {
long time = unit.toMillis(waitTime);
long current = System.currentTimeMillis();
final long threadId = Thread.currentThread().getId();
//申请锁,返回剩余过期时间
Long ttl = tryAcquire(leaseTime, unit);
// 无竞争...
if (ttl == null) {
return true;
}
//有竞争...
}
2.1 先申请锁,然后返回锁剩余时间
- 将异步执行的结果以同步的形式返回
Redisson 实现的执行 Redis 命令都是异步的,但是它在异步的基础上提供了以同步的方式获得执行结果的封装。
private Long tryAcquire(long leaseTime, TimeUnit unit) {
return get(tryAcquireAsync(leaseTime, unit, Thread.currentThread().getId()));
}
- 异步获取锁
分布式锁要确保未来的一段时间内锁一定能够被释放,因此要对锁设置超时释放的时间,在我们没有指定该时间的情况下,Redisson 默认指定为30秒
private RFuture tryAcquireAsync(long leaseTime, TimeUnit unit, final long threadId) {
//1.如果用户设置的有最大等待时间,则直接执行LUA脚本获取锁
if (leaseTime != -1) {
return tryLockInnerAsync(leaseTime, unit, threadId, RedisCommands.EVAL_LONG);
}
// 2.如果用户没有设置最大等待时间,用默认的锁超时时间去获取锁
RFuture ttlRemainingFuture = tryLockInnerAsync(LOCK_EXPIRATION_INTERVAL_SECONDS, TimeUnit.SECONDS, threadId, RedisCommands.EVAL_LONG);
ttlRemainingFuture.addListener(new FutureListener() {
@Override
public void operationComplete(Future future) throws Exception {
if (!future.isSuccess()) {
return;
}
Long ttlRemaining = future.getNow();
// lock acquired
if (ttlRemaining == null) {
// 3.锁过期时间刷新任务调度
scheduleExpirationRenewal(threadId);
}
}
});
return ttlRemainingFuture;
}
- 使用 EVAL 命令执行 Lua 脚本获取锁
RFuture tryLockInnerAsync(long leaseTime, TimeUnit unit, long threadId, RedisStrictCommand command) {
internalLockLeaseTime = unit.toMillis(leaseTime);
return commandExecutor.evalWriteAsync(getName(), LongCodec.INSTANCE, command,
"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; " +
"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]);",
Collections.
lua脚本中有三个参数:
KEYS[1]:就是传入的加锁的key,比如:RLock lock = redisson.getLock("myLock");
ARGV[2]:代表的是加锁的客户端的ID,类似于下面这样:8743c9c0-0795-4907-87fd-6c719a6b4586:1
ARGV[1]:代表的就是锁key的默认生存时间,默认30秒
执行分析:
- 先判断key是否存在。执行命令:exists key。如果不存在,继续向下执行:
- 如果不存在,执行HSET key field value命令,比如:
hset myLock 8743c9c0-0795-4907-87fd-6c719a6b4586:1 1
结果如下:
myLock:{
"8743c9c0-0795-4907-87fd-6c719a6b4586:1":1
}
这样设置之后,就表示客户端8743c9c0-0795-4907-87fd-6c719a6b4586:1对myLock这个锁完成了加锁操作。
- 执行过期时间设置,比如:
PEXPIRE key milliseconds
PEXPIRE myLock 100000
- 此时另一个客户端来对key进行加锁操作,又执行了一段Lua脚本,会发生什么问题呢?
第一个if判断会执行“exists myLock”,发现myLock这个锁key已经存在了。 - 接着第二个if判断,判断一下,myLock锁key的hash数据结构中,是否包含客户端2的ID,但是明显不是的,因为那里包含的是客户端1的ID。
执行命令判断ash里面field是否存在:
HEXISTS key field
如果不存在,则返回key的剩余生存时间。如果存在,则继续一下处理:
- 如果存在,redission增加了锁的可重入性,通过一下命令:
增加 key 指定的哈希集中指定字段的数值
HINCRBY key field increment
incrby myLock 8743c9c0-0795-4907-87fd-6c71a6b4586:1 1
结果如下:
myLock:{
"8743c9c0-0795-4907-87fd-6c719a6b4586:1":2
}
- 执行过期时间设置,比如:
PEXPIRE key milliseconds
PEXPIRE myLock 100000
执行lua脚本流程如下:
2.2 无竞争
无竞争是直接获取到锁的。
if (ttl == null) {
return true;
}
2.3 有竞争
通过tryAcquire发现锁被其它线程申请时,需要进入等待竞争逻辑中。
time -= (System.currentTimeMillis() - current);
if (time <= 0) {
return false;
}
current = System.currentTimeMillis();
final RFuture subscribeFuture = subscribe(threadId);
if (!await(subscribeFuture, time, TimeUnit.MILLISECONDS)) {
if (!subscribeFuture.cancel(false)) {
subscribeFuture.addListener(new FutureListener() {
@Override
public void operationComplete(Future future) throws Exception {
if (subscribeFuture.isSuccess()) {
unsubscribe(subscribeFuture, threadId);
}
}
});
}
return false;
}
try {
time -= (System.currentTimeMillis() - current);
if (time <= 0) {
return false;
}
while (true) {
long currentTime = System.currentTimeMillis();
ttl = tryAcquire(leaseTime, unit);
// lock acquired
if (ttl == null) {
return true;
}
time -= (System.currentTimeMillis() - currentTime);
if (time <= 0) {
return false;
}
// waiting for message
currentTime = System.currentTimeMillis();
if (ttl >= 0 && ttl < time) {
getEntry(threadId).getLatch().tryAcquire(ttl, TimeUnit.MILLISECONDS);
} else {
getEntry(threadId).getLatch().tryAcquire(time, TimeUnit.MILLISECONDS);
}
time -= (System.currentTimeMillis() - currentTime);
if (time <= 0) {
return false;
}
}
} finally {
unsubscribe(subscribeFuture, threadId);
}
流程如下:
4. 分布式锁释放
如果执行lock.unlock(),就可以释放分布式锁,此时的业务逻辑也是非常简单的。
其实说白了,就是每次都对myLock数据结构中的那个加锁次数减1。
如果发现加锁次数是0了,说明这个客户端已经不再持有锁了,此时就会用:
“del myLock”命令,从redis里删除这个key。
然后呢,另外的客户端2就可以尝试完成加锁了。
这就是所谓的 分布式锁的开源Redisson框架的实现机制。
一般我们在生产系统中,可以用Redisson框架提供的这个类库来基于redis进行分布式锁的加锁与释放锁。
5. Redis分布式锁的缺点
其实上面那种方案最大的问题,就是如果你对某个redis master实例,写入了myLock这种锁key的value,此时会异步复制给对应的master slave实例。
但是这个过程中一旦发生redis master宕机,主备切换,redis slave变为了redis master。
接着就会导致,客户端2来尝试加锁的时候,在新的redis master上完成了加锁,而客户端1也以为自己成功加了锁。
此时就会导致多个客户端对一个分布式锁完成了加锁。
这时系统在业务语义上一定会出现问题, 导致各种脏数据的产生 。
所以这个就是redis cluster,或者是redis master-slave架构的 主从异步复制 导致的redis分布式锁的最大缺陷:在redis master实例宕机的时候,可能导致多个客户端同时完成加锁。这也是我们项目没有使用redis作为分布式锁的方案的主要原因。