当前位置:首页 > 问答 > 正文

Redis里头那种读写锁,怎么保证操作原子性又能受控呢?

在Redis中实现一个读写锁,核心目标就是模拟我们在编程中常用的那种读写锁机制:允许多个读者同时读,但只允许一个写者写,且读写操作不能同时进行,由于Redis的命令是单线程原子执行的,但我们的业务逻辑通常涉及多个Redis命令,所以关键在于如何将多个命令组合成一个不可分割的原子操作,并利用Redis的特性来实现锁的控制(如获取、释放、超时)。

保证操作原子性:利用Lua脚本

原子性意味着一个操作要么全部成功,要么全部失败,不会停留在中间状态,在Redis中,单个命令(如SET key value NX)天生就是原子性的,但实现一个锁,往往需要多个步骤,

  1. 检查锁是否存在。
  2. 如果不存在,则设置锁并设置超时时间。

如果使用客户端顺序执行两个命令,在第一步和第二步之间,可能有其他客户端也检查到锁不存在并设置了锁,这就导致了竞态条件,多个客户端会同时认为自己获得了锁。

引用来源:Redis官方文档 明确指出,解决这个问题的标准方法是使用Lua脚本,Redis会单线程执行整个Lua脚本,在执行过程中不会被其他命令打断,这就保证了脚本内的多个Redis命令作为一个整体原子性地执行。

一个简单的写锁获取脚本可能是这样的(用伪代码表示逻辑):

local lockKey = KEYS[1] -- 锁的键名
local clientId = ARGV[1] -- 客户端唯一标识
local ttl = ARGV[2] -- 锁的超时时间
-- 原子性地检查并设置锁
if redis.call('SET', lockKey, clientId, 'NX', 'PX', ttl) then
    return 1 -- 表示获取锁成功
else
    return 0 -- 表示获取锁失败
end

这个脚本将“检查锁是否存在”和“设置锁”两个动作合并为一个原子操作,只有锁不存在(NX选项)时,设置才会成功,并且同时设置了超时时间(PX选项),这样就完美避免了非原子性操作带来的竞态问题,释放锁、读锁的获取和释放同样需要借助Lua脚本来保证原子性。

实现受控的读写锁:设计锁的数据结构与逻辑

Redis里头那种读写锁,怎么保证操作原子性又能受控呢?

原子性是基础,但要实现一个功能完备、受控的读写锁,还需要精心设计锁的数据结构和交互逻辑。

锁的标识与重入性(可选但重要) 为了防止误删其他客户端的锁,设置锁的值应该是一个唯一的客户端标识(如UUID),释放锁的脚本需要先检查当前锁的值是否与自己的标识匹配,匹配才删除,这提供了最基本的安全性。 更进一步,可以支持重入性,即同一个客户端可以多次获取同一个锁,这可以通过一个哈希表结构来实现,键为锁名,字段为客户端ID,值为重入次数,获取锁时次数加一,释放时次数减一,只有当次数为零时才真正删除锁键,这使得锁的使用更加灵活可控。

读写状态的管理 一个典型的读写锁需要区分读锁和写锁。

  • 写锁:是排他性的,可以使用一个键来表示,例如lock:resource_{id}_write,获取写锁的条件是当前没有任何读锁或写锁。
  • 读锁:是共享性的,不能简单地用一个键表示,因为需要记录读锁的数量,通常使用一个计数器,键名如lock:resource_{id}_read_count,获取读锁的条件是当前没有写锁。

引用来源:Martin Kleppmann的论文《How to do distributed locking》 以及相关的实践讨论中,常提到一种基于多个键和Lua脚本的读写锁设计,其核心逻辑如下:

Redis里头那种读写锁,怎么保证操作原子性又能受控呢?

  • 获取读锁
    • 原子性地(通过一个Lua脚本)检查写锁是否存在。
    • 如果写锁不存在,则增加读锁计数器。
    • 如果写锁已存在,则获取读锁失败。
  • 释放读锁

    原子性地将读锁计数器减一,如果计数器减到零,则删除该计数器键。

  • 获取写锁
    • 原子性地检查写锁是否存在,并且读锁计数器是否为零。
    • 如果两者都不存在,则设置写锁和自己的标识。
    • 否则,获取写锁失败。
  • 释放写锁

    原子性地检查写锁的值是否为自己的标识,如果是,则删除写锁。

锁的超时与续期 为了避免客户端崩溃导致锁永远无法释放,必须为锁设置一个过期时间(TTL),但这引入了新的问题:如果业务操作时间超过了锁的超时时间,锁会自动释放,可能导致数据不一致。 为了解决这个问题,需要一种“看门狗”机制,即,在获取锁成功后,由一个独立的线程或定时任务,在锁即将过期但业务还未完成时,定期(例如在过期时间的三分之一处)对锁进行续期(通过PEXPIRE命令),这要求锁的获取和续期必须是同一个客户端,这增加了锁控制的复杂性,但对于长耗时操作是必要的安全措施。

等待与重试机制 当锁获取失败时,客户端通常不会立即返回错误,而是会选择等待一段时间后重试,这可以通过在客户端代码中实现一个循环,配合一定的退避策略(如指数退避)来实现,以避免活锁和过度消耗资源,这种机制使锁的使用更具弹性和可控性。

Redis实现可控的读写锁是一个组合方案:

  • 原子性基石:完全依赖Lua脚本将多个检查、设置、修改操作捆绑成一个原子单元。
  • 控制手段:通过设计合理的数据结构(如唯一客户端ID、读锁计数器、写锁键)来精确表达读写锁的互斥与共享规则;通过超时与续期机制来平衡故障恢复和长任务需求;通过客户端的等待与重试逻辑来提升系统的健壮性。

这种实现方式虽然不如ZooKeeper或etcd等原生支持分布式协调的系统那样强大(例如它们通过临时顺序节点可以实现更公平的锁和更精确的通知),但凭借Redis的高性能和Lua脚本的原子性能力,它在许多要求高性能且允许在极端情况下存在少量冲突的场景下,是一个非常流行且有效的解决方案。