多线程下的数据一致性问题一直都是热点问题,既要考虑到数据的一致,又要考虑实现的效率,在分布式情况下,这又要成为一种新的难题。分布式锁和我们java基础中学习到的synchronized略有不同,synchronized中我们的锁是个对象,当前系统部署在不同的服务实例上,单纯使用synchronized或者lock 已经无法满足对库存一致性的判断。本次主要讲解基于rediss 实现的分布式锁
说到大家熟悉的rediss分布式锁 ,大部分人都会想到:setnx+过期时间
- 获取锁(client_id 可以是UUID等)
SET lock_name client_id NX PX 30000
或者 setnx+lua
if redis.call("get",KEYS[1]) == ARGV[1] then
return redis.call("del",KEYS[1])
else
return 0
end
这种实现的方式又以下几点好处:
1.set命令要用set key value px milliseconds nx;
自动释放,避免死锁
2.释放锁时要验证vaule
避免误解锁,同时也要注意value的唯一性
redisson已经有对 Reentrant lock算法封装,接下来对其用法进行简单介绍 参考官方wiki
首先导入 pom文件
org.redisson
redisson
3.3.2
简单的用法展示
RLock lock = redissonClient.getLock("REDLOCK_KEY");
boolean isLock;
try {
isLock = isLock.tryLock();
// 5s拿不到锁, 就认为获取锁失败。5s即5s是锁失效时间。
isLock = isLock.tryLock(5, 5, TimeUnit.SECONDS);
if (isLock) {
//业务逻辑
}
} catch (Exception e) {
} finally {
// 无论如何, 最后都要解锁
redLock.unlock();
}
实现分布式锁的一个非常重要的点就是set的value要具有唯一性,redisson的value是怎样保证value的唯一性呢?答案是UUID+threadId。
protected final UUID id = UUID.randomUUID();
String getLockName(long threadId) {
return id + ":" + threadId;
}
获取锁的api是 lock.tryLock()或者lock.tryLock(waitTime, leaseTime, TimeUnit),两者实现原理是一样,只不过前者获取锁的默认租约时间(leaseTime)是LOCK_EXPIRATION_INTERVAL_SECONDS,即30s:
RFuture tryLockInnerAsync(long leaseTime, TimeUnit unit, long threadId, RedisStrictCommand command) {
internalLockLeaseTime = unit.toMillis(leaseTime);
// 获取锁时向redis实例发送的命令
return commandExecutor.evalWriteAsync(getName(), LongCodec.INSTANCE, command,
// 首先分布式锁的KEY不能存在,如果确实不存在,
// 那么执行hset命令(hset REDLOCK_KEY uuid+threadId 1),并通过pexpire设置失效时间
"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已经存在,并且value也匹配
// 表示是当前线程持有的锁,那么可重入次数++,并且更新失效时间
// 执行命令 incrby lockkey uuid:threadId 1
"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; " +
// 最后就是都不满足的情况 获取分布式锁的KEY的失效时间毫秒数
"return redis.call('pttl', KEYS[1]);",
Collections.
加锁完成后的redis 数据结构大致 如下
lockkey :{
"uuid:threadId": 1
}
参数说明
KEYS[1] 代表的是你加锁的那个key,比如说:RLock lock = redisson.getLock(“myLock”);这里你自己设置了加锁的那个锁key就是“myLock”。
ARGV[1] 代表的就是锁key的续约时间,默认30秒。
ARGV[2] 代表的是加锁的set的vaule,类似于下面这样 uuid:threadId(本质其实就是uuid+线程id)
释放锁的api为 lock.unlock()
protected RFuture unlockInnerAsync(long threadId) {
// 向实例都执行如下命令
return commandExecutor.evalWriteAsync(getName(), LongCodec.INSTANCE, RedisCommands.EVAL_BOOLEAN,
// 如果分布式锁KEY不存在,那么向channel发布一条消息
"if (redis.call('exists', KEYS[1]) == 0) then " +
"redis.call('publish', KEYS[2], ARGV[1]); " +
"return 1; " +
"end;" +
// 如果分布式锁存在,但是value不匹配,表示锁已经被占用,那么直接返回
"if (redis.call('hexists', KEYS[1], ARGV[3]) == 0) then " +
"return nil;" +
"end; " +
// 如果就是当前线程占有分布式锁,那么将重入次数减1
"local counter = redis.call('hincrby', KEYS[1], ARGV[3], -1); " +
// 重入次数减1后的值如果大于0,表示分布式锁有重入过,那么只设置失效时间,还不能删除
"if (counter > 0) then " +
"redis.call('pexpire', KEYS[1], ARGV[2]); " +
"return 0; " +
"else " +
// 重入次数减1后的值如果为0,表示分布式锁只获取过1次,那么删除这个KEY,并发布解锁消息
"redis.call('del', KEYS[1]); " +
"redis.call('publish', KEYS[2], ARGV[1]); " +
"return 1; "+
"end; " +
"return nil;",
Arrays.
其实写到这里,我个人以为rediss的分布式锁 已经实现的很完美了,但还是有其他声音对这个方案提出了异议。Redis通过sentinel保证高可用,如果在某个时间进行主备切换,很有可能在预备slave 上还没有master节点的锁。具体流程如下
1.Redis的master节点上拿到了锁;
2.但是这个加锁的key还没有同步到slave节点;
3.master故障,发生故障转移,slave节点升级为master节点;
最终导致 导致锁丢失。
在这个背景下,Redis作者antirez基于分布式环境下提出了一种更高级的分布式锁的实现方式:Redlock。
我理解的算法大致如下
假设有N个Redis 节点。这些节点完全互相独立,不存在主从复制或者其他集群协调机制。确保将在N个实例上使用与在Redis单实例下相同方法获取和释放锁。
这里重点就是 完全互相独立!
演示一下简单操作
RedissonRedLock redLock = new RedissonRedLock(lock1, lock2, lock3);
// 这里的lock1 lock2 lock3 就是从各个redis节点获取的 锁
boolean isLock;
try {
isLock = redLock.tryLock(500, 30000, TimeUnit.MILLISECONDS);
System.out.println("isLock = "+isLock);
if (isLock) {
//TODO if get lock success, do something;
Thread.sleep(30000);
}
} catch (Exception e) {
} finally {
// 无论如何, 最后都要解锁
System.out.println("");
redLock.unlock();
}
最大的变化就是RedLock 的初始化RedissonRedLock redLock = new RedissonRedLock(lock1, lock2, lock3); 这里选择的是三个节点, 可以选择多个。
RedLock 其实是RedissonMultiLock的子类,tryLock其实 是RedissonMultiLock的tryLock方法,源码如下:
public boolean tryLock(long waitTime, long leaseTime, TimeUnit unit) throws InterruptedException {
// 允许的失败数量
// RedissonMultiLock 0 , RedLock 是少于半数
int failedLocksLimit = failedLocksLimit();
List acquiredLocks = new ArrayList(locks.size());
// 实现要点之遍历所有节点通过EVAL命令执行lua加锁
// locks就是初始化的传入的redis节点
for (ListIterator iterator = locks.listIterator(); iterator.hasNext();) {
RLock lock = iterator.next();
boolean lockAcquired;
try {
// 尝试对某一个节点
lockAcquired = lock.tryLock(awaitTime, newLeaseTime, TimeUnit.MILLISECONDS);
} catch (RedisConnectionClosedException|RedisResponseTimeoutException e) {
// 如果抛出这类异常,为了防止加锁成功,但是响应失败,需要解锁
unlockInner(Arrays.asList(lock));
lockAcquired = false;
} catch (Exception e) {
// 抛出异常表示获取锁失败
lockAcquired = false;
}
if (lockAcquired) {
// 成功获取锁集合
acquiredLocks.add(lock);
} else {
// 如果达到了允许加锁失败节点限制,那么break,即此次Redlock加锁失败
if (locks.size() - acquiredLocks.size() == failedLocksLimit()) {
break;
}
}
}
return true;
}
以redis 的以哨兵 模式架构为例。假设有三个独立的哨兵模式集群,如果要获取分布式锁,那么需要向这3个sentinel集群通过EVAL命令执行LUA脚本,需要过半数,即至少2个sentinel集群响应成功,才算成功的以Redlock算法获取到分布式锁。这样就算某一个集群失败了,其他两个成功也一样可以加锁成功。
详细步骤如下
1.获取当前时间戳 startTIme
2.从注册的实例上,使用相同的key和 uuid:threadId 获取锁(和重入锁的获取方式一直)。当向Redis请求获取锁时,客户端应该设置一个网络连接和响应超时时间,这个超时时间应该小于锁的失效时间。
3.锁是否成功状态 -> nowTime - startTIme =获取锁的时间 。当获得锁的时间小于有效时间,则判断得锁成功,成功个数 大于等于 N/2+1, 返回加锁成功
4.如果加锁失败。那则执行解锁操作
1.Redlock 理解误区
才开始的想法是 Redlock 向redis集群或者哨兵模式发送lua脚本, 通过hash 算法还是过固定到某一个redis槽点上。那发三次 不都是发到同一个槽点上面了吗?过后 才发现, 完全独立!!!其实 是向三个不同的redis 集群发送lua脚本,利用多个独立的redis节点确保锁的丢失问题。 算是一种典型的空间换时间的方法吧,毕竟在实际生产中,多台完全独立的redis集群,成本还是挺大的。但是在一次业务开发中,我发现RedissonMultiLock的另一个用途,比如一次购买多件商品, 在减库存的操作时,对每个商品加锁,业务实现挺复杂的,这个时候RedissonMultiLock的重要性 就体现出来了。
2.失效时间设置
如果业务执行时间超过了失效时间,那么锁就会被自动释放,目前 我找到的方案时启动一个线程,自动续约,比如每隔十秒,查询某个锁的状态, 如果是持有状态, 那么自动续约。很明显这又提升了分布式锁的复杂度。这个问题就留给大家帮我解答了