想象一下游泳馆的更衣室:几百个储物柜排成两排,每个柜子一把挂锁。你游泳时想把手机和衣物锁进柜子,但如果两个人同时盯上同一个空柜子,就会争抢。更麻烦的是,如果你游完泳忘了拿钥匙就走人,那把锁会永远锁着,后来的人就只能干瞪眼。更恐怖的是,如果有人粗心大意,打开别人的锁,把别人的东西当成自己的取走——全场都得乱套。 这就是分布式系统里的“抢锁”难题。在计算机世界里,多个服务器进程就像多个游泳的顾客,共享的数据库记录、缓存、文件就是那一个个“储物柜”。为了避免大家同时写入造成数据错乱,我们需要一种可靠机制:同一时刻只允许一个进程操作资源,用完必须归还,万一中途意外退出,锁也要自动解开。 Redis 分布式锁就是这一套“储物柜管理体系”的数字化实现。早期版本用一条简单的 SETNX 命令充当“挂锁”,后来发现挂锁不够安全,于是陆续加上过期时间、唯一标识、Lua 脚本校验、可重入计数器,最终演进到多节点共识的 Redlock 算法。这篇文章就用储物柜的比喻,把这条演进线讲透。

岗位关联

分布式锁是后端开发绕不开的硬核技能。掌握 Redis 锁的演进逻辑,能让你在生产环境中避免并发事故、正确设计数据一致性方案,这是从“会写 CRUD”到“能扛流量”的分水岭。

目录
  1. 可视化演示
  2. 锁的基础概念
  3. SETNX初代锁
  4. 超时与过期
  5. 防误删优化
  6. 可重入设计
  7. Redlock算法
  8. AI 应用场景
  9. Python 演示
  10. 常见误区
  11. 面试题

可视化演示

动态演示Redis分布式锁实现的核心机制,通过源头/管道/节点/终端四种模式展示关键概念与交互流程。

本节配有交互动效(进入视口后自动加载,约 3KB)。

Redis分布式锁实现:分布式锁 分布式锁的核心需求与基本原语 锁的安全释放与Lua脚本原子性 核心机制可视化 Redis分布式锁实现整体架构与关键组件

锁的基础概念

游泳馆的更衣室里,储物柜的管理员立了一条铁规矩:任何一个储物柜,同一时刻只允许一个人使用。这就是“互斥”——你锁上柜门的时候,别人不可能再往里面塞东西。计算机世界里,多个进程同时修改同一份数据时,也需要这样一条铁规矩,否则就会出现“两个人同时把衣服塞进同一个柜子”的混乱局面。

“互斥”在单机时代很容易实现:一个内存标志位、一把操作系统锁就够了。但在分布式系统中,进程跑在不同的机器上,没有共享内存,这时候就需要一个所有进程都能访问的“中间人”来裁决——Redis 就是这样的中间人。它维护一把“虚拟锁”:锁存在时,其他进程必须等待;锁不存在时,第一个来的人可以获取它。

用数学语言描述互斥,最核心的判断只有一个:同一时刻最多只有一个进程持有锁。

回到比喻里:互斥是储物柜的铁规矩,但光有规矩还不够。如果一个人拿了钥匙却猝不及防地消失了,柜门永远打不开——这就是死锁。所以真正可靠的分布式锁,既要保证互斥,还要保证“意外发生时锁能自动解开”,这也是后续所有演进的需求来源。

∀i≠j,Li(t)=1⇒Lj(t)=0\forall i \neq j, \quad L_i(t) = 1 \Rightarrow L_j(t) = 0
公式的意思是:在任意时刻 t,如果进程 i 持有锁(L_i(t)=1),那么任何其他进程 j 都必然不持有锁(L_j(t)=0)。这就是锁的“互斥保证”。
  1. 锁状态变量L_i(t) 表示进程 i 在时刻 t 的锁状态。值为 1 表示该进程持有锁,0 表示未持有。
    Li(t)∈{0,1}L_i(t) \in \{0, 1\}
  2. 持有锁判定当 L_i(t) = 1 时,说明进程 i 此刻拥有操作共享资源的资格。这是获取锁成功的标志。
    Li(t)=1L_i(t) = 1
  3. 互斥推导对于任意两个不同进程 i 和 j,i 持有锁的同一时刻,j 的锁状态必须为 0。这就是“同一时刻只允许一个持有者”的数学表达。
    ∀j≠i, Lj(t)=0\forall j \neq i, \ L_j(t) = 0

SETNX初代锁

最早的 Redis 分布式锁非常朴素:储物柜管理员手里有一个登记本,上面写着“当前被占用的柜号”。顾客来存东西时,管理员先翻一眼登记本——如果柜号没被登记,就把名字写上去,把钥匙交给顾客;如果已经被登记,就摇摇头说“换一个吧”。这个“翻一眼、写一笔”的动作,在 Redis 里对应一条天然原子的命令:SETNX(SET if Not eXists,不存在才设置)。

SETNX 的本质是“只有键不存在时才能写入”。这恰恰是“先查再占”的安全版:它把“检查柜子有没有被占”和“写登记本”两个动作合并成一步,中间不会插入其他顾客。完整语义可以写成下面的分段函数:

初代锁的用法很直接:抢锁就是 SETNX lock_key owner_id,释放锁就是 DEL lock_key。在游泳馆里,这相当于“登记本上写名字”对应挂锁,“划掉名字”对应开锁。可是问题很快暴露了:如果顾客拿着钥匙突然晕倒在泳池边,登记本上的名字永远不会被划掉,那把锁也永远打不开。SETNX 不是原子操作的一部分吗?确实,但原子性救不了“忘记释放”的人为失误——进程崩溃了,DEL 命令永远不会执行。这就是初代锁最经典也最致命的缺陷:没有过期时间,死锁风险极高。

SETNX(k,v)={1,k∉D0,k∈D\text{SETNX}(k, v) = \begin{cases} 1, & k \notin \mathcal{D} \\ 0, & k \in \mathcal{D} \end{cases}
公式的含义是:当键 k 在 Redis 数据库中不存在(k∉D)时,SETNX 写入键值对并返回 1,代表加锁成功;当键 k 已存在(k∈D)时,什么都不做并返回 0,代表加锁失败。
  1. 判空查询首先判断键 k 是否已经存在于数据库。这个判断与写入操作一起被封装在 SETNX 内部,不会被其他命令打断。
    k∉Dk \notin \mathcal{D}
  2. 加锁成功若键不存在,则写入键值对 (k, v),并返回 1。v 通常存当前进程的唯一标识(比如 UUID),用于后续校验。
    SETNX(k,v)=1\text{SETNX}(k, v) = 1
  3. 加锁失败若键已存在,说明锁已被其他进程持有,SETNX 不覆盖原值,直接返回 0。
    SETNX(k,v)=0\text{SETNX}(k, v) = 0

超时与过期

为了解决“人消失、锁还在”的问题,储物柜管理员想出一个新办法:每把锁都装一个倒计时器。顾客挂锁时设定一个时间,比如 10 分钟;10 分钟后不管人有没有回来,锁自动弹开。这样即使顾客晕倒、跑路、彻底失联,柜子也一定能被重新使用。这个“倒计时器”对应 Redis 键的 过期时间(TTL)。

在 Redis 里,加锁从“先 SETNX 再单独 EXPIRE”演变为一条命令完成:SET lock_key owner_id NX EX 10。其中 NX 保证“只有键不存在时才设置”,EX 10 保证“锁在 10 秒后自动过期”。为什么不能用两个独立的命令?因为“加锁”和“设过期”若分开执行,中间进程一旦崩溃,锁照样没有过期时间,死锁问题依旧。合并成一条原子命令,删除了这个致命的“空窗期”。

锁的剩余存活时间,就是一个简单的减法:

有了过期时间,死锁问题迎刃而解,但新的矛盾又浮出水面:TTL 设多长合适?设太短,业务还没执行完锁就自动弹开了,别人会闯进来抢资源;设太长,一旦进程崩溃,其他进程要等待很久才能拿到锁。更麻烦的是,锁可能到了“该释放”的时候,而持锁人业务还没做完——柜门“咔哒”一声弹开,另一个顾客立刻把东西塞了进去。那时候原来的顾客回来,迷迷糊糊地以为柜子还是自己的,伸手就去开锁——他会打开谁的门?

tleft=texp−tnowt_{\text{left}} = t_{\text{exp}} - t_{\text{now}}
其中 t_exp 是锁设定的过期时刻,t_now 是当前时刻。当 t_left ≤ 0 时,Redis 自动删除该键,锁被释放。
  1. 记录过期时刻加锁时把过期时刻设为当前时间加 TTL(存活时长)。比如当前是 12:00:00,TTL 是 10 秒,t_exp 就是 12:00:10。
    texp=tnow+TTLt_{\text{exp}} = t_{\text{now}} + TTL
  2. 计算剩余时间任意时刻用过期时刻减当前时刻,得到锁还剩余多少秒。这个值会被返回给客户端,用于判断锁是否即将失效。
    tleft=texp−tnowt_{\text{left}} = t_{\text{exp}} - t_{\text{now}}
  3. 到期自动释放当剩余时间小于等于 0 时,Redis 主动删除锁键,相当于倒计时归零、储物柜自动弹开。
    锁自动释放  ⟺  tleft≤0\text{锁自动释放} \iff t_{\text{left}} \leq 0

防误删优化

想象一个更具体的场景:顾客 A 锁了 5 分钟的柜子,结果超时锁自动弹开;顾客 B 正好过来,锁上了同一个柜子。这时 A 回来了,他伸手用钥匙一拧——柜门开了,里面是 B 的衣物!A 一脸懵地把 B 的东西取走了。问题出在哪?A 的钥匙(锁值)没有标识主人身份,任何一把“通用钥匙”都能打开这把锁。

在 Redis 锁里,这个问题的根源是:释放锁时直接 DEL lock_key,完全不检查这个锁是不是当前进程加的。解决方案是给每把锁配一把“专属钥匙”——加锁时存入一个唯一标识(通常是 UUID),释放前先取出锁的值与自己的标识对比,一致才允许删除:

但“先 GET 再 DEL”存在一个新的安全隐患:从 GET 到 DEL 之间,锁可能刚好过期,另一个进程抢到了锁,这时再去 DEL 就会把别人的锁误删。要根治,必须把“比较”和“删除”合并成一个不可拆分的原子操作。Redis 原生支持 Lua 脚本,脚本内的多条命令在单线程中连续执行,中间不会插入任何其他命令——这个概念相当于管理员把“验钥匙、开柜门”设计成一个机械联动装置:钥匙插进去的一瞬间,验证和开锁同步完成,中间没有停顿,别人插不进来。

有了这个防误删的升级,Redis 锁在“释放安全”上才算是完备了。但故事还没结束:A 在业务逻辑里写了一个递归函数,外层调用了加锁的方法,内层又调用了同一个加锁方法——同一个进程想重复获取同一把锁,却被自己挡在了门外,这就引出了下一个问题:锁能不能被同一个持有者多次获取?

DEL(k)  ⟺  V(k)=owner\text{DEL}(k) \iff V(k) = \text{owner}
公式表示:只有当键 k 当前存储的值 V(k) 等于本次操作的持有者标识 owner 时,才执行删除。否则说明锁已被别人持有,不能删。
  1. 读取锁值先执行 GET lock_key,拿到当前锁的持有者标识。
    V(k)=当前存储的owner值V(k) = \text{当前存储的owner值}
  2. 身份比较把这个值与本地保存的 owner 标识进行比对。相等说明锁确实是自己加的,不相等说明锁已被别人抢走。
    V(k)=?owner本地V(k) \stackrel{?}{=} \text{owner}_{\text{本地}}
  3. 条件删除只有相等时才执行 DEL。这一条是兜底规则,不满足条件时什么都别做。
    DEL(k)  ⟺  V(k)=owner本地\text{DEL}(k) \iff V(k) = \text{owner}_{\text{本地}}

可重入设计

游泳馆里还有一种尴尬的场景:顾客 A 锁好柜子后,忽然想起外套还在泳池边,于是拿了钥匙去取,回来发现钥匙落在储物柜里了——他不得不再找管理员“再开一次”。有些高级储物柜支持“同一把钥匙可以多次打开同一把锁”,只要第一次锁上后,钥匙的主人不换人,再开门、再锁上都不会抵触。计算机里的可重入锁就是这个意思:同一个线程可以多次获取同一把锁,而不会阻塞自己。

为什么需要可重入?因为业务代码常常层层嵌套:方法 A 加锁,A 内部调用方法 B,B 又加同一把锁;如果锁不可重入,B 就会被自己挡在外面,直接死锁。解决方案是给锁加一个计数器:第一次获取时计数器从 0 变 1;同一个线程再次获取时,计数器继续加一;每次释放时减一,只有减到 0 才真正删除锁键:

在 Redis 中实现可重入锁通常有两种方式:一是将计数器和 owner 存在一个 Hash 结构里,用 HINCRBY 做自增操作;二是在应用层用线程本地变量维护计数器,只有首次加锁和最后一次释放才真正操作 Redis。无论哪种方式,核心都是“计数器归零才释放”。可重入设计解决了“自己拦自己”的问题,但到目前为止,我们讨论的仍然是单节点 Redis 上的锁——如果这台 Redis 宕机了,整个储物柜体系就瘫痪了。要不要给储物柜做“多区域备份”?

C←C+1(获取锁)C←C−1(释放锁)C \leftarrow C + 1 \quad (\text{获取锁}) \qquad C \leftarrow C - 1 \quad (\text{释放锁})
DEL(k)  ⟺  C=0\text{DEL}(k) \iff C = 0
公式的含义是:获取锁时计数器加一,释放锁时计数器减一;只有当计数器归零时,才执行删除键的操作。这就像一个嵌套的储物柜使用记录:每进一层记一笔,每出一层销一笔,全部销完才算真正结束。
  1. 首次加锁进程第一次获取锁时,计数器从 0 变为 1。同时写入锁值和过期时间。
    C:0→1C: 0 \to 1
  2. 重入累加同一线程再次调用加锁方法时,发现当前锁的 owner 正是自己,于是计数器继续加一,不重复设置锁键。
    C←C+1C \leftarrow C + 1
  3. 释放递减每次释放锁时计数器减一。如果减到 0,说明所有嵌套层都已退出,此时才真正删除锁键。
    DEL(k)  ⟺  C=0\text{DEL}(k) \iff C = 0

Redlock算法

单节点的 Redis 锁有一个致命软肋:Redis 如果挂了,锁就全丢了;即使有主从架构,主节点故障时从节点尚未同步锁数据,也会导致“旧锁丢失、新锁产生”的竞态。就像你只有一间更衣室,屋顶漏水或者管理员请假,系统就崩溃。为了提升可用性,Redis 官方提出了 Redlock 算法——在多台独立部署的 Redis 实例上同时加锁,只有超过半数的实例都同意,才真正算拿到锁。

Redlock 的思路非常像“多个独立投票区”:假设有 5 间互不相通的更衣室(5 个 Redis 实例),顾客要存东西时,必须同时去 5 间更衣室挂锁,每间都挂锁成功打了勾。如果 5 间中有 3 间以上成功,那么即使某个更衣室的管理员撒谎或睡觉,剩下的柜子仍然能保证互斥。这个“过半数”的判定,在数学上就是多数派原则:

具体流程是:客户端先记录当前时间 t1,然后依次向 N 个 Redis 实例发送 SET key value NX EX ttl 命令(带很短的响应超时,比如 50ms),加完所有实例后记录 t2,Δt = t2 - t1。如果 N_ok ≥ ⌊N/2⌋+1 且 Δt < TTL,就把锁的有效时间定为 TTL - Δt。失败时,需要向所有实例发送释放锁命令——包括那些没加成功的实例,防止“部分成功”残留垃圾锁。

Redlock 用多数派换来了高可用,但它并非完美无瑕。它的主要争议点在于时钟漂移:如果某个实例的服务器时钟跳变,锁的过期时间计算就会出错。比如一台 Redis 的时钟突然往后拨了几分钟,它认为锁还有效,但实际早就过期了,另一个客户端就能趁机拿到锁。此外,Redlock 假设各实例之间完全独立,但在实际部署中,多个 Redis 可能运行在同一批物理机上,一旦宿主机宕机,多数派也会同时失效。因此 Redlock 适用于“需要更高可用性”的场景,但不能视为绝对安全的银弹。回到比喻中:多间更衣室确实让“管理员偷懒”的风险大大降低,但如果在同一座楼上,一场火灾就能烧掉全部更衣室——真实世界的工程抉择,永远是平衡的艺术。

Nok≥⌊N2⌋+1N_{\text{ok}} \geq \left\lfloor \frac{N}{2} \right\rfloor + 1
其中 N 是 Redis 实例总数,N_ok 是成功加锁的实例数。当成功数达到多数派门槛时,锁才被认为获取成功。例如 5 个实例需要至少 3 个成功。
  1. 计算多数门槛对 N 个实例,门槛值是 ⌊N/2⌋+1。N=5 时门槛是 3,N=3 时门槛是 2。
    ⌊N2⌋+1\left\lfloor \frac{N}{2} \right\rfloor + 1
  2. 校验总耗时客户端向所有实例依次发送加锁命令,记录整个过程消耗的时间 Δt。如果 Δt 超过了锁的 TTL,说明加锁过程太慢,此时即使多数派成功也不能算数。
    Δt<TTL\Delta t < TTL
  3. 计算锁的有效时长真正可用的锁时长必须扣除加锁消耗的时间,即 t_valid = TTL - Δt。否则锁还没用就已经过期了。
    tvalid=TTL−Δtt_{\text{valid}} = TTL - \Delta t

AI 应用场景

秒杀与库存扣减

阶段:并发控制阶段

解决的问题:电商秒杀时海量请求同时扣减库存,数据库行锁在极高并发下吞吐骤降,且多实例同时扣减会导致超卖。用 Redis 分布式锁把“扣库存”变成互斥操作,同一时刻只允许一个线程执行关键路径。

具体案例:某电商平台的大促秒杀活动中,后端服务有 20 个实例,所有实例共享同一个 Redis 锁队列。每次扣减库存前先获取 Redis 分布式锁,成功者执行数据库操作并释放锁,失败者直接返回“已抢光”。上线后超卖率降为 0,接口 QPS 峰值达 12000。

分布式定时任务防重

阶段:任务调度阶段

解决的问题:分布式部署的定时任务在每个节点都会触发同一任务,导致重复计算、重复发消息。用 Redis 锁保证同一时刻仅一个节点执行任务,其他节点检测到锁已存在便直接跳过。

具体案例:某 AI 数据平台每天凌晨对用户特征表执行全量更新,任务在 3 台机器上同时被调度器触发。通过 Redis 分布式锁实现“抢到锁的机器执行任务,抢不到的机器立即退出”,避免了重复计算造成的数据错乱,任务耗时从 2 小时降到 40 分钟。

AI 推理服务缓存重建

阶段:缓存更新阶段

解决的问题:AI 模型推理结果的缓存过期后,大量并发请求同时回源计算,造成缓存击穿和 GPU 算力浪费。用 Redis 分布式锁让只有一个请求去重建缓存,其余请求等待或返回旧值。

具体案例:某推荐系统对用户特征做模型推理,结果缓存 5 分钟过期。高峰期缓存失效瞬间有 3000 个并发请求同时触发重建,压垮推理服务。引入 Redis 锁后,仅一个请求获取锁执行重建,其余请求直接打上“缓存重建中”标记并快速返回旧值,缓存击穿问题彻底解决。

Python 演示

python65 行
import timeimport uuidimport redis  class SimpleLock:    """基础版 Redis 分布式锁:SETNX + EXPIRE + Lua 防误删"""    def __init__(self, client, key, ttl=10):        self.client = client        self.key = key        self.ttl = ttl        self.owner = str(uuid.uuid4())     def acquire(self):        # SET key value NX EX ttl:原子完成加锁与过期设置        return bool(self.client.set(            self.key, self.owner, nx=True, ex=self.ttl        ))     def release(self):        # Lua 脚本:先校验持有者,再删除,保证原子性        lua = """        if redis.call('get', KEYS[1]) == ARGV[1] then            return redis.call('del', KEYS[1])        else            return 0        end        """        ok = self.client.eval(lua, 1, self.key, self.owner)        return bool(ok)  class ReentrantLock(SimpleLock):    """可重入版:加计数器,同一线程可多次获取锁"""    def __init__(self, client, key, ttl=10):        super().__init__(client, key, ttl)        self.count = 0     def acquire(self):        cur = self.client.get(self.key)        if cur is None or cur.decode() == self.owner:            self.client.set(self.key, self.owner, ex=self.ttl)            self.count += 1            return True        return False     def release(self):        if self.count <= 0:            return False        self.count -= 1        if self.count == 0:            return super().release()        self.client.expire(self.key, self.ttl)        return True  if __name__ == '__main__':    client = redis.Redis(host='127.0.0.1', port=6379, db=0)    lock = ReentrantLock(client, 'order:lock', ttl=5)    if lock.acquire():        print('获取锁成功,执行临界区操作...')        if lock.acquire():            print('再次获取锁成功(可重入)')        print('释放锁:', lock.release())        print('释放锁:', lock.release())
目的:演示从“基础版分布式锁”(SETNX + Lua 防误删)到“可重入锁”(引入计数器)的完整实现,覆盖正文中获取、释放、防误删、可重入四大核心要点。
  1. return bool(self.client.set(self.key, self.owner, nx=True, ex=self.ttl))SET 命令的 NX 和 EX 选项把“加锁”和“设过期”合并成一步原子操作,避免死锁。
  2. ok = self.client.eval(lua, 1, self.key, self.owner)把 Lua 脚本整体发给 Redis 执行,Redis 单线程特性保证比较与删除之间不会有其他命令插入。
  3. if redis.call('get', KEYS[1]) == ARGV[1] thenLua 脚本中先读取锁值,和当前线程的 owner 标识比对,只有相等才继续删除。
  4. cur = self.client.get(self.key); if cur is None or cur.decode() == self.owner可重入判断:锁不存在或锁值属于自己时才允许加锁。
  5. self.count += 1可重入锁的计数器加一,记录同一线程获取锁的次数。
  6. self.client.expire(self.key, self.ttl)每次释放时刷新过期时间,防止业务未完成锁就自动过期。

常见误区

误区很多初学者认为「SETNX 获取锁」和「EXPIRE 设置过期」分成两步没关系,反正最终会设置上过期时间,锁不会死锁。

正解这两步之间存在时间窗口。如果 SETNX 执行成功后、EXPIRE 执行前进程崩溃,锁键没有过期时间,就会永久占用,死锁依旧发生。正确做法是用 SET key value NX EX ttl 一条命令原子完成,就像给储物柜挂锁的同时启动了倒计时器,中间没有任何空档。

为何常见:初学者习惯把操作拆成“先做 A 再做 B”,忽视了 Redis 命令之间可能插入崩溃或网络中断,这是分布式系统直觉还没建立时的典型错误。

误区掌握分布式锁后,很多人认为释放就是一行 `DEL lock_key`,简单粗暴,不需要多余的判断。

正解直接用 DEL 释放锁,可能误删别人的锁。典型场景:线程 A 的锁因业务超时自动过期,线程 B 抢到锁正在执行;这时 A 执行 DEL,把 B 的锁删了,另一个线程 C 又抢到锁——两个线程同时进入临界区,互斥被彻底破坏。正确做法是先校验锁值是否属于自己的 owner 标识,再执行删除,且校验与删除必须用 Lua 脚本包成原子操作。

为何常见:单机编程中“解锁”永远属于同一个持有者,不会出现“锁被偷换”的问题;分布式中锁的生命周期跨越多个进程,不校验就释放的后果被放大了。

误区既然业务可能执行很久,那把 TTL 设成 30 分钟甚至 1 小时,总不会在业务执行中途自动过期了吧?

正解TTL 越大,死锁恢复的代价越高。如果进程崩溃,其他进程要等整整 1 小时才能拿到锁。TTL 应该按业务的最长执行时间估算,并留少量余量;更成熟的做法是“看门狗”机制——后台线程在锁快过期时自动续期,保证长时间业务不被中断,同时崩溃时锁仍能较快释放。

为何常见:人们本能地怕“锁不够用”,却忽略了锁的另一面——过多保护同样会伤害系统可用性。分布式系统里每个参数都是权衡。

误区Redlock 用多数派思想解决单点故障,很多人认为它已经无懈可击,任意场景都可放心使用。

正解Redlock 仍有不少隐患。最典型的是时钟漂移——Redis 所在机器的系统时钟如果发生跳变,锁的过期时间会失真,可能让两个客户端同时持有锁。此外 Redlock 要求实例完全独立部署,如果多个实例在同一台物理机上,机器宕机会导致多数派同时失效。Redlock 只适合对可用性要求高于强一致的场景,绝不可以作为“绝对互斥”的保证。

为何常见:人们习惯把“高可用”误解为“绝对正确”。Redlock 解决的是单点故障的可用性问题,而不是分布式系统的强一致性问题,两者有本质区别。

误区实现可重入时,有人用一个布尔变量标记“是否已持有锁”,判断为 True 就允许重入。

正解布尔标记只解决了“能不能重入”的问题,却无法解决“嵌套层数”的问题。如果外层加锁、内层加锁、随后内层释放,布尔标记就会被置为 False——外层结束时想释放锁,却发现标记已经没了,无法正确释放。必须用计数器记录层数:获取加一、释放减一,归零才真正释放。这才是可重入锁的正确语义。

为何常见:“有 or 没有”的二元直觉让初学者忽略“嵌套次数”这一维度,这正是可重入设计和普通互斥设计的本质差异。

面试题

Q1. SETNX 命令在键不存在时会怎样?

单选题 · easy

  • A.返回 1 并创建新键← 正确答案
  • B.返回 0 并创建新键
  • C.返回 1 但拒绝写入
  • D.抛出键已存在的异常

答案:A。SETNX 是 "SET if Not eXists" 的缩写——键不存在时写入并返回 1,键已存在时不执行任何操作只返回 0。这就像储物柜管理员看到登记本上没有该柜号就写下名字,已经有时就不动。B 是键已存在的情况,C、D 不符合命令的真实语义。

Q2. 使用 SETNX 获取锁后,为什么必须设置过期时间?

单选题 · easy

  • A.防止锁被误删
  • B.防止死锁发生← 正确答案
  • C.让锁能更快释放
  • D.减少 Redis 内存占用

答案:B。如果锁没有过期时间,持有锁的进程崩溃后锁键将永远存在,其他进程永远无法获取锁,形成死锁。过期时间相当于给储物柜装了自动弹锁的倒计时器,即使主人消失也能自动恢复。A 是防误删解决的问题,C、D 不是关键原因。

Q3. 释放 Redis 锁时,为什么不能直接执行 DEL?

单选题 · mid

  • A.直接 DEL 会阻塞 Redis
  • B.可能误删别人的锁← 正确答案
  • C.DEL 无法删除带过期的键
  • D.直接 DEL 会触发语法错误

答案:B。锁到期后可能已被别的线程重新获取,此时直接 DEL 会把别人的锁删掉,破坏互斥。正确做法是先用唯一标识校验锁的归属,再通过 Lua 脚本原子地执行删除。比喻来说,必须确认柜门上的锁是自己那把才能打开,否则可能取出别人的东西。

Q4. 防误删操作中 Lua 脚本的关键作用是什么?

单选题 · mid

  • A.加快键的删除速度
  • B.保证判断和删除的原子性← 正确答案
  • C.让多个 Redis 节点自动同步
  • D.自动刷新锁的过期时间

答案:B。Redis 单线程执行 Lua 脚本,脚本内所有命令在执行期间不会被其他命令插入,因此“读锁值→比较→删除”三步被合并成一个原子操作,消除了判断与删除之间的竞态窗口。这不是速度问题、同步问题或续期问题,而是原子性的问题。

Q5. 可重入锁计数器的正确行为是?

单选题 · mid

  • A.获取加一,释放减一← 正确答案
  • B.获取减一,释放加一
  • C.获取设为 1,释放设为 0
  • D.获取清零,释放加一

答案:A。可重入锁用计数器记录嵌套层数:每次获取加一,每次释放减一,归零时才真正删除锁键。这对应“嵌套的储物柜使用记录”——进一层记一笔,出一层销一笔,全部销完才算结束。B、D 方向反了,C 的布尔化处理无法支持多层嵌套。

Q6. SETNX 与 EXPIRE 分两步执行的隐患是?

单选题 · mid

  • A.SETNX 会覆盖已有键
  • B.两步之间存在死锁窗口← 正确答案
  • C.EXPIRE 会让锁立即失效
  • D.SETNX 执行后无法再设过期

答案:B。SETNX 成功后、EXPIRE 执行前,如果进程崩溃,锁键将没有任何过期时间,永远无法释放,形成死锁。正确做法是 SET key value NX EX ttl 一条命令原子完成。这就好比挂锁和设定倒计时必须同时进行,不能“先挂锁再装定时器”。

Q7. Redlock 算法有效条件除了多数派成功,还需要什么?

单选题 · hard

  • A.所有实例都返回成功
  • B.加锁总耗时小于 TTL← 正确答案
  • C.锁值必须为随机整数
  • D.至少三个实例部署

答案:B。Redlock 除了要求超过半数的实例成功加锁,还要求整个加锁过程的耗时 Δt 必须小于锁的 TTL,否则锁可能在真正持有之前就已过期。有效持有时间应扣除加锁耗时,即 TTL - Δt。A 过于严苛,C 不是必要条件,D 是部署建议而非算法条件。

Q8. 仅用 SETNX 实现分布式锁存在哪些缺陷?

多选题 · mid

  • A.无过期时间可能死锁← 正确答案
  • B.无法区分锁的持有者← 正确答案
  • C.加锁操作不是原子的
  • D.释放锁可能误删他人锁← 正确答案

答案:ABD。SETNX 命令本身具备原子性,所以 C 错误。但仅用 SETNX 的锁确实存在三个问题:不设过期时间会死锁(A);锁值没有唯一标识,无法判断锁归属(B);释放时直接 DEL 可能误删他人锁(D)。这些缺陷催生了后续的 EXPIRE、Lua 脚本等演进。

Q9. 可重入锁的实现需要满足哪些条件?

多选题 · mid

  • A.同一线程可多次加锁← 正确答案
  • B.不同线程共享计数器
  • C.每次释放计数器减一← 正确答案
  • D.计数器归零才删除键← 正确答案

答案:ACD。可重入锁的核心是同一线程可以重复获取锁(A);释放时计数器递减(C);只有归零才真正删除锁键(D)。注意计数器是按持有者的身份隔离的,每个线程维护自己的计数,不同线程不共享(B 错误)。

Q10. 关于 Redlock 算法的说法哪些是正确的?

多选题 · hard

  • A.需要在多个独立实例上加锁← 正确答案
  • B.成功需要超过半数的锁← 正确答案
  • C.完全不受时钟漂移影响
  • D.锁的有效期需扣除耗时← 正确答案

答案:ABD。Redlock 在多个独立 Redis 实例上逐一加锁(A),成功数必须超过实例总数的一半(B),锁的有效时间等于 TTL 减去加锁总耗时(D)。Redlock 并非对时钟漂移免疫——时钟跳变可能导致锁过期时间失真,因此 C 错误。