Skip to content

什么是分布式锁?

分布式锁:跨多个服务器的锁,需要保证同一时刻只有一个线程拿到锁。常用实现方式:redis。

设计redis分布式锁需要考虑的问题

设计redis分布式锁需要考虑以下5个核心问题:

一、原子性问题

多步操作被打断导致竞态条件,需要考虑如何保证加锁和解锁过程中的多个操作不被中断。
加锁原子性:

SET key value NX PX timeout

注:

  • NX:等同于SETNX的作用(仅当key不存在才设置);
  • PX:同时设置过期时间,单位是毫秒。

此命令在一条命令中原子性完成了判断是否存在、设置值、设置过期时间三个任务。

解锁原子性: 使用Lua脚本封装逻辑,对于原生命令无法覆盖的复杂逻辑,lua脚本是保证原子性的终极武器,redis会以单线程模式执行整个lua脚本,期间不会被任何其他命令打断,从而保证了操作的原子性。示例如下:

redis
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是否与自己的唯一标识相同"、"如果相同则删除锁"三个步骤一起原子性操作。示例如下:
redis
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集群健康状态等关键指标,并在出异常时及时告警。

Released under the MIT License.