01-Redis 分布式锁(重要)

官网保平安:https://redis.io/

为什么需要分布式锁

在分布式应用进行逻辑处理时经常会遇到并发问题。比如一个操作要修改用户的状态,修改状态需要先读出用户的状态,在内存里进行修改,改完了再存回去。如果在执行这个操作同时,另一个进程也操作了这个状态,就会出现并发问题,因为读取和保存状态这两个操作不是原子的。

这个时候就要使用到分布式锁来限制程序的并发执行(zookeeper 和 Redis 都可以实现分布式锁)。一般来说,生产环境可用的分布式锁需要满足以下几点:

  • 互斥性:互斥是锁的基本特征,同一时刻只能有一个线程持有锁,执行临界操作。
  • 超时释放:超时释放是锁的另一个必备特性,可以对比 MySQL InnoDB 引擎中的 innodb_lock_wait_timeout 配置,通过超时释放,防止不必要的线程等待和资源浪费。
  • 可重入性:在分布式环境下,同一个节点上的同一个线程如果获取了锁之后,再次请求还是可以成功(Redis 再次获取会失败)。

所谓 Redis 的并发竞争 Key 的问题也就是多个系统同时对一个 key 进行操作,但是最后执行的顺序和我们期望的顺序不同,这样也就导致了结果的不同!最后转化为分布式锁问题。(如果不存在 Redis 的并发竞争 Key 问题,不要使用分布式锁,这样会影响性能)

Redis 分布式锁

Redis 实现分布式锁 ★★★

Redis 分布式锁本质就是在 Redis 里面占一个坑,当别的进程也要来占坑时,发现已经被占领了,就只能等待再尝试。

Redis 使用 SETNX(Set If Not Exists) 实现分布式锁。

  • 语法:SETNX key value
  • 作用:只在键 key 不存在的情况下,将键 key 的值设置为 value,若键 key 存在,则 SETNX 不做任何动作,并且返回 0
1
2
3
4
5
6
7
8
9
10
11
redis> SETNX job true     # 获得锁成功,返回1
(integer) 1
redis> SETNX lock true # 获得锁成功,返回1
(integer) 1
redis> SETNX job "code" # 尝试覆盖 job ,失败返回0
(integer) 0
redis> EXPIRE lock 5 # 设置5秒的过期时间
(integer) 1
# 做你自己的事情
redis> DEL lock # 释放锁
(integer) 1

SETNX 问题一:一直持有 ★★★

SETNX 有一个致命问题,就是某个线程在获取锁之后由于某些异常(如宕机)而不能正常的执行解锁操作,那么这个锁就永远释放不掉了。

解决办法:为这个锁加上一个超时时间 SET key value EX seconds

注:Redis 2.8 之后才能使用。

SETNX 问题二:如果在 SETNX 之后执行 EXPIRE 之前进程意外 crash 或者要重启维护了,怎么样? ★★★

有两种方案:

  • 方案一:SET 指令有非常复杂的参数,可以把 SET NXEXPIRE 合成一条指令:SET key val NX EX max-lock-time

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    # 给lock设置了过期时间为60000毫秒(也可以用ex 6000,单位就变成了秒),当用 NX 再次赋值,则返回 nil,不能重入操作
    127.0.0.1:6379> set lock true NX px 60000
    OK
    127.0.0.1:6379> set lock true NX px 6000
    (nil)
    127.0.0.1:6379> get lock
    "true"
    127.0.0.1:6379> ttl lock
    (integer) 43
    # 时间过期后再次 get,返回 nil,表明 key 为 lock 的锁已经释放
    127.0.0.1:6379> get lock
    (nil)
  • 方案二:将过期的时间写入到 lock 中 SETNX lock <current Unix time + lock timeout>。获得锁的判断条件跟之前一样, 如果返回了 1 的话,表示获得了锁,可以进行下一步的操作。

    • 判断过期条件

      正常情况下,操作完成之后,仍旧执行 DEL 操作将当前锁释放。那么如果当前程序发生了错误退出了,当前锁没有正常释放,其他的进程如何获得锁呢。

      假设上一个进程加锁之后异常退出,没有释放锁。当前的进程想要加锁,在调用 SETNX 的时候发现加锁失败,然后需要调用 GET 命令获得当前锁的值,即上一个进程写入的过期时间。

      如果获得的过期时间未到,那么当前进程继续等待;

      如果锁的过期时间已经到了,很大的概率上一个获得锁的进程已经发生了错误,因为我们这个过期时间一般会设置的比正常的运行时间要长。在这种情况下,当前进程可以重新写入这个锁并进行后续的操作。

    • 解决竞争条件

      但是这样又带来一个新的问题:假设有 P1 和 P2 两个进程同时想获得锁,他们都检测到了当前的锁已经过期了,他们可以写入,他们调用 SET 命令写入都会成功,那么如果决定到底是哪个进程获得了锁呢。

      所以在这边重新写入的时候不能简单的调用 SET 命令,还有另一个命令可以考虑: GETSET。GETSET 命令在设置值的同时,会将设置之前的值返回。

      仍旧考虑刚才的情形,P1 和 P2 同时在竞争锁,发现锁的时间 T 已经过期了,然后他们同时调用 GETSET 命令设置新的锁。假设 P1 先设置成功时间 T1,那么调用 GETSET 得到的值就是 T;P2 调用 GETSET 虽然将锁的时间设置成了 T2,但是他得到的值是 T1。

      通过判断 GETSET 返回的值,就能判断自己是否获得了锁。如果返回的值仍然是一个 过期的时间,那么说明正确的加锁了;否则的话,说明正好有别的进程已经设置了锁,当前进程只是更新了一下锁而已,就继续等待。

      可能会说这边有一个小问题,P1 设置的锁的过期时间被 P2 更改了。考虑到产生这种竞态条件的时候肯定时间间隔是非常小的,即使重新设置了过期时间,这种很短的时间修改在大多数情况下都可以忽略不计。

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
      17
      while 1:
      lock = redis.SETNX(key, time.now() + timeout)
      if lock == 1:
      // 获得锁
      break
      lock_ts = redis.GET(key)
      if (lock_ts < time.now()) && (redis.GETSET(key, time.now() + timeout) < time.now()):
      // 锁已经过期,用GETSET重新写锁
      // 返回的原来的时间仍旧过期,说明加锁成功
      break
      else:
      sleep

      .... do something ...

      // 完成之后释放锁
      redis.DEL(key)

SETNX 问题三:程序运行时间超过过期时间,key 已被释放,此时再释放是释放的其它线程已经获取到的锁。

方案看上去很完美,但实际上还是会有问题:某 线程A 获取了锁并且设置了过期时间为 10s,然后在执行业务逻辑的时候耗费了 15s,此时 线程A 获取的锁早已被 Redis 的过期机制自动释放了,而且锁可能已经被其它线程获取到了。当 线程A 执行完业务逻辑准备解锁(DEL key)的时候,有可能删除掉的是其它线程已经获取到的锁。

解决方案:最好在解锁时判断锁是否是自己的。

  1. 在设置 key 的时候将 value 设置为一个唯一值 uniqueValue(可以是随机值、UUID、或者机器号+线程号的组合、签名等)。
  2. 解锁时,也就是删除 key 时,先判断一下 key 对应的 value 是否等于先前设置的值,如果相等才能删除 key。

这里我们一眼就可以看出问题来:GETDEL 是两个分开的操作,在 GET 执行之后且在 DEL 执行之前的间隙是可能会发生异常的。

如果我们只要保证解锁的代码是原子性的就能解决问题了。

这里我们引入了一种新的方式,就是 Lua脚本,示例如下:

1
2
3
4
5
if redis.call("get",KEYS[1]) == ARGV[1] then    # ARGV[1]表示设置key时指定的唯一值。
return redis.call("del",KEYS[1])
else
return 0
end

由于 Lua 脚本的原子性,在 Redis 执行该脚本的过程中,其他客户端的命令都需要等待该 Lua 脚本执行完才能执行。

在 Redis 中执行 Lua 的原子性是指:整个 Lua 脚本在执行期间,会被当作一个整体,不会被其他客户端的命令打断。

SETNX 问题三:程序运行时间超过过期时间,key 已被释放,其他程序获得锁。

为了防止多个线程同时执行业务代码,需要确保过期时间大于业务执行时间。

增加一个 boolean 类型的属性 isOpenExpirationRenewal,用来标识是否开启定时刷新过期时间。

在增加一个 scheduleExpirationRenewal 方法用于开启刷新过期时间的线程。

加锁代码在获取锁成功后将 isOpenExpirationRenewal 置为 true,并且调用 scheduleExpirationRenewal 方法,开启刷新过期时间的线程。

解锁代码增加一行代码,将 isOpenExpirationRenewal 属性置为 false,停止刷新过期时间的线程轮询。

Redisson 实现

Redission 是 JAVA 的一个库。

获取锁成功就会开启一个定时任务,定时任务会定期检查去续期。

该定时调度每次调用的时间差是 internalLockLeaseTime/3,也就 10 秒。

默认情况下,加锁的时间是 30 秒。如果加锁的业务没有执行完,那么到 30-10 = 20 秒的时候,就会进行一次续期,把锁重置成 30 秒。

RedLock 算法

在集群中,主节点挂掉时,从节点会取而代之,客户端上却并没有明显感知。

假设第一个客户端在主节点中申请成功了一把锁,但是这把锁还没有来得及同步到从节点,主节点突然挂掉了。然后从节点变成了主节点,这个新的节点内部没有这个锁,所以当另一个客户端过来请求加锁时,立即就批准了。这样就会导致系统中同样一把锁被两个客户端同时持有,不安全性由此产生。

Redlock 算法就是为了解决这个问题

使用 Redlock,需要提供多个 Redis 实例,这些实例之前相互独立没有主从关系。同很多分布式算法一样,Redlock 也使用大多数机制。

加锁时,它会向过半节点发送 setnx 指令,只要过半节点 setnx 成功,那就认为加锁成功。释放锁时,需要向所有节点发送 del 指令。不过 Redlock 算法还需要考虑出错重试、时钟漂移等很多细节问题,同时因为 Redlock 需要向多个节点进行读写,意味着相比单实例 Redis 性能会下降一些。

Redlock 算法是在单 Redis 节点基础上引入的高可用模式,Redlock 基于 N 个完全独立的 Redis 节点,一般是大于 3 的奇数个(通常情况下 N 可以设置为 5),可以基本保证集群内各个节点不会同时宕机。

假设当前集群有 5 个节点,运行 Redlock 算法的客户端依次执行下面各个步骤,来完成获取锁的操作:

  • 客户端记录当前系统时间,以毫秒为单位;
  • 依次尝试从 5 个 Redis 实例中,使用相同的 key 获取锁,当向 Redis 请求获取锁时,客户端应该设置一个网络连接和响应超时时间,超时时间应该小于锁的失效时间,避免因为网络故障出现的问题;
  • 客户端使用当前时间减去开始获取锁时间就得到了获取锁使用的时间,当且仅当从半数以上的 Redis 节点获取到锁,并且当使用的时间小于锁失效时间时,锁才算获取成功;
  • 如果获取到了锁,key 的真正有效时间等于有效时间减去获取锁所使用的时间,减少超时的几率;
  • 如果获取锁失败,客户端应该在所有的 Redis 实例上进行解锁,即使是上一步操作请求失败的节点,防止因为服务端响应消息丢失,但是实际数据添加成功导致的不一致。

也就是说,假设锁 30 秒过期,三个节点加锁花了 31 秒,自然是加锁失败了。

在 Redis 官方推荐的 Java 客户端 Redisson 中,内置了对 RedLock 的实现

https://redis.io/topics/distlock

https://github.com/redisson/redisson/wiki

RedLock问题:

  1. 失败时重试(脑裂问题)。

    当客户端无法取到锁时,应该在一个随机延迟后重试,防止多个客户端在同时抢夺同一资源的锁(这样会导致脑裂,没有人会取到锁)。同样,客户端取得大部分 Redis 实例锁所花费的时间越短,脑裂出现的概率就会越低(必要的重试),所以,理想情况一下,客户端应该同时(并发地)向所有 Redis 发送 SET 命令。

    需要强调,当客户端从大多数 Redis 实例获取锁失败时,应该尽快地释放(部分)已经成功取到的锁,这样其他的客户端就不必非得等到锁过完”有效时间”才能取到(然而,如果已经存在网络分裂,客户端已经无法和 Redis 实例通信,此时就只能等待 key 的自动释放了,等于被惩罚了)。

  2. 为什么释放锁,要操作所有节点,对所有节点都释放锁?

    因为当对某一个 Redis 节点加锁时,可能因为网络原因导致加锁“失败”。注意这个“失败”,指的是 Redis 节点实际已经加锁成功了,但是返回的结果因为网络延迟并没有传到加锁的线程,被加锁线程丢弃了,加锁线程误以为没有成功,于是加锁线程去尝试下一个节点了。

    所以释放锁的时候,不管以前有没有加锁成功,都要释放所有节点的锁,以保证清除节点上述图中发生的情况导致残留的锁。

  3. RedLock 是一个严重依赖系统时钟的分布式系统。

Martin 对 RedLock 的批评:

  • 对于提升效率的场景下,RedLock 太重。
  • 对于对正确性要求极高的场景下,RedLock 并不能保证正确性。

Python 实现 RedLock,需要安装 redlock-py

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
from redlock import Redlock
dlm = Redlock(
[
{"host": "localhost", "port": 6379, "db": 0},
{"host": "localhost", "port": 6379, "db": 0},
{"host": "localhost", "port": 6379, "db": 0},
]
)
# 加锁,acquire
my_lock = dlm.lock("my_resource_name",10000)
if my_lock:
# 进行操作
# 解锁,release
dlm.unlock(my_lock)
else:
print('获取锁失败')
# 通过sever.eval(self.unlock_script)执行一个lua脚本,用来删除加锁时的key

ZooKeeper 分布式锁

核心思想:当客户想要获取锁,则创建节点,使用完锁,则删除该节点。

  1. 客户端获取锁时,在 lock 节点下创建临时顺序节点。
    为什么使用临时节点,和 Redis 的过期时间一个道理,就算 ZooKeeper 服务器宕机,临时节点会随着服务器的宕机而消失,避免了死锁的情况。

  2. 然后获取 lock 下面所有的子节点,客户端获取到所有的子节点之后,如果发现自己创建的子节点序号最小,那么就认为该客户端获取到了锁。使用完锁后,将该节点删除。

  3. 如果发现自己创建的节点并非 lock 节点下所有子节点中最小的,说明自己还没有获取到锁,此时客户端需要找到比自己小的那个节点,同时对其注册事件监听器,监听删除事件。

  4. 如果发现比自己小的那个节点被删除,则客户端的 Watcher 会受到相应通知,此时再判断自己创建的节点是否是 lock 子节点中序号最小的,如果是则获取到了锁,如果不是则重复以上步骤继续获取到比自己小的一个节点并注册监听。

ZooKeeper 介绍

百度百科是这么介绍 ZooKeeper 的:ZooKeeper 是一个分布式的,开放源码的分布式应用程序协调服务,是 Google 的 Chubby 一个开源的实现,是 Hadoop 和 Hbase 的重要组件。

对于初认的人,可以理解成 ZooKeeper 就像是我们的电脑文件系统,我们可以在 d 盘中创建文件夹 a,并且可以继续在文件夹 a 中创建文件夹 a1,a2。

那我们的文件系统有什么特点??那就是同一个目录下文件名称不能重复,同样 ZooKeeper 也是这样的。在 ZooKeeper 所有的节点,也就是文件夹称作 Znode,而且这个 Znode 节点是可以存储数据的。

我们可以通过 create/zkjjj nice 来创建一个节点,这个命令就表示,在根目录下创建一个 zkjjj 的节点,值是 nice。这里的值,没什么意义。

ZooKeeper 可以创建4种类型的节点,分别是:

  1. 持久性节点。持久性节点表示只要你创建了这个节点,那不管你 ZooKeeper 的客户端是否断开连接,ZooKeeper 的服务端都会记录这个节点。

  2. 持久性顺序节点。顺序性节点是指,在创建节点的时候,ZooKeeper 会自动给节点编号比如 0000001,0000002 这种的。

  3. 临时性节点。临时性节点和持久性借点刚好相反,一旦 ZooKeeper 客户端断开了连接,那 ZooKeeper 服务端就不再保存这个节点

  4. 临时性顺序节点。顺序性节点是指,在创建节点的时候,ZooKeeper 会自动给节点编号比如 0000001,0000002 这种的。

我们可能需要注意到一点,就是惊群效应:举一个很简单的例子,当你往一群鸽子中间扔一块食物,虽然最终只有一个鸽子抢到食物,但所有鸽子都会被惊动来争夺,没有抢到..

就是当 服务器1 节点有变化,会通知其余的 99 个服务器,但是最终只有 1 个服务器会创建成功,这样 98 还是需要等待监听,那么为了处理这种情况,就需要用到临时顺序性节点。

大致意思就是,之前是所有 99 个服务器都监听一个节点,现在就是每一个服务器监听自己前面的一个节点。

假设 100 个服务器同时发来请求,这个时候会在 /zkjjj 节点下创建 100 个临时顺序性节点 /zkjjj/000000001、/zkjjj/000000002 ... /zkjjj/000000100 这个编号就等于是已经给他们设置了获取锁的先后顺序了。

当 001 节点处理完毕,删除节点后,002 收到通知,去获取锁,开始执行,执行完毕,删除节点,通知 003 以此类推。

参考:https://blog.csdn.net/whirlwind526/article/details/124162916
https://it.sohu.com/a/579943276_411876

zookeeper 和 Redis 分布式锁的区别

  • 实现方式的不同,Redis 实现为去插入一条占位数据,而 ZooKeeper 实现为去注册一个临时节点。
  • 遇到宕机情况时,Redis 需要等到过期时间到了后自动释放锁,而 ZooKeeper 因为是临时节点,在宕机时候已经是删除了节点去释放锁。
  • Redis 在没抢占到锁的情况下一般会去自旋获取锁,比较浪费性能,而 ZooKeeper 是通过注册监听器的方式获取锁,性能而言优于 Redis。
  • Redis 的设计定位决定了它的数据并不是强一致性的,在某些极端情况下,可能会出现问题。锁的模型不够健壮。即便使用 RedLock 算法来实现,在某些复杂场景下,也无法保证其实现 100% 没有问题。
    使用 Redis 实现分布式锁在很多企业中非常常见,而且大部分情况下都不会遇到所谓的“极端复杂场景”,所以使用 Redis 作为分布式锁也不失为一种好的方案,最重要的一点是 Redis 的性能很高,可以支撑高并发的获取、释放锁操作。
  • zookeeper 天生设计定位就是分布式协调,强一致性。锁的模型健壮、简单易用、适合做分布式锁。

处理死锁

  • 通过 Redis 中 expire() 给锁设定最大持有时间,如果超过,则 Redis 来帮我们释放锁。
  • 使用 setnx key 当前系统时间+锁持有的时间getset key 当前系统时间+锁持有的时间 组合的命令就可以实现。

Reference


01-Redis 分布式锁(重要)
https://flepeng.github.io/interview-41-数据库-41-Redis-01-Redis-分布式锁(重要)/
作者
Lepeng
发布于
2020年8月8日
许可协议