什么是分布式锁?
分布式锁:跨多个服务器的锁,需要保证同一时刻只有一个线程拿到锁。常用实现方式:redis。
设计redis分布式锁需要考虑的问题
设计redis分布式锁需要考虑以下5个核心问题:
一、原子性问题
多步操作被打断导致竞态条件,需要考虑如何保证加锁和解锁过程中的多个操作不被中断。
加锁原子性:
SET key value NX PX timeout注:
- NX:等同于SETNX的作用(仅当key不存在才设置);
- PX:同时设置过期时间,单位是毫秒。
此命令在一条命令中原子性完成了判断是否存在、设置值、设置过期时间三个任务。
解锁原子性: 使用Lua脚本封装逻辑,对于原生命令无法覆盖的复杂逻辑,lua脚本是保证原子性的终极武器,redis会以单线程模式执行整个lua脚本,期间不会被任何其他命令打断,从而保证了操作的原子性。示例如下:
if redis.call('get',KEYS[1])==argv[1] then
return redis.call('del',KEYS[1])
else
return 0
end注:调用时,KEYS[1]传锁的key(比如odrder:100),ARGV[1]传当前线程的标识(比如线程ID)。
二、死锁问题
持有锁的线程崩溃后锁无法释放。 最基本的死锁场景:源于客户端的异常退出,如服务器端电,或当一个线程成功获取锁后,执行finally块中解锁逻辑前发生崩溃,这个锁将永远无法释放,导致其他所有等待该锁的线程陷入无限期等待中,造成死锁问题。
解决方案:
1、原子化加锁与设置过期时间:
SET key value NX PX timeout注:这条命令保证了加锁和设置过期时间的原子性。绝不能使用setnx和expire两个独立命令,它们不能保证原子性。
2、看门狗机制:业务执行时间超过了锁的过期时间,此时锁自动释放,其他线程进入,会导致并发问题。成熟的框架如Redisson引入了看门狗机制,它会在客户端获取锁后,启动一个后台线程,每隔一段时间检查一次,线程是否还在执行任务,如果还在执行,在锁的过期时间到达前,自动为锁续期,直到业务执行完毕,客户端主动释放锁。 问题点:看门狗为何要设置为守护线程?防止如果线程A在执行业务时候突然挂了,看门狗一直续期问题,设置为守护线程,它的生命周期依赖于主线程(业务线程如:线程A),主线程挂了,守护线程会自动终止。
三、锁误删问题
一个线程释放了另一个线程的锁。 引入过期时间,解决了死锁问题,但带来你的问题:锁误删。 场景描述:线程A加锁(key是order:100)成功,因为网络卡了或者程序执行时间长导致锁过期了自动释放,此时线程B拿到锁(key是order:100),如果此时线程A网络恢复或者程序执行完了,直接执行DEL order:100释放锁,就会把线程B的锁给释放掉。
解决方案:
解锁前必须先判断这个锁是不是自己加的。也就是:
- 1.设置唯一标识: 加锁时,将一个惟一id(如UUID:线程ID)作为key的value,解锁前检查当前锁的value是不是自己线程的标识(如UUID:线程ID);
- 2.使用Lua脚本原子化解锁: 解锁时不能简单实用DEL命令,将"获取锁value"、"判断value是否与自己的唯一标识相同"、"如果相同则删除锁"三个步骤一起原子性操作。示例如下:
if redis.call('get',KEYS[1])==argv[1] then
return redis.call('del',KEYS[1])
else
return 0
end注:调用时,KEYS[1]传锁的key(比如odrder:100),ARGV[1]传当前线程的标识(比如线程ID)。
四、单点故障问题
redis宕机导致服务不可用。 解决方案:
- 1.主从+哨兵模式: 通过哨兵自动进行主备切换,问题在于写操作全部集中在主节点,且主从复制是异步。在主节点宕机,锁信息尚未同步到从节点时,可能发锁丢失。
- 2.redis cluster模式(推荐):官方推荐的集群方案,通过将数据分片到16384个哈希槽中,并将槽位分布在多个主节点上,实现真正的去中心化。每个主节点都可以处理读写请求,当某个主节点宕机时,只会影响其负责的一部分哈希槽,而不是整个服务。对于分布式锁而言,这意味着即使部分节点故障,其他节点上的锁服务依然可用,极大提高了系统的容错能力和可用性。
五、不可重入问题
同一线程在持有锁的情况下,无法重复获取该锁。 场景描述:在一个递归方法或者方法潜逃调用中,一个简答的,非重入的锁会导致线程在第二次尝试加锁时被自己阻塞,从而引发死锁。
解决方案:
- 1.使用hash结构记录重入次数:要实现可重入,需要让锁能够识别出加锁的是同一个线程,并记录重入的次数,可以使用redis的hash数据结构来存储锁信息: 锁的key: order:100 ; hash的field: 线程ID+UUID(区分不同服务器的线程,避免集群环境下线程id重复); hash的value: 重入次数。 流程: 1、线程A第一次加锁:执行HSETNX order:100 "线程A的ID+UUID" 1 (如果field不存在,设置为1); 2、线程A再次加锁(比如调用方法B):执行HINCRBY order:100 1 "线程A的ID+UUID" 1 (计数器加1); 3、线程释放一次锁:执行HINCRBY 。。。 -1(计数器减1); 4、当计数器减到0时,删除整个Hash(真正释放锁)。
- 2.本地维护计数器+redis存锁标识: ++ redis中只存当前锁被哪个线程持有(string类型); ++ 每个服务器的本地用ConcurrentHashMap维护一个计数器:key是锁的名称,value是重入次数。
流程: 1.线程A第一次加锁:redis中设置成功,本地map中计数器设为1; 2.再次加锁:redis中已存在自己的标识,直接把本地计数器加1; 3.释放锁:本地计数器减1,减到0时,在调用lua脚本删除redis中的锁。
加锁逻辑:检查锁是否存在,如果不存在,则创建hash并设置重入次数为1。如果存在,则判断field是否为当前线程ID,如果是,则将重入次数加1(HINCRBY),如果不是,则加锁失败。
解锁逻辑:将重入次数减1,如果减1后次数仍大于0,则表示锁还不能被释放,只有当次数减为0,才真正删除这个hash键。redisson等成熟框架已经完美实现了这套逻辑。
生产环境最佳实践
五大问题完整解决方案:
| 关键问题 | 核心原因 | 最佳方案 | 关键技术 |
|---|---|---|---|
| 原子性问题 | 多步操作可被打断 | 原子命令+Lua脚本 | SET NX PX +Lua |
| 死锁问题 | 线程崩溃或超时未释放锁 | 过期时间+Redisson看门狗 | SET EX PX +自动续期 |
| 锁误删问题 | 未校验锁持有者身份 | 唯一标识+Lua脚本 | UUID+线程ID+原子校验 |
| 单点故障问题 | redis单节点无冗余 | redis cluster | 分片存储+主从复制 |
| 不可重入问题 | 未记录重入次数 | redisson hash计算器等 | HSET + HINCRBY |
强烈建议遵循以下原则:
- 1、框架选型:优先使用redisson等成熟的开源框架。他们已经封装了对上述所有问题的解决方案,包括看门狗、可重入、Lua脚本等,可以让我们避免重复造轮子,将精力聚焦于业务逻辑。
- 2、集群方案:部署redis cluster(至少3主3从)来保证锁服务的高可用性和水平扩展能力。
- 3、监控告警:建立完善的监控体系,实时关注锁的获取成功率,平均时耗、redis集群健康状态等关键指标,并在出异常时及时告警。
