全面解析Redis分布式锁
在分布式系统中,当多个进程或服务需要互斥地访问共享资源时,就需要用到分布式锁。Redis因其高性能和丰富的数据结构,成为实现分布式锁的常用选择。然而,实现一个健壮的Redis分布式锁并非简单的 SETNX
命令那么简单,需要综合考虑锁的获取、释放、超时以及死锁等问题。
一、基础实现:SETNX 与存在的问题
最简单的想法是使用Redis的 SETNX
(SET if Not eXists)命令。如果Key不存在,则设置成功,表示获取锁。
1. 基础流程
# 1. 尝试获取锁(锁的Key为 "resource_lock")
SETNX lock_key 1
(integer) 1 # 返回1,获取成功# 2. 执行业务逻辑...# 3. 释放锁
DEL lock_key
2. 致命缺陷
这个方案有严重问题:
死锁风险:如果获取锁的客户端在释放锁之前崩溃,锁将永远无法被释放,导致其他客户端永远无法获取锁,形成死锁。
二、改进方案:设置过期时间
为了解决死锁问题,我们需要为锁设置一个过期时间(TTL),这样即使客户端崩溃,锁也会在超时后自动释放,避免死锁。
1. 使用 SETNX + EXPIRE(错误示范)
这是一个常见的错误做法:
SETNX lock_key 1
EXPIRE lock_key 10 # 设置10秒后过期
问题在于 SETNX
和 EXPIRE
是两条独立的Redis命令,不具备原子性。如果在执行完 SETNX
后、执行 EXPIRE
前客户端崩溃,过期时间将无法设置,依然会导致死锁。
2. 使用一条SET命令(正确基础)
Redis 2.6.12之后,SET
命令增加了扩展参数,可以原子性地完成设值和设置过期时间。
# 一条原子性命令:设置锁,并在10秒后自动过期
SET lock_key 1 EX 10 NX
NX
:等同于SETNX
,仅当Key不存在时设置成功。EX 10
:设置过期时间为10秒。
这解决了原子性问题,是构建分布式锁的基石。但此时还有一个核心问题待解决。
三、核心挑战:谁加的锁只能由谁释放
考虑以下场景:
客户端A获取了锁,设置的过期时间是10秒。
A的业务操作执行了15秒(可能因为GC停顿或网络延迟),锁在第10秒时因过期自动释放了。
客户端B在第11秒成功获取了锁。
客户端A在第15秒终于执行完毕,然后执行
DEL lock_key
,错误地释放了客户端B持有的锁。
解决方案:为锁绑定唯一标识符
每个客户端在获取锁时,需要设置一个唯一的值(如UUID、请求ID)。在释放锁时,先验证当前锁的值是否与自己设置的一致,只有一致时才允许删除。
完整流程如下:
# 1. 获取锁(value使用UUID保证全局唯一)
SET lock_key $unique_value EX 10 NX# 2. 执行业务逻辑...# 3. 释放锁:使用Lua脚本保证原子性
if redis.call("GET", KEYS[1]) == ARGV[1] thenreturn redis.call("DEL", KEYS[1])
elsereturn 0
end
为什么使用Lua脚本?
因为 GET
和 DEL
是两条命令。如果不用Lua脚本,在 GET
判断通过后、执行 DEL
前,锁可能恰好过期并被其他客户端获取,依然会导致误删。Lua脚本可以确保这两条命令的原子性执行。
四、生产级考量与Redlock算法
上述方案在单机Redis环境下基本可用,但在更严格的生产环境或Redis集群环境下,仍有风险:
1. 单点故障问题
如果使用单机Redis,一旦Redis节点宕机,所有锁将失效。即使采用主从复制,由于复制是异步的,在主节点宕机后,从节点可能还未同步到锁信息,导致锁状态丢失,出现多个客户端同时持有锁的情况。
2. Redlock算法
为了在分布式Redis环境下实现更安全的锁,Redis作者提出了 Redlock(Redis Distributed Lock) 算法。它基于多个独立的Redis主节点(非主从关系,通常是5个)。
算法流程如下:
客户端获取当前精确时间(T1)。
客户端依次向5个独立的Redis实例发送锁获取命令(
SET lock_key $unique_value EX $ttl NX
)。客户端计算获取锁所花费的总时间(当前时间T2 - T1)。只有当客户端从超过半数(即至少3个) 的节点上成功获取锁,且总耗时小于锁的过期时间(TTL) 时,才认为锁获取成功。
如果锁获取成功,锁的真正有效时间变为
TTL - (T2 - T1)
。如果锁获取失败(未达到半数或超时),客户端会向所有Redis实例发起释放锁的请求。
Redlock通过引入多节点和多数派机制,降低了单点故障的风险,但其实现复杂,且对系统时钟有依赖,在实践中也存在争议(如Martin Kleppmann的著名反驳)。对于绝大多数场景,基于单Redis实例或哨兵模式的锁已足够。
五、最佳实践总结
使用一条
SET ... NX EX
命令:原子性地获取锁并设置过期时间。设置唯一的锁值:释放锁时用于验证身份,避免误释放。
使用Lua脚本释放锁:保证检查锁值和删除操作的原子性。
设置合理的过期时间:过期时间应大于业务操作的平均耗时,并考虑重试机制。也可以使用“看门狗”(watch dog) 机制在客户端后台自动续期。
明确业务需求:评估是否真的需要Redlock级别的强一致性。对于大多数高可用场景,基于哨兵或集群的单个Redis实例锁已经足够。
一个生产可用的Redis分布式锁必须具备三个特性:
互斥性:任意时刻,只有一个客户端能持有锁。
防死锁:即使客户端崩溃,锁也能在超时后自动释放。
解铃还须系铃人:只能由加锁的客户端来解锁。