分享

20)用 Redis 实现分布式锁

 古明地觉O_o 2022-12-08 发布于北京

什么是分布式锁

锁是多线程编程中的一个重要概念,它是保证多线程并发时顺利执行的关键。我们通常所说的是指程序中的锁,也就是单机锁,比如 Python threading 模块里面的 Lock 等等。

因此锁主要用于并发控制,保证一项资源在任何时候只能被一个线程使用,如果其它线程也要使用同样的资源,必须排队等待上一个线程使用完。

但很明显,单机锁要求使用范围必须局限在一个进程当中,如果换做是多个进程,需要同时操作一个共享资源,如何互斥呢?例如,现在的业务应用通常都是微服务架构,这也意味着一个应用会部署多个进程,那这多个进程如果需要修改 MySQL 中的同一行记录时,该怎么做呢?

显然为了避免操作乱序导致数据错误,我们需要引入「分布式锁」来解决这个问题。

想要实现分布式锁,必须借助一个外部系统,所有进程都去这个系统上申请「加锁」。而这个外部系统,必须要实现「互斥」的能力,即两个请求同时进来,只会给一个进程返回成功,另一个返回失败(或等待)。

而这个外部系统,可以是关系型数据库, Redis, ZooKeeper, etcd 等等,本次我们就来介绍如何基于 Redis 实现分布式锁。


如何实现分布式锁

Redis 如果想实现分布式锁,那么它必须要有互斥的能力,显然单线程的 Redis 是可以满足的。

它的实现思路是使用 setnx(set if not exists),该命令的特点是只有 key 不存在时才会设置成功,如果 key 存在则会设置失败。所以当一个应用调用 setnx 成功时,则表明此锁创建成功,否则代表这个锁已经被占用、创建失败。

我们举个例子,假设有两个客户端,首先客户端 1 申请加锁,加锁成功:

127.0.0.1:6379> SETNX lock 1
(integer) 1 # 客户端 1,加锁成功

然后客户端 2 也申请加锁,但因为后到达,所以加锁失败:

127.0.0.1:6379> SETNX lock 1
(integer) 0 # 客户端 2,加锁失败

此时加锁成功的客户端,就可以去操作「共享资源」了,例如修改 MySQL 的某一行数据,或者调用一个 API 请求。但需要注意的是,操作完成后还要及时释放锁,给后来者让出操作共享资源的机会。如果锁不释放,那么其它进程就永远都没有机会操作共享资源了。

而释放锁也很简单,直接使用 DEL 命令将 key 删除即可:

127.0.0.1:6379> DEL lock # 释放锁
(integer) 1

这样其它客户端就可以继续创建锁了。

所以分布式锁只是一个抽象的概念,并不是什么具体的数据结构。因为操作共享资源的多个进程之间是没有任何关系的,为了能让它们彼此互斥,就需要借助一个第三方系统,比如 Redis。多个进程同时连接 Redis,然后创建一个名称相同的 key,谁创建成功了,那么我们就认为谁拿到了分布式锁。

但这个过程要求 Redis 能够实现互斥,多个进程只能有一个创建成功,因此 setnx 命令就是一个绝佳的选择。设置成功时返回 True,那么进程就知道自己拿到了锁,于是会去操作共享资源;如果返回 False,就知道这个 key 已经被其它进程设置了,换言之就是分布式锁已经被别人取走了。

从逻辑上来讲,单机锁和分布式锁是类似的:

但是从实现上来讲,两者是不同的,分布式锁需要借助一个具有互斥功能的第三方组件。

但是目前这个设计还是存在问题的,首先获得锁的进程在资源操作完毕之后,必须通过 DEL 命令把锁释放掉,否则其它进程就永远没有机会操作共享资源了。那么问题来了,如果在执行 DEL 释放锁之前,程序挂掉了怎么办,比如出现异常、节点宕机,都可以导致程序挂掉。

显然如果是这种情况,那么就出现了死锁,因为锁永远不会被释放。要如何解决呢?


如何解决死锁

一个容易想到的方案就是,在设置 key 的时候,同时绑定一个过期时间,比如 30 秒。

# 加锁
127.0.0.1:6379> SETNX lock 1    
(integer) 1
# 10s后自动过期
127.0.0.1:6379> EXPIRE lock 30  
(integer) 1

这样即使程序出现崩溃,也不用担心,因为 30 秒超时时间一过,这个锁会自动解除,因此不会出现死锁的情况了。

但真的就是万事大吉了吗?可以看到 setnx 和 expire 是两条独立的命令,故存在原子性的问题,比如 setnx 成功之后,因为网络原因导致 expire 执行失败、或者因为客户端异常崩溃导致 expire 压根没有执行。

因此这两条命令如果不能保证是原子操作(全部成功),仍有潜在的风险导致过期时间设置失败,进而发生「死锁」问题。那么我们如何能保证这两条命令同时成功呢?

在早期要解决此问题,需要引入额外的类库,但这样就增加了使用的成本。因此在 Redis 2.6.12 时将 set 命令进行了扩展,从而解决了此问题。

  • set key value ex 30:设置 key 的同时指定 30s 的过期时间;

  • set key value nx:key 不存在进行设置,存在则设置失败,等价于 setnx;

  • set key value xx:key 存在进行设置,不存在则设置失败,没有 setxx;

  • set key value nx|xx ex 30:相当于将 setnx/setxx 和 expire 组合在一起;

# 设置成功,ex 30 和 nx 谁先谁后都可以
127.0.0.1:6379> set lock 1 ex 30 nx  
OK
# 在锁被占用的情况下,设置失败
127.0.0.1:6379> set lock 1 ex 30 nx  
(nil)
# 30s 过后,锁失效,设置成功
127.0.0.1:6379> set lock 1 ex 30 nx  
OK

这样我们就可以使用 set 命令来设置分布式锁,并同时设置超时时间了,因为整体就是一条 set 命令,可以保证原子性。


分布式锁的超时问题

使用 set 命令之后好像所有问题都解决了,然而真相却没那么简单。使用 set 命令只解决创建锁的问题,但释放锁还存在一些问题。

例如我们设置锁的最大超时时间是 30s,但业务处理使用了 35s,这就会导致原有的业务还未执行完成,锁就被释放了。这时候第二个客户端进程就会拿到锁,于是就会出现两个客户端同时操作共享资源,从而造成一系列问题。

所以不管是什么组件,只要是和设置超时时间相关的,基本都是很难评估的。谁也说不准,操作一个共享资源究竟要花费多长时间。即使把超时时间设置长一些,也只能缓解,却无法彻底根治。而且如果超时时间设置长了的话,比如 60s,但如果客户端 10s 就执行完了,那么就会额外多出 50s 的等待时间,这对服务的执行效率也不友好。

因此即便设置了过期时间,也会导致问题(业务的执行时间超过了分布式锁的过期时间)。但除此之外,该问题还会导致另一个问题:锁被误删。

假设锁的时间是 30s,进程 1 执行了 35s,因此进程 2 会在过了30s、锁被自动释放之后,重新获取锁。于是从 30s 到 35s 这个过程期间,两个进程会同时操作共享资源。然后在 35s 时,进程 1 执行完毕,而执行完毕后要释放锁,但此时进程1 释放的是进程 2 创建的锁(被误删)。

注意:不同的进程在执行 SET 和 DEL 命令的时候,key 一定是相同的,因为锁只能有一把。

所以接下来的重点就是如何把过期时间不好评估锁被误删这两个问题给解决掉?

如何设置过期时间

过期时间不好评估的话,我们可以换一种方式,首先大致估算一个时间,然后开启一个守护线程,定时去检测这个锁的失效时间。如果锁快要过期了,操作共享资源还未完成,那么就自动对锁进行「续租」,重新设置过期时间。

这样就避免了两个进程同时获得锁的问题。

锁被误删

锁被误删的解决方案也很简单,在使用 set 命令创建锁时,将 value 设置为只有当前进程才知道的值,比如设置一个 UUID。每次在删除之前先判断 value 是不是自己设置的 UUID,如果是的话,再删除,这样就避免了锁被误删的问题。

# 锁的 VALUE 设置为只有自己知道的 UUID
127.0.0.1:6379> SET lock $uuid EX 30 NX
OK

同时启动一个守护线程自动检测过期时间,如果时间快到了但操作还没有完成,则自动续租。之后,在释放锁时,要先判断这把锁是否是自己持有,伪代码可以这么写:

# 锁是自己的,才释放
if client.get("lock") == $uuid:
    client.delete("lock")

但这里释放锁使用的是 GET + DEL 两条命令,这又遇到前面说的原子性问题了。

  • 1)客户端 1 执行 GET,判断锁是自己的;

  • 2)客户端 2 执行了 SET 命令,强制获取到锁;

  • 3)客户端 1 执行 DEL,却释放了客户端 2 的锁;

由此可见,这两个命令还是必须要原子执行才行。怎样才能原子执行呢?答案是通过 Lua 脚本。我们可以把 GET + DEL 这两个操作组合起来,放在一个 Lua 脚本里,让 Redis 来执行。

因为 Redis 处理每一个请求都是「单线程」执行的,在执行一个 Lua 脚本时,其它请求必须等待,直到这个 Lua 脚本处理完成。这样一来,GET + DEL 之间就不会插入其它命令了。

安全释放锁的 Lua 脚本如下:

-- 判断锁是自己的,才释放
if redis.call("GET", KEYS[1]) == ARGV[1]
then
    return redis.call("DEL", KEYS[1])
else
    return 0
end

关于 Redis 中如何嵌入 lua 脚本,我们以后会说。


主从复制带来的锁重复问题

如果我们能够保证 Redis 所在节点不宕掉,那么采用 Redis 实现分布式锁就是完美的,但显然我们无法保证这一点。所以实际在使用 Redis 时,一般会采用主从复制模式,当主库异常宕机时,哨兵可以实现「故障自动切换」,把从库提升为主库,继续提供服务,以此保证可用性。

那么问题来了,当「主从发生切换」时,这个分布锁还安全吗?显然是不安全的,我们不妨想一下这样的场景:

  • 1)客户端 1 在主库上执行 SET 命令,加锁成功;

  • 2)此时,主库异常宕机,SET 设置的锁还未同步到从库上,因为主从复制是异步的;

  • 3)从库被哨兵提升为新主库,这个锁在新的主库上就丢失了。换句话说,锁对应的 key 没有同步过来,这就意味着客户端 2 会 SET 成功,也会获得分布式锁,那么此时就有了两把分布式锁;

所以当 Redis 采用主从复制时,分布式锁还是会受到影响的,或者说用 Redis 实现的分布式锁在当前这种极端场景下是不 ok 的。如果从 CAP 的角度来理解的话,因为分布式锁要求组件是 CP 模型,但 Redis 是一个 AP 模型,所以极端条件下 Redis 是不适合的。

不过很多公司还是会拿 Redis 实现分布式锁,因为 Redis 组件在项目中太常用了,并且用它来实现分布式锁也很简单。虽然在极端场景下(Redis 主库挂掉,数据同步之前从库提升为主库)可能会有问题,但毕竟发生的概率还是很低,很多公司可以接受这一点。

这里值得一提的是,阿里也是用 Redis 实现的分布式锁,但它没有使用主从集群,而是只用单个节点的 Redis 实现分布式锁。这个节点什么也不做,只用于实现分布式锁的 Redis,这样的话 Redis 服务就不会因为内存不足、CPU 负载过高等原因挂掉。

然后是网络、断电问题,阿里会给该节点配置多块网卡、多块电源,只要有一块能够工作,那么该节点就能正常工作,除非所有的网卡和电源同时宕掉,但很明显这概率是非常低的。所以阿里是通过这种手段来保证分布式锁服务可用,可以说简单粗暴,虽然是解决办法,但很明显需要钱来维持,因为它要求你首先要有一个自己的机房。

因此一般公司不会采用阿里这种做法,为了保证 Redis 服务的高可用,还是采用主从复制的模式。那么问题来了,如果真的遇到上面这种极端条件,要如何解决它呢?为此,Redis 的作者提出了一种解决方案,就是我们经常听到的 Redlock(红锁)。

但说句话,红锁这个方案个人觉得太笨重了,生产上用的非常少,感兴趣可以自己去了解一下。并且分布式神书《数据密集型应用系统设计,简称 DDIA》的作者 Martin Kleppmann 还对红锁提出了质疑,在网上和 Redis 作者来了一场辩论,可以去了解一下。


小结

本文介绍了锁和分布式锁的概念,锁其实就是用来保证同一时刻只有一个程序可以去操作某一个资源,以此来保证数据在并发时不出问题。

使用 Redis 实现分布式锁不能用 setnx 命令,因为它可能会带来死锁的问题,因此我们可以使用 Redis 2.6.12 中支持多参数的 set 命令来申请锁。但它会涉及业务执行时间超过锁的超时时间,带来线程安全和锁误删的问题。而这两个问题,可以通过守护线程定时续租、以及设置唯一标识来解决。

最后就是主从复制带来的问题,因为 Redis 是 AP 模型,而分布式锁要求的是 CP 模型,所以在极端场景下 Redis 会出问题。换句话说,使用 Redis 做分布式锁,99% 的情况下都是 ok 的。

因此 Martin 更推荐使用 zookeeper 实现分布式锁,它是 CP 模型,当然啦,出于性能考虑,更推荐 etcd。


本文参考自:

  • 《水滴与银弹》

    转藏 分享 献花(0

    0条评论

    发表

    请遵守用户 评论公约

    类似文章 更多