Redis 并发控制深度解析
Redis 并发控制
一、原子操作
为了实现并发控制要求的临界区代码互斥执行,Redis 的原子操作采用了两种方法:
- 把多个操作在 Redis 中实现成一个操作,也就是单命令操作
- 把多个操作写到一个 Lua 脚本中,以原子性方式执行单个 Lua 脚本
1.1 单命令操作
Redis 是使用单线程来串行处理客户端的请求操作命令的,所以,当 Redis 执行某个命令操作时,其他命令是无法执行的,这相当于命令操作是互斥执行的。当然,Redis 的快照生成、AOF 重写这些操作,可以使用后台线程或者是子进程执行,也就是和主线程的操作并行执行。不过,这些操作只是读取数据,不会修改数据,所以,我们并不需要对它们做并发控制。
虽然 Redis 的单个命令操作可以原子性地执行,但是在实际应用中,数据修改时可能包含多个操作,至少包括读数据、数据增减、写回数据三个操作,这显然就不是单个命令操作了。Redis 提供了 INCR/DECR 命令,把这三个操作转变为一个原子操作。INCR/DECR 命令可以对数据进行增值/减值操作,而且它们本身就是单个命令操作,Redis 在执行它们时,本身就具有互斥性。
单命令操作的局限性:
单命令原子操作的适用范围较小,并不是所有的 RMW 操作(Read-Modify-Write)都能转变成单命令的原子操作。例如 INCR/DECR 命令只能在读取数据后做原子增减,当我们需要对读取的数据做更多判断,或者是我们对数据的修改不是简单的增减时,单命令操作就不适用了。
1.2 Lua 脚本
如果我们要执行的操作不是简单地增减数据,而是有更加复杂的判断逻辑或者是其他操作,那么,Redis 的单命令操作已经无法保证多个操作的互斥执行了。这个时候,我们需要使用 Lua 脚本。
Redis 会把整个 Lua 脚本作为一个整体执行,在执行的过程中不会被其他命令打断,从而保证了 Lua 脚本中操作的原子性。如果我们有多个操作要执行,但是又无法用 INCR/DECR 这种命令操作来实现,就可以把这些要执行的操作编写到一个 Lua 脚本中。然后,我们可以使用 Redis 的 EVAL 命令来执行脚本。这样一来,这些操作在执行时就具有了互斥性。
Lua 脚本的优势与注意事项:
Redis 的 Lua 脚本可以包含多个操作,这些操作都会以原子性的方式执行,绕开了单命令操作的限制。不过,如果把很多操作都放在 Lua 脚本中原子执行,会导致 Redis 执行脚本的时间增加,同样也会降低 Redis 的并发性能。
建议: 在编写 Lua 脚本时,要避免把不需要做并发控制的操作写入脚本中。
当然,加锁也能实现临界区代码的互斥执行,只是如果有多个客户端加锁时,就需要分布式锁的支持了。
二、分布式锁
2.1 基于单个节点的实现
2.1.1 基本原理
作为分布式锁实现过程中的共享存储系统,Redis 可以使用键值对来保存锁变量,再接收和处理不同客户端发送的加锁和释放锁的操作请求。我们要赋予锁变量一个变量名,把这个变量名作为键值对的键,而锁变量的值,则是键值对的值,这样一来,Redis 就能保存锁变量了,客户端也就可以通过 Redis 的命令操作来实现锁操作。
2.1.2 加锁与释放锁流程
加锁操作:
客户端 A 和 C 同时请求加锁。因为 Redis 使用单线程处理请求,所以,即使客户端 A 和 C 同时把加锁请求发给了 Redis,Redis 也会串行处理它们的请求。假设 Redis 先处理客户端 A 的请求,读取 lock_key 的值,发现 lock_key 为 0,所以,Redis 就把 lock_key 的 value 置为 1,表示已经加锁了。紧接着,Redis 处理客户端 C 的请求,此时,Redis 会发现 lock_key 的值已经为 1 了,所以就返回加锁失败的信息。
释放锁操作:
释放锁就是直接把锁变量值设置为 0。当客户端 A 持有锁时,锁变量 lock_key 的值为 1。客户端 A 执行释放锁操作后,Redis 将 lock_key 的值置为 0,表明已经没有客户端持有锁了。
因为加锁包含了三个操作(读取锁变量、判断锁变量值以及把锁变量值设置为 1),而这三个操作在执行时需要保证原子性。
2.1.3 使用 SETNX 命令实现
Redis 可以用 SETNX 命令实现加锁操作。SETNX 命令用于设置键值对的值,这个命令在执行时会判断键值对是否存在,如果不存在,就设置键值对的值,如果存在,就不做任何设置。
对于释放锁操作来说,我们可以在执行完业务逻辑后,使用 DEL 命令删除锁变量。因为 SETNX 命令在执行时,如果要设置的键值对(也就是锁变量)不存在,SETNX 命令会先创建键值对,然后设置它的值。所以,释放锁之后,再有客户端请求加锁时,SETNX 命令会创建保存锁变量的键值对,并设置锁变量的值,完成加锁。
2.1.4 潜在风险与解决方案
使用 SETNX 和 DEL 命令组合实现分布锁,存在两个潜在的风险:
风险一:锁无法释放
假如某个客户端在执行了 SETNX 命令、加锁之后,紧接着却在操作共享数据时发生了异常,结果一直没有执行最后的 DEL 命令释放锁。因此,锁就一直被这个客户端持有,其它客户端无法拿到锁,也无法访问共享数据和执行后续操作,这会给业务应用带来影响。
解决方案: 给锁变量设置一个过期时间。这样一来,即使持有锁的客户端发生了异常,无法主动地释放锁,Redis 也会根据锁变量的过期时间,在锁变量过期后,把它删除。其它客户端在锁变量过期后,就可以重新请求加锁。
风险二:锁被误释放
如果客户端 A 执行了 SETNX 命令加锁后,假设客户端 B 执行了 DEL 命令释放锁,此时,客户端 A 的锁就被误释放了。如果客户端 C 正好也在申请加锁,就可以成功获得锁,进而开始操作共享数据。这样一来,客户端 A 和 C 同时在对共享数据进行操作,数据就会被修改错误。
解决方案: 在锁变量的值上做文章。在使用 SETNX 命令进行加锁的方法中,我们通过把锁变量值设置为 1 或 0,表示是否加锁成功。1 和 0 只有两种状态,无法表示究竟是哪个客户端进行的锁操作。所以,我们在加锁操作时,可以让每个客户端给锁变量设置一个唯一值,这里的唯一值就可以用来标识当前操作的客户端。在释放锁操作时,客户端需要判断,当前锁变量的值是否和自己的唯一标识相等,只有在相等的情况下,才能释放锁。
2.1.5 使用 SET 命令的完整实现
Redis 的 SET 命令提供了 NX 选项,用来实现”不存在即设置”。如果使用了 NX 选项,SET 命令只有在键值对不存在时,才会进行设置,否则不做赋值操作。此外,SET 命令在执行时还可以带上 EX 或 PX 选项,用来设置键值对的过期时间。
举个例子,执行 SET key value [EX seconds | PX milliseconds] [NX] 时,只有 key 不存在时,SET 才会创建 key,并对 key 进行赋值。另外,key 的存活时间由 seconds 或者 milliseconds 选项值来决定。
加锁命令:
1 | // 加锁, unique_value作为客户端唯一性的标识 |
其中,unique_value 是客户端的唯一标识,可以用一个随机生成的字符串来表示,PX 10000 则表示 lock_key 会在 10s 后过期,以免客户端在这期间发生异常而无法释放锁。
释放锁操作:
因为在加锁操作中,每个客户端都使用了一个唯一标识,所以在释放锁操作时,我们需要判断锁变量的值,是否等于执行释放锁操作的客户端的唯一标识。在释放锁操作中,我们需要使用 Lua 脚本,这是因为,释放锁操作的逻辑也包含了读取锁变量、判断值、删除锁变量的多个操作,而 Redis 在执行 Lua 脚本时,可以以原子性的方式执行,从而保证了锁释放操作的原子性。
2.1.6 单节点方案的局限性
我们现在只用了一个 Redis 实例来保存锁变量,如果这个 Redis 实例发生故障宕机了,那么锁变量就没有了。此时,客户端也无法进行锁操作了,这就会影响到业务的正常执行。所以,我们在实现分布式锁时,还需要保证锁的可靠性。
2.2 基于多个节点的实现(Redlock 算法)
2.2.1 Redlock 算法简介
当我们要实现高可靠的分布式锁时,就不能只依赖单个的命令操作了,我们需要按照一定的步骤和规则进行加解锁操作,否则,就可能会出现锁无法工作的情况。为了避免 Redis 实例故障而导致的锁无法工作的问题,Redis 的开发者 Antirez 提出了分布式锁算法 Redlock。
Redlock 算法的基本思路,是让客户端和多个独立的 Redis 实例依次请求加锁,如果客户端能够和半数以上的实例成功地完成加锁操作,那么我们就认为,客户端成功地获得分布式锁了,否则加锁失败。这样一来,即使有单个 Redis 实例发生故障,因为锁变量在其它实例上也有保存,所以,客户端仍然可以正常地进行锁操作,锁变量并不会丢失。
2.2.2 Redlock 算法执行步骤
Redlock 算法的实现需要有 N 个独立的 Redis 实例。接下来,我们可以分成 3 步来完成加锁操作:
第一步: 客户端获取当前时间。
第二步: 客户端按顺序依次向 N 个 Redis 实例执行加锁操作。这里的加锁操作和在单实例上执行的加锁操作一样,使用 SET 命令,带上 NX,EX/PX 选项,以及带上客户端的唯一标识。当然,如果某个 Redis 实例发生故障了,为了保证在这种情况下,Redlock 算法能够继续运行,我们需要给加锁操作设置一个超时时间。如果客户端在和一个 Redis 实例请求加锁时,一直到超时都没有成功,那么此时,客户端会和下一个 Redis 实例继续请求加锁。加锁操作的超时时间需要远远地小于锁的有效时间,一般也就是设置为几十毫秒。
第三步: 一旦客户端完成了和所有 Redis 实例的加锁操作,客户端就要计算整个加锁过程的总耗时。
2.2.3 加锁成功的判断条件
客户端只有在满足下面的这两个条件时,才能认为是加锁成功:
- 条件一: 客户端从超过半数(大于等于 N/2+1)的 Redis 实例上成功获取到了锁
- 条件二: 客户端获取锁的总耗时没有超过锁的有效时间
在满足了这两个条件后,我们需要重新计算这把锁的有效时间,计算的结果是锁的最初有效时间减去客户端为获取锁的总耗时。如果锁的有效时间已经来不及完成共享数据的操作了,我们可以提前释放锁,以免出现还没完成数据操作,锁就过期了的情况。
当然,如果客户端在和所有实例执行完加锁操作后,没能同时满足这两个条件,那么,客户端向所有 Redis 节点发起释放锁的操作。
2.2.4 释放锁操作
在 Redlock 算法中,释放锁的操作和在单实例上释放锁的操作一样,只要执行释放锁的 Lua 脚本就可以了。这样一来,只要 N 个 Redis 实例中的半数以上实例能正常工作,就能保证分布式锁的正常工作了。所以,在实际的业务应用中,如果你想要提升分布式锁的可靠性,就可以通过 Redlock 算法来实现。
2.2.5 Redlock 的部署建议
为了保证集群环境下分布式锁的可靠性,Redis 官方设计了一个分布式算法 Redlock(红锁),当然还可以通过 Redission 实现联锁 MultiLock(在多个 Redis 节点上同时加锁,必须全部成功才算获取锁,严格 AND 逻辑)。它是基于多个 Redis 节点实现的分布式锁,只要”多数 Redis 节点”还活着,客户端仍然可以认为锁是有效的。
Redis 主从复制模式中的数据是异步复制的,可能在主节点故障切换时导致锁丢失(故障的时候没有同步成功,注意锁本质上就是一个 string 数据),从而破坏互斥性,官方推荐是至少部署 5 个 Redis 节点,而且都是主节点,它们之间没有任何关系,都是一个个孤立的节点。
2.2.6 Redlock 的安全性问题
RedLock 在设计算法时依赖时钟和网络延迟等假设,并且缺少 fencing token,这可能导致在极端情况下互斥性被破坏。因此在理论上它不能完全保证分布式锁的安全性,对于要求强一致的场景应慎用。
2.3 解决红锁的安全性问题
2.3.1 问题场景
在实际环境中,网络延迟、进程暂停(如 GC 停顿)等情况难以避免。考虑以下场景:
- Client 1 获取锁后开始操作共享资源
- 由于 GC 停顿或网络延迟,Client 1 的锁过期
- Client 2 获取锁并开始操作共享资源
- Client 1 从停顿中恢复,继续操作共享资源
- 此时两个客户端同时操作共享资源,数据可能被损坏
具体流程:
- A 尝试向多个 Redis 节点获取锁
- 所有节点都返回成功,但客户端还没处理完请求
- 客户端 A 遇到长时间 GC 暂停
- 这段时间锁过期了
- 客户端 B 获得了锁并开始工作
- A 恢复后才处理完原来的结果,误以为仍持有锁
2.3.2 Fencing Token 机制
fencing token 的核心思想很简单:在获取锁的同时,获取一个单调递增的 token(令牌),每次访问共享资源时都必须出示这个 token。共享资源端会记录处理过的最大的 token 值,如果接收到一个更小的 token,请求就会被拒绝。这样就建立了一种序列机制,确保了操作的顺序性。
fencing token 通过为每次加锁生成递增令牌,并在资源层校验令牌顺序,从根本上防止旧锁操作,是严格分布式锁必须的机制。
2.3.3 看门狗机制的局限性
看门狗机制可以保证客户端在操作期间锁不会意外过期,从而避免死锁和短时间锁丢失。但它无法解决 RedLock 的核心安全问题:在极端网络延迟或客户端暂停情况下,仍可能出现两个客户端同时认为自己持锁。真正解决这个问题需要 fencing token,保证过期或旧锁的操作被拒绝。看门狗只能”努力不让锁过期”,但不能证明”我现在仍然合法持锁”。
看门狗机制只能减少锁过期的概率,但无法在客户端发生 STW GC、网络分区等情况下保证锁一定不会过期。一旦锁过期且被其他客户端获取,旧客户端仍可能继续操作共享资源,导致互斥性被破坏。
总结:
- TTL + 看门狗解决的是”锁不容易丢”
- fencing token 解决的是”锁丢了也不会出事”
2.4 看门狗机制
超时时间不好设置,不过可以通过看门狗机制来合理设置超时时间。看门狗机制是什么?看门狗 = 自动续期机制。只要持锁线程还活着,就不断给锁续命。不用人工猜超时时间,业务执行多久都安全,线程挂了,锁自然释放。
看门狗主要解决的是”锁超时问题”,和单节点/多节点无关。
2.5 ZooKeeper 的分布式锁方案
ZooKeeper 的分布式锁通常通过创建临时顺序节点实现。客户端在指定路径下创建临时顺序节点后,判断自己是否是序号最小的节点,如果不是则监听前一个节点。当持锁客户端释放锁或会话失效时,其临时节点被删除,后继节点收到通知并重新竞争锁。该方案依托 ZooKeeper 的强一致性协议,能够保证严格的互斥性和公平性。
Redis 锁 vs ZooKeeper 锁:
- Redis 锁靠”时间”,设置超时时间
- ZooKeeper 锁靠”共识”,先来后到
ZooKeeper 锁的工作流程:
1 | Client A → create lock-0001 → 获得锁 |
总结
Redis 并发控制主要通过原子操作和分布式锁两种方式实现:
- 原子操作:适用于简单的并发场景,通过单命令操作(如 INCR/DECR)或 Lua 脚本保证操作的原子性
- 分布式锁:适用于复杂的并发场景,可以基于单节点或多节点(Redlock)实现,需要注意安全性问题和超时处理
- 看门狗机制:解决锁超时问题,自动续期保证业务执行安全
- Fencing Token:解决极端情况下的安全性问题,保证操作的顺序性
- ZooKeeper 方案:提供更强的一致性保证,适合对一致性要求极高的场景