此文同步发表在知乎账号:聊一聊分布式锁
分布式锁想必已经很常见了,我主要从整体上讲一讲以及一些具体的思路细节和常见的问题总结
特点
- 互斥:锁必须是互斥的,即不能两个线程同时拿到锁。redis中我们可以lua脚本保证原子性,因为redis本身就是单线程的
- 防止死锁:即不能出现某个锁迟迟不能释放
- 可重入:即一个线程对资源加锁后可以对同一个资源再次加锁
实现方式
数据库
- 原理
- 乐观锁:加version
- 悲观锁:select for update
- 缺点:发生竞争时会涉及到事务回滚,性能较低、浪费资源
- 优点:无需引入额外的分布式锁组件,维护成本低
zookeeper
满足CAP中的CP
- 原理
- 客户端 1 和 2 都尝试创建「临时节点」,例如 /lock
- 假设客户端 1 先到达,则加锁成功,客户端 2 加锁失败
- 客户端 1 操作共享资源
- 客户端 1 删除 /lock 节点,释放锁
- 优点
- 不需要考虑锁的过期时间,客户端连接中断则锁被释放
- watch 机制,加锁失败,可以 watch 等待锁释放,实现乐观锁
- 缺点
- 性能不如redis
- 部署和运维成本高
- 客户端与 Zookeeper 的长时间失联,锁被释放问题
Redis
Redis本身也有悲观锁和乐观锁的实现,悲观锁获取不到锁则阻塞,乐观锁获取不到则直接返回,一般我们实际更多的使用乐观锁。Redis实现分布式锁应该是目前使用最广泛的,下面也结合分布式锁的特点,讲一讲redis如何实现分布式锁(实质上是Redission的实现方式)
Redission
加锁流程
keys: ["lockkey"];
args:["leaseTime","randUUid+threadId"] //threadId前面需要加上randUUid:避免不同机器threadId重复
if(redis.call('exists',keys[1]) ==0) then //如果key不存在,即没有加锁
redis.call('hset',keys[1],args[2],1); //记录当前线程加锁次数为1,实现锁的可重入
redis.call('pexpire',keys,args[1]); //记录锁过期时间
return
if(redis.call('hexists',keys[1],args[2]) == 1) then //如果当前key存在,且加锁线程是当前线程
redis.call('hincryby',keys[1],args[2],1); //加锁次数+1
redis.call('pexpire',keys[1],args[1]); //锁续期
return redis.call('pttl',keys[1]); //返回锁剩余时间
解锁流程
keys:["lockkey","channelName"]
args:["unlockMessage","leaseTime","randUUid+threadId"]
if(redis.call('exists',keys[1]) == 0) then //如果key不存在,说明早已经解锁了
redis.call('publish',keys[2],args[1]) //通知等待获取锁的线程,锁被释放了,可以尝试获取锁
return
if(redis.call('hexists',keys[1],args[3]) == 0) then //说明解锁的线程和加锁的线程不一致,无法解锁
return nil;
local counter = redis.call('hincrby',keys[1],args[3],-1) //解锁,count--,考虑重入情况
if(counter > 0) then //说明锁被重入了,还需要被加锁的线程持有
redis.call('pexpire',keys[1],args[2]) //锁续期
return 0;
else
redis.call('del',keys[1]); //解锁,删除key
reids.call('publish',keys[2],args[1]); //通知等待获取锁的线程,锁被释放了,可以尝试获取锁
return 1;
end
return nil;
看门狗watch dog
- 目的:解决我们加锁时,不好设置锁过期时间从而导致锁失效问题
- 原理:watch dog只有在业务方加锁时没有显示设置锁过期时间才会生效,要使watchLog机制生效,我们在加锁时不要设置锁过期时间。当我们使用Redission加锁没有设置过期时间时,Redission会初始默认设置过期时间为30然后watch dog 在当前节点存活时每10s给分布式锁的key续期30s,机制启动且代码中没有释放锁操作时,watch dog 会不断的给锁续期;如果程序释放锁操作时因为异常没有被执行,那么锁无法被释放,所以释放锁操作一定要放到 finally 中
- watchlog的延时时间 可以由 lockWatchdogTimeout指定默认延时时间,但是不要设置太小
- watchdog 会每 lockWatchdogTimeout/3时间,来实现异步延时,最终还是通过lua脚本来进行延时
常见问题
事务中使用分布式锁会有问题吗,怎么解决?
我们一般都会使用spring的声明式注解,但是注意一定不要在事务内部释放锁,可以有两种方案
- 一是在外层加锁和解锁,锁的逻辑包裹事务,建议使用这种方式
- 二是如果在事务内加锁了,那么可以通过TransactionSynchronizationManager注册事务监听器,事务提交后再释放锁
redis分布式锁会存在锁失效问题吗,怎么解决?
redis锁失效主要会存在故障转移时,比如在master加锁了,master挂了,数据还没有同步到slave,slave升级成master,之前的加锁信息丢失了,导致可以重新加锁,可以采用Redlock实现
- RedLock
- 多节点redis的分布式锁,只要大部分redis节点存活(半数以上),就可以正常提供服务
前提:不再需要部署从库和哨兵实例,只部署主库;但主库要部署多个,官方推荐至少 5 个实例,注意不是集群 - Redission对RedLock进行了实现
- 多节点redis的分布式锁,只要大部分redis节点存活(半数以上),就可以正常提供服务
如果不采用RedLock,那么我们的系统需要做好兜底逻辑,比如利用数据库的唯一键或者乐观锁,当出现了锁失效,两个请求同时拿到了锁做了数据更新操作,利用数据库的机制保障数据的准确性
实际如何使用分布式锁的?分布式锁的过期时间是怎么设置的?
有两种方式,不过无论哪种方式,务必保证在finally中释放锁
- 评估接口耗时,然后加锁时设置一个较大的过期时间,平均rt的几倍,然后finally释放锁
- 加锁时不设置过期时间,redission默认初始过期时间是30s,然后启动看门狗,每隔10s续期过期时间30s,也是需要finally中释放,一般这种用的比较多
Redis分布式锁会死锁吗?比如服务器重启或者机器突然断电
理论上不会,如果加锁时设置了过期时间则会到期释放,如果没有设置使用Redission时会初始设置,同时通过看门狗自动续期。比如正常服务器重启会等待请求执行完成,如果出现异常比如断电,那么看门狗续期也会中断,最终锁都会有一个失效时间。