分布式锁-基于Redis的实现

29 阅读8分钟

1.什么是锁,分布式锁和单机锁有什么区别

  • 在单进程的系统中,当存在多个线程可以同时改变某个变量(可变共享变量)时,就需要对变量或代码块做同步,使其在修改这种变量时能够线性执行消除并发修改变量。
  • 而同步的本质是通过锁来实现的。为了实现多个线程在一个时刻同一个代码块只能有一个线程可执行,那么需要在某个地方做个标记,这个标记必须每个线程都能看到,当标记不存在时可以设置该标记,其余后续线程发现已经有标记了则等待拥有标记的线程结束同步代码块取消标记后再去尝试设置标记。这个标记可以理解为锁。
  • 不同地方实现锁的方式也不一样,只要能满足所有线程都能看得到标记即可。如 Java 中 synchronize 是在对象头设置标记,Lock 接口的实现类基本上都只是某一个 volitile 修饰的 int 型变量其保证每个线程都能拥有对该 int 的可见性和原子修改,linux 内核中也是利用互斥量或信号量等内存数据做标记。
  • 分布式与单机情况下最大的不同在于其不是多线程而是多进程
  • 多线程由于可以共享堆内存,因此可以简单的采取内存作为标记存储位置。而进程之间甚至可能都不在同一台物理机上,因此需要将标记存储在一个所有进程都能看到的地方。
  • 当在分布式模型下,数据只有一份(或有限制),此时需要利用锁的技术控制某一时刻修改数据的进程数。
  • 与单机模式下的锁不仅需要保证进程可见,还需要考虑进程与锁之间的网络问题。(我觉得分布式情况下之所以问题变得复杂,主要就是需要考虑到网络的延时和不可靠。。。一个大坑)
  • 分布式锁还是可以将标记存在内存,只是该内存不是某个进程分配的内存而是公共内存如 Redis、Memcache。至于利用数据库、文件等做锁与单机的实现是一样的,只要保证标记能互斥就行。

2.分布式锁的实现

本文主要介绍基于Redis的分布式锁实现 基于Redis的分布式锁实现

  • 方案一:SETNX + EXPIRE

setnx+ expire命令。即先用setnx来抢锁,如果抢到之后,再用expire给锁设置一个过期时间,防止锁忘记了释放。

setnxexpire两个命令分开了,不是原子操作。如果执行完setnx加锁,正要执行expire设置过期时间时,进程crash或者要重启维护了,那么这个锁就“长生不老”了,别的线程永远获取不到锁啦

  • 方案二:SETNX + value值是(系统时间+过期时间)

为了解决方案一,发生异常锁得不到释放的场景,有小伙伴认为,可以把过期时间放到setnx的value值里面。如果加锁失败,再拿出value值校验一下即可。

过期时间是客户端自己生成的(System.currentTimeMillis()是当前系统的时间),必须要求分布式环境下,每个客户端的时间必须同步。

如果锁过期的时候,并发多个客户端同时请求过来,都执行jedis.getSet(),最终只能有一个客户端加锁成功,但是该客户端锁的过期时间,可能被别的客户端覆盖

该锁没有保存持有者的唯一标识,可能被别的客户端释放/解锁。

  • 方案三:使用Lua脚本(包含SETNX + EXPIRE两条指令)
if (redis.call('setnx', KEYS[1], ARGV[1]) < 1)
then return 0;
end;
redis.call('expire', KEYS[1], tonumber(ARGV[2]));
return 1;

优点:保证加锁和解锁的原子性。

缺点:无法支持锁的重入,主从模式可能造成锁丢失,锁无法自动续期。可能存在锁误解除情况

如果线程 A 成功获取到了锁,并且设置了过期时间 30 秒,但线程 A 执行时间超过了 30 秒,锁过期自动释放,此时线程 B 获取到了锁;随后 A 执行完成,线程 A 使用 DEL 命令来释放锁,但此时线程 B 加的锁还没有执行完成,线程 A 实际释放的线程 B 加的锁。

  • 方案四:使用Lua脚本(包含SETNX + EXPIRE两条指令)并且在value中设置能够表示当前线程的身份信息
// 解锁
if (redis.call('get', KEYS[1]) == ARGV[1])
    then return redis.call('del', KEYS[1])
else return 0
end

缺点:锁没有自动续期机制,锁无法支持重入。

解决方案:
将过期时间设置足够长,确保代码逻辑在锁释放之前能够执行完成;
为获取锁的线程增加守护线程,为将要过期但未释放的锁增加有效时间。

当线程在持有锁的情况下再次请求加锁,如果一个锁支持一个线程多次加锁,那么这个锁就是可重入的。如果一个不可重入锁被再次加锁,由于该锁已经被持有,再次加锁会失败。Redis 可通过对锁进行重入计数,加锁时加 1,解锁时减 1,当计数归 0 时释放锁。
解决方案:可以使用Redis Hash数据结构来实现分布式锁,既存锁的标识也对重入次数进行计数。

  • 方案六: 开源框架:Redisson

对于可能存在锁过期释放,业务没执行完的问题。我们可以稍微把锁过期时间设置长一些,大于正常业务处理时间就好啦。如果你觉得不是很稳,还可以给获得锁的线程,开启一个定时守护线程,每隔一段时间检查锁是否还存在,存在则对锁的过期时间延长,防止锁过期提前释放。

当前开源框架Redisson解决了这个问题。可以看下Redisson底层原理图:

image.png

只要线程一加锁成功,就会启动一个watch dog看门狗,它是一个后台线程,会每隔10秒检查一下,如果线程1还持有锁,那么就会不断的延长锁key的生存时间。因此,Redisson就是使用watch dog解决了锁过期释放,业务没执行完问题。

优点:锁支持自动续期。

缺点:主从模式可能造成锁丢失。

客户端1 对某个 master节点写入了redisson锁,此时会异步复制给对应的 slave节点。但是这个过程中一旦发生 master节点宕机,主备切换,slave节点从变为了 master节点。 这时 客户端2 来尝试加锁的时候,在新的master节点上也能加锁,此时就会导致多个客户端对同一个分布式锁完成了加锁。

  • 方案七:多机实现的分布式锁Redlock

  • 按顺序向5个master节点请求加锁

  • 根据设置的超时时间来判断,是不是要跳过该master节点。

  • 如果大于等于三个节点加锁成功,并且使用的时间小于锁的有效期,即可认定加锁成功啦。

  • 如果获取锁失败,解锁!

缺点:需要部署多台redis机器,极端情况下,会造成两个线程同时获得锁。为了避免该种情况发生,要求宕机的redis在超过锁超时时间后再重启。使用锁的时间要小于锁超时时间。

极端场景

原本ClientA通过RedLock加锁成功在Redis_1Redis_2Redis_3实例上成功加锁!但过了一段时间后,Redis_3节点宕机掉后重启加入集群,但加锁的数据没了,此时被ClientB趁虚而入,在Redis_3Redis_4Redis_5节点成功超半数加锁,那么ClientA和ClientB同时持有锁,这个锁就不包熟了!

解决办法:

  • 持久化数据,使用AOF方式来存储数据,尽可能地保存全部锁的数据,当节点宕机之后也能保证重启之后锁依然在Redis中。AOF同步策略中,有每秒同步每次同步。设置位每秒同步,每次进行写操作的时候都会写日志,就是效率优点低。
  • 延迟启动。光时靠持久化数据还不够,必须估计到数据还没有持久化到磁盘后就宕机的情况。此时我们可以采取延迟启动。Redis宕机之后不要立即重启,而是要等分布式锁中最长的Key的TTL(超时时间)过了之后再启动,保证全部Key都被强制解锁了。但这种方案需要用一个东西来存储每个分布式锁的TTL时间。

客户端通过RedLock加锁成功后,就执行自己的业务逻辑。

客户端恰巧执行垃圾回收,GC中的STW(stop the world),机制会导致客户端阻塞一段时间。

当客户端醒过来后,锁已经在Redis中失效了,然后被Client2趁虚而入,Client2加锁成功。此时Client1、Client2同时持有锁,导致资源不安全。

给每一个锁都加入一个标志ID,这个标志ID是单调递增的。越晚生成的锁就越小。

回到刚刚的场景下,Client1拿到了锁并且这个锁的ID是369。而Client2是后来拿到锁的,所以他生成的锁的ID是370(比369要大)。那么在他们进行写入操作的时候,只允许最大的那个锁所在的客户端有效即可!