分布式锁实现方案及原理说明
分布式锁
1. 分布式锁
1.1 为什么要用分布式锁
![](https://img-blog.csdnimg.cn/img_convert/dd13c500fb9a05ad8e917e2a80d17c2b.png#clientId=ue704be3c-a092-4&errorMessage=unknown error&from=paste&id=u6dfd0c8f&originHeight=455&originWidth=495&originalType=url&ratio=1&rotation=0&showTitle=false&status=error&style=none&taskId=u4d173bb4-b258-4b8c-8e98-5c134a02a2c&title=)
从上图可以看到,变量A存在JVM1、JVM2、JVM3三台服务器上。如果不加任何控制的话,变量A同时会被在每个JVM中分配一块内存,三个请求过来同时对这个变量操作,结果显然是不对的。
三个请求分别操作三个不同JVM内存区域中的数据,变量A之间不存在共享,也不具有可见性,处理的结果也是不对的。
为了保证一个方法或者属性在高并发的情况下,同一时间只能被同一个线程执行,在传统单体应用及其部署的情况下可以使用Java并发处理相关的API(如ReentranLock或Synchronized)进行控制。但是在分布式集群中,由于分布式多线程且分布在不同的机器上,单纯的java API并不能提供分布式锁的能力。所以需要用分布式锁来解决跨JVM的互斥机制来控制共享资源的访问。
1.2 分布式锁应该具备哪些条件
- 在分布式系统环境下,一个方法在同一时间只能内一个机器的一个线程执行
- 高可用的获取锁和释放锁
- 高性能的获取锁和释放锁
- 具备可重入特性
- 具备锁失效机制,防止死锁
- 具备非阻塞锁特性,即没有获取到锁将直接返回获取锁失败
1.3 三种实现分布式锁的方式
- 基于数据库实现分布式锁
- 基于Redis实现分布式锁
- 基于Zookeeper实现分布式锁
1.4 三种方式对比
分类 | 方案 | 实现原理 | 优点 | 缺点 |
---|---|---|---|---|
基于数据库 | 基于MySQL表唯一索引 | 1)表增加唯一索引 | ||
2)加锁:执行insert语句,若报错则表明加锁失败 | ||||
3)解锁:执行delete语句 | 完全利用DB现有能力,实现简单 | 1)锁无超时自动失效机制,有死锁风险 | ||
2)不支持锁重入,不支持阻塞等待 | ||||
3)操作数据库开销大,性能不高 | ||||
基于分布式协调系统 | 基于Zookeeper | 1)加锁:在/lock目录下创建临时有序节点,判断创建的节点序号是否最小。若是则表示获取到锁;否则watch /lock目录下序号比自身小的前一个节点 | ||
2)解锁:删除节点 | 1)有zk保障系统高可用 | |||
2)Curator框架已原生支持系列分布式锁命令,使用简单 | 需要单独维护一套zk集群,维保成本高 | |||
基于缓存 | 基于redis命令 | 1)加锁:执行setnx命令,若成功再执行expire添加过期时间 | ||
2)解锁:执行delete命令 | 实现简单,相比数据库和Zookeeper的实现,该方案最轻,性能最好 | 1)setnx和expire分两步执行,非原子操作,可能会出现死锁 | ||
2)delete命令存在误删,除非当前线程保持有锁的可能 | ||||
3)不支持阻塞等待,不可重入 | ||||
基于redis Luau脚本 | 1)加锁:实行 SET lock_nam random_value EX seconds NX命令 | |||
2)解锁:执行Luau脚本,释放锁时验证random_value | ||||
– ARGV[1]为random_value, KEYS[1]为lock_name | ||||
if redis.call(“get”, KEYS[1]) == ARGV[1] then | ||||
return redis.call(“del”,KEYS[1]) | ||||
else | ||||
return 0 | ||||
end | 同上;实现逻辑上也更严谨,除了单点问题,生产环境采用这种方案,问题也不大 | 不支持锁重入,不支持阻塞等待 | ||
1.5 基于数据库实现分布式锁
乐观锁:
DROP TABLE IF EXISTS `method_lock`;
CREATE TABLE `method_lock` (
`id` int(11) unsigned NOT NULL AUTO_INCREMENT COMMENT '主键',
`method_name` varchar(64) NOT NULL COMMENT '锁定的方法名',
`state` tinyint NOT NULL COMMENT '1:未分配;2:已分配',
`update_time` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
`version` int NOT NULL COMMENT '版本号',
`PRIMARY KEY (`id`),
UNIQUE KEY `uidx_method_name` (`method_name`) USING BTREE
) ENGINE=InnoDB AUTO_INCREMENT=3 DEFAULT CHARSET=utf8 COMMENT='锁定中的方法';
1)先获取锁的信息
select id, method_name, state,version from method_lock where state=1 and method_name='methodName';
2)占有锁
update t_resoure set state=2, version=2, update_time=now() where method_name='methodName' and state=1 and version=2;
如果没有更新影响到一行数据,则说明这个资源已经被别人占位了。
缺点:
- 强依赖数据库的可用性,数据库是一个单点,一旦数据挂掉,会导致业务系统不可用
- 没有失效时间,一旦解锁失败,就会导致锁记录一直在数据库中,其他线程无法再获取到锁
- 锁是非阻塞的,因为是数据库的操作,一旦插入失败就会直接报错,没有获得锁的线程不会进入队列排队,想要再次获取锁就要再次出发数据库操作
- 锁是非重入的,同一个线程在没有释放锁之前无法再次获得该锁。
1.6 基于Redis实现分布式锁
1.6.1 setnx命令
线程1申请加锁,这时没有人持有锁,加锁成功:
127.0.0.1:6379> setnx lock 1
(integer) 1
线程2申请加锁,此时发现有人持有锁未释放,加锁失败:
127.0.0.1:6379> setnx lock 1
(integer) 0
线程1执行完成业务逻辑后,执行DEL命令释放锁:
127.0.0.1:6379> del lock
(integer) 1
存在的问题:
1)假设线程1执行到一半,系统挂了,这时锁还没有释放,就会造成是说
2)如果Redis加锁后,Master还没有同步给Slave就挂了,会导致有两个客户端获取到锁
解决方案:setnx expire
1.6.2 setnx expire
为了解决上述死锁问题,我们在setnx后,给这个key加上失效时间。
127.0.0.1:6379> setnx lock 1 ## 加锁
(integer) 1
127.0.0.1:6379> expire lock 3 ## 设置 key 3秒失效
(integer) 1
存在问题:
- 假设setnx lock 1执行成功了,但是expire lock 3执行失败了,还是会存在死锁问题,这两个命令需要保证原子性。
- 失效时间是我们写死的,不能自动续约,如果业务执行时间超过失效时间,会出现线程1还在执行,线程2就加锁成功了,并没有达到互斥的效果。
- 如果Redis加锁后,Master还没有同步给Slave就挂了,会导致有两个客户端获取到锁。
解决方案:RedissonLock
1.6.3 Redisson
实现原理:
- RedissonLock底层使用的是lua脚本执行redis指令,lua脚本可以保证加锁和失效执行的原子性
- RedissonLock底层有个看门狗机制,加锁成功后,会开启一个定时调度任务,每个10秒去检查锁是否释放,如果没有释放就把失效时间刷新成30s,这样锁就可以一直续期,不会释放
存在问题:如果redis是单节点,存在单节点故障问题;如果做主从架构,redis加锁后,master还没同步给slave就挂了,会导致有两个客户端获取到锁。
解决方案:RedLock,虽然RedLock可以解决上述的问题,但是生产环境中我们很少用,因为他部署成本很高,相比RedissonLock性能也略有锁下降。如果业务对数据要求绝对正确,我们会采用Zookeeper来做分布式锁。
源码解读:
private void lock(long leaseTime, TimeUnit unit, boolean interruptibly) throws InterruptedException {
// 当前线程ID
long threadId = Thread.currentThread().getId();
// 尝试获取锁
Long ttl = tryAcquire(leaseTime, unit, threadId);
// 如果ttl为空,则证明获取锁成功
if (ttl == null) {
return;
}
RFuture<RedissonLockEntry> future = subscribe(threadId);
if (interruptibly) {
commandExecutor.syncSubscriptionInterrupted(future);
} else {
commandExecutor.syncSubscription(future);
}
try {
while (true) {
// 再次尝试获取锁
ttl = tryAcquire(leaseTime, unit, threadId);
// 如果ttl为空,则证明获取锁成功
if (ttl == null) {
break;
}
// 如果ttl为空,则证明获取锁成功
if (ttl >= 0) {
try {
future.getNow().getLatch().tryAcquire(ttl, TimeUnit.MILLISECONDS);
} catch (InterruptedException e) {
if (interruptibly) {
throw e;
}
future.getNow().getLatch().tryAcquire(ttl, TimeUnit.MILLISECONDS);
}
} else {
if (interruptibly) {
future.getNow().getLatch().acquire();
} else {
future.getNow().getLatch().acquireUninterruptibly();
}
}
}
} finally {
unsubscribe(future, threadId);
}
// get(lockAsync(leaseTime, unit));
}
private <T> RFuture<Long> tryAcquireAsync(long leaseTime, TimeUnit unit, long threadId) {
// 如果带有过期时间,则按照普通方式获取锁
if (leaseTime != -1) {
return tryLockInnerAsync(leaseTime, unit, threadId, RedisCommands.EVAL_LONG);
}
// RFuture就是tryLockInnerAsync() 方法的 return redis.call('pttl', KEYS[1]);
RFuture<Long> ttlRemainingFuture = tryLockInnerAsync(commandExecutor.getConnectionManager().getCfg().getLockWatchdogTimeout(), TimeUnit.MILLISECONDS, threadId, RedisCommands.EVAL_LONG);
ttlRemainingFuture.onComplete((ttlRemaining, e) -> {
// 如果lua脚本执行失败的话,那这里就不是success,这里的监听器什么都不干
if (e != null) {
return;
}
// ttlRemaining为null,说明获取锁成功
if (ttlRemaining == null) {
// 后台开启一个定时调度的任务
// 每个10秒去检查锁是否释放,如果没有释放,把失效时间刷新成30s
// 这样锁就可以一直续期,不会释放
scheduleExpirationRenewal(threadId);
}
});
return ttlRemainingFuture;
}
<T> RFuture<T> tryLockInnerAsync(long leaseTime, TimeUnit unit, long threadId, RedisStrictCommand<T> 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.<Object>singletonList(getName()), internalLockLeaseTime, getLockName(threadId));
}
protected RFuture<Boolean> unlockInnerAsync(long threadId) {
return commandExecutor.evalWriteAsync(getName(), LongCodec.INSTANCE, RedisCommands.EVAL_BOOLEAN,
"if (redis.call('hexists', KEYS[1], ARGV[3]) == 0) then " +
"return nil;" +
"end; " +
"local counter = redis.call('hincrby', KEYS[1], ARGV[3], -1); " +
"if (counter > 0) then " +
"redis.call('pexpire', KEYS[1], ARGV[2]); " +
"return 0; " +
"else " +
"redis.call('del', KEYS[1]); " +
"redis.call('publish', KEYS[2], ARGV[1]); " +
"return 1; "+
"end; " +
"return nil;",
Arrays.<Object>asList(getName(), getChannelName()), LockPubSub.UNLOCK_MESSAGE, internalLockLeaseTime, getLockName(threadId));
}
1.6.4 RedLock
我们假设有5个完全相互独立的Redis Master单机节点,所以我们需要在5台机器上面运行这些实例,如下图所示(请注意这张图中5个Master节点完全相互独立)
![](https://img-blog.csdnimg.cn/img_convert/3315097dc8d6396707295e743c7a9b07.png#clientId=ue704be3c-a092-4&errorMessage=unknown error&from=paste&id=u6d929dff&originHeight=317&originWidth=660&originalType=url&ratio=1&rotation=0&showTitle=false&status=error&style=none&taskId=u222fc40e-5767-4c0c-9025-018a0eec4c2&title=)
1)获取当前时间,以毫秒为单位。
2)依次尝试从N个master实例使用相同的key和随机值获取锁(假设这个key是LOCK_KEY)。当向Redis设置锁时,客户端应该设置一个网络连接和响应超时时间,这个超时时间应该小于锁的失效时间(例如锁的自动失效时间为10秒,则超时时间应该在5-50毫秒之间)。这样可以避免服务器端Redis已经挂掉的情况下,客户端还在死死地等待响应结果。如果服务器端没有在规定时间内响应,客户端应该尽快尝试另外一个Redis实例。
3)客户端使用当前时间减去开始获取锁时间就能得到获取锁的使用时间,从大多数的Redis节点都取到锁,并且使用的时间小于失效时间时,锁才算获取成功。
4)如果获取到了锁,key的真正有效时间等于有效时间减去获取锁所使用的时间
5)如果因为某些原因,获取锁失败,客户端应该在所有的redis实例上进行解锁。
缺点:并发量比较大,生产环境必须要做分片才能扛住并发,想上述方案,需要准备5个Redis集群,这种机器成本是非常高的。
1.7 基于Zookeeper实现分布式锁
![](https://img-blog.csdnimg.cn/img_convert/603570670fbd45d9444be15d2071f7e5.png#clientId=ue8534570-20f5-4&errorMessage=unknown error&from=paste&id=ud0c6940f&originHeight=774&originWidth=854&originalType=url&ratio=1&rotation=0&showTitle=false&status=error&style=none&taskId=u5c38b65c-394e-4577-ab3a-fdab1312b77&title=)
一把锁,被多个人竞争,此时多个人会排队,第一个拿到锁的人会执行,然后释放锁,后面的每个人都会去监听排在自己前面的那个人创建的node上,一旦某个人释放了锁,排在自己后面的人就会被zookeeper给通知,一旦通知了之后,自己就能获取到锁了。