摘要:Redis 是互联网后端中使用最广泛的中间件之一,除了作为缓存和消息队列,它还被大量用于实现分布式锁。本文从分布式锁需要解决的核心问题出发,系统讲解 Redis 实现分布式锁的常见方案、加锁与解锁的正确姿势、锁超时与自动续期、Redisson 看门狗机制、原子性 Lua 脚本、可重入与公平锁实现,以及 Redis 集群、主从切换、网络分区等场景下可能出现的极端问题。文章最后结合 RedLock、红锁争议和实际生产案例,给出可落地的选型建议与避坑指南。
1. 为什么需要分布式锁
在单机应用里,多个线程并发修改同一个资源时,通常会使用 JVM 内置的同步机制来保证数据一致性。例如在 Java 中可以使用 synchronized 关键字或 ReentrantLock,它们基于本地内存的监视器对象或 AQS 状态实现互斥,能够很好地在同一个进程内协调线程执行顺序。
但随着业务规模扩大,服务通常会被部署成多个实例。订单服务、库存服务可能同时运行在几十台甚至上百台机器上,这些实例各自拥有独立的 JVM、独立的内存和独立的锁对象。此时,synchronized 和 ReentrantLock 只能保证单机内部的互斥,无法防止不同机器上的线程同时进入临界区。
以一个经典的超卖场景为例:假设库存表中某件商品剩余 1 件,两个用户同时发起下单请求。如果两个请求分别落在两台应用服务器上,两台机器都会读取到库存为 1,都判断可以继续下单,于是都执行扣减操作,最终可能出现一个订单被创建两次、库存变成负数的情况。为了让不同进程之间也能协调访问共享资源,就必须引入分布式锁。
分布式锁需要同时满足几个基本条件:在任意时刻只能有一个客户端持有锁;加锁和解锁必须是同一个客户端;锁具备失效机制,避免客户端崩溃后锁永远无法释放;加锁和解锁过程要具备原子性。Redis 凭借超高的读写性能、丰富的数据结构和成熟的客户端生态,成为实现分布式锁最常用的组件之一。
2. Redis 实现分布式锁的核心思路
Redis 中实现互斥最直接的方式,是利用 SET 命令的 NX 与 EX 参数。SET 命令原本用于设置一个字符串键值,而 NX 表示只在键不存在时才设置成功,这正好可以用来表达“抢占”的动作。
如果多个客户端同时执行 SET key value NX,Redis 的单线程命令处理机制保证最终只有一个客户端能够成功。抢锁成功的客户端获得返回值 OK,其他客户端得到 nil,从而天然形成互斥。锁本身用一个 Redis key 表示,value 通常写入当前客户端的唯一标识,比如 UUID 加线程 ID,用来在解锁时判断这把锁是否仍然属于自己。
只使用 NX 还不够。假设持有锁的客户端突然宕机、进程被杀或网络长时间中断,如果没有过期时间,这个 key 会一直存在,其他客户端永远无法继续获取锁,整个业务就可能被卡死。因此加锁时还需要设置 EX 或 PX 参数,让锁在一段时间后自动过期。SET key value NX EX 30 表示抢占一个 30 秒后自动失效的锁。
把 NX 和 EX 合在一次 SET 命令里非常重要。上下文中如果先执行 SETNX,再执行 EXPIRE,一旦两个命令之间客户端崩溃,就会出现一个永远不会过期的死锁。Redis 2.6.12 之后支持在 SET 中同时携带 NX 和 EX 参数,从根本上规避了这个原子性问题。
3. 简单版分布式锁的加锁与解锁
一个最基础的自研分布式锁,代码往往由三部分组成:生成唯一标识、尝试加锁、安全解锁。以 Java 和 Jedis 客户端为例,加锁逻辑可以写成下面这样。
import redis.clients.jedis.Jedis; import redis.clients.jedis.params.SetParams; import java.util.UUID; public class SimpleRedisLock { private final Jedis jedis; private final String lockKey; public SimpleRedisLock(Jedis jedis, String lockKey) { this.jedis = jedis; this.lockKey = lockKey; } /** 尝试加锁,返回当前客户端的唯一标识。 加锁失败时返回 null。 */ public String tryLock(long expireSeconds) { String requestId = UUID.randomUUID().toString() + ":" + Thread.currentThread().getId(); SetParams params = SetParams.setParams().nx().ex(expireSeconds); String result = jedis.set(lockKey, requestId, params); return "OK".equals(result) ? requestId : null; } /** 只有确认锁仍属于自己时才删除。 */ public void unlock(String requestId) { String currentValue = jedis.get(lockKey); if (requestId.equals(currentValue)) { jedis.del(lockKey); } } }这段代码表达了分布式锁最核心的两个思想。第一,value 必须唯一。不同客户端、不同线程在竞争同一把锁时,请求标识不能重复,否则解锁时无法区分谁是当前持有者。第二,解锁不能简单地直接删除 key。如果客户端 A 的锁已经过期,客户端 B 拿到了新锁,而 A 的业务逻辑此时才执行到解锁步骤,直接删除就会把 B 的锁误删。
上面示例中先 get 再 del 的方式已经比无脑删除安全,但仍然不够严谨。get 和 del 是两个独立命令,多线程环境下可能发生如下交错:A 执行 get 确认 value 是自己的,尚未执行 del 时锁恰好过期;B 成功拿到新锁;A 继续执行 del,把 B 刚建立的锁删除。要彻底避免这种误删,必须让“比较”和“删除”成为原子操作。
4. 用 Lua 脚本保证解锁原子性
Redis 会以单条命令的方式整体执行 Lua 脚本,脚本执行期间其他命令无法插入,因此可以把比较 value 与删除 key 两个动作放进同一个脚本里,使其具备原子性。常见的解锁脚本如下。
if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end这段脚本先读取锁对应的 value,如果与客户端传入的唯一标识一致,说明锁仍然由当前客户端持有,此时才执行删除;如果不一致,说明锁可能已经过期并被其他客户端抢占,此时不应删除。
在 Java 端的调用方式可以封装如下。需要注意的是,Lua 脚本中的 KEYS 与 ARGV 要严格区分,key 相关的参数通过 KEYS 传入,普通字符串通过 ARGV 传入,如果错误地混用,在 Redis Cluster 场景下可能导致脚本被路由到错误的节点。
import redis.clients.jedis.Jedis; import java.util.Collections; public class LuaUnlockExample { private static final String UNLOCK_SCRIPT = "if redis.call('get', KEYS[1]) == ARGV[1] then " + "return redis.call('del', KEYS[1]) " + "else return 0 end"; public static void unlock(Jedis jedis, String lockKey, String requestId) { jedis.eval(UNLOCK_SCRIPT, Collections.singletonList(lockKey), Collections.singletonList(requestId)); } }除了解锁,很多生产实现也会把加锁过程封装成 Lua 脚本。这样做的好处是可以在脚本中同时完成 key 是否存在、value 是否属于当前线程、是否延长过期时间等多个判断,从而支持可重入锁。脚本被 Redis 原子执行,不需要在客户端侧加本地锁来保护多条命令的时序。
5. 锁过期时间设置与业务耗时的矛盾
基础版分布式锁面临一个非常现实的问题:锁的过期时间应该设置为多少。设置得过长,一旦客户端异常退出,其他客户端要等很久才能接管;设置得过短,业务还没有执行完,锁就已经自动释放,第二个客户端会提前进入临界区。
典型的危险场景是:客户端 A 获取了一把 30 秒的锁,执行数据库写入、调用第三方接口、处理批量数据时发生了慢查询或网络抖动,实际耗时超过了 30 秒。30 秒期满后 Redis 删除了这把锁,客户端 B 立即抢锁成功并开始处理同一批资源。此时 A 和 B 同时执行,互斥被彻底破坏。
因此,单纯使用固定过期时间只适合执行时间非常稳定、耗时远小于过期时间的场景。对于执行时间不可控的业务,业界通常采用两种改进思路:一是客户端开启一个后台线程定时续期,只要主逻辑仍在执行,就不断把过期时间向后延长;二是把过期时间设置得足够宽松,并接受个别情况下锁被提前释放的风险。
第一种思路正是 Redisson 看门狗机制的核心。它默认在抢锁成功后启动一个定时任务,每隔一定时间检查业务线程是否还持有锁,如果仍然持有,就把过期时间重置为初始值。当业务线程主动解锁或异常退出时,续期任务也随之取消。
6. Redisson:生产级的 Redis 分布式锁
Redisson 是一个基于 Redis 的 Java 内存数据网格框架,它对 Reactor-Netty 之上的 Redis 客户端做了大量封装,提供了分布式锁、信号量、闭锁、原子长整型等丰富的分布式对象。与直接拼 Redis 命令相比,Redisson 的分布式锁实现更加完善,也更贴近生产环境要求。
Redisson 的 RLock 实现了标准的 java.util.concurrent.locks.Lock 接口,因此可以像使用 ReentrantLock 一样使用。一个最简单的用法如下。
import org.redisson.api.RLock; import org.redisson.api.RedissonClient; import java.util.concurrent.TimeUnit; public class RedissonLockExample { private final RedissonClient redissonClient; public RedissonLockExample(RedissonClient redissonClient) { this.redissonClient = redissonClient; } public void executeWithLock(String lockName) { RLock lock = redissonClient.getLock(lockName); try { // 尝试加锁,最多等待 5 秒,锁自动释放时间由看门狗管理 boolean locked = lock.tryLock(5, TimeUnit.SECONDS); if (!locked) { System.out.println("获取锁失败"); return; } System.out.println("获取锁成功,开始执行业务"); // 执行业务逻辑 } catch (InterruptedException e) { Thread.currentThread().interrupt(); } finally { // 只有当前线程持有锁才释放,避免误释放 if (lock.isHeldByCurrentThread()) { lock.unlock(); } } } }Redisson 的可重入锁底层使用 Hash 数据结构来保存重入次数,而不再使用简单字符串。Hash 结构的 key 是 Redisson 客户端 ID 加线程 ID,value 是重入次数。加锁时通过 Lua 脚本判断:如果锁不存在,则创建 Hash 并记录持有者,同时设置过期时间;如果锁已存在且持有者就是当前线程,则把重入次数加一;如果是其他线程持有,则返回剩余的锁过期时间,让客户端继续等待或直接失败。
这种设计带来了两个直接好处。第一,锁支持可重入。同一个线程在业务方法嵌套调用中反复加锁不会死锁,只需要在释放时执行相同次数的解锁。第二,解锁时能够精确判断锁是否属于当前线程,既不会误删其他线程的锁,也能正确处理多次重入时每次解锁只减一次计数的情况。
7. Redisson 看门狗续期机制详解
如果使用不带 leaseTime 参数的 tryLock 或 lock 方法,Redisson 会启用看门狗机制。默认情况下,锁的初始过期时间为 30 秒,Redisson 内部维护一个定时任务,每隔 10 秒就检查一次锁是否仍然由当前线程持有,如果仍然持有,就把过期时间重新设置为 30 秒。
看门狗续期依赖 Lua 脚本完成,脚本判断 Hash 中的持有者标识与当前线程标识一致,再通过 PEXPIRE 延长有效期。由于续期动作在服务端原子执行,不会因为并发访问而破坏锁状态。
如果业务逻辑意外卡住,但主线程仍然存活,而没有主动释放锁,看门狗会持续续期。对于耗时较长但最终能完成的业务,这是一种保护;但如果业务进入无法自行恢复的阻塞状态,锁可能被长期占用。因此,业务代码中仍然应当通过超时、熔断、限流等手段避免无限等待。
当调用方显式传入 leaseTime 时,Redisson 不会启动看门狗续期,而是在 leaseTime 到期后由 Redis 自动释放锁。两种方式各有适用场景:业务耗时基本可预估且短时任务适合显式设置 leaseTime,以免后台续期线程增加额外开销;无法预估耗时的任务更适合依赖看门狗,但必须在 finally 中可靠解锁。
8. 可重入锁的实现原理
可重入锁指的是同一个线程已经持有锁的情况下,再次请求同一把锁时可以直接成功,而不会被自己阻塞。对于自研简单版锁,如果使用 NX 命令,同一个线程重复加锁会失败,因此简单版锁只能用于不会嵌套调用的场景。
要实现可重入,必须区分“锁的持有者是谁”和“这个持有者加了几次锁”。通常有两种实现方式,一种是使用 Redis Hash,field 存放客户端和线程的唯一标识,value 存放重入次数;另一种是使用字符串 value,并在 value 中同时保存持有者标识和重入次数。
使用 Hash 是 Redisson 采用的方案。加锁脚本需要处理三种情况:锁不存在时创建 Hash、记录重入次数为 1 并设置过期时间;锁存在且持有者是当前线程时,将重入次数加 1 并刷新过期时间;锁存在但持有者是其他线程时,返回剩余存活时间表示暂时无法获取。
解锁脚本与之对应:如果持有者不是当前线程,直接返回,代表无权解锁;如果持有者是当前线程且重入次数大于 1,则把次数减 1 并刷新过期时间;如果重入次数等于 1,删除该 Hash,释放锁。这样加锁与解锁的次数完全对齐,锁的生命周期也保持正确。
8.1 可重入锁的 Lua 加锁脚本示例
下面给出一个基于 Hash 结构的简化加锁脚本,用于说明重入逻辑。实际生产代码还要处理异常、超时重试和续期,但核心判断与此相同。
local key = KEYS[1] local requestId = ARGV[1] local expireMs = tonumber(ARGV[2]) if redis.call('exists', key) == 0 then redis.call('hset', key, requestId, 1) redis.call('pexpire', key, expireMs) return 1 end if redis.call('hexists', key, requestId) == 1 then redis.call('hincrby', key, requestId, 1) redis.call('pexpire', key, expireMs) return 1 end return 0这段脚本首先检查锁是否存在。不存在说明当前锁空闲,可以立即创建并抢占。锁存在时,再检查 Hash 中的 field 是否为当前客户端标识,如果是,说明发生了重入,只需要把重入次数加一。如果锁被其他客户端持有,则直接加锁失败。
8.2 可重入锁的 Lua 解锁脚本示例
local key = KEYS[1] local requestId = ARGV[1] if redis.call('hexists', key, requestId) == 0 then return 0 end local count = redis.call('hincrby', key, requestId, -1) if count > 0 then redis.call('pexpire', key, tonumber(ARGV[2])) return 1 else redis.call('del', key) return 1 end脚本在重入次数大于 1 时只做递减,不删除锁,保证外层业务还在持锁时内层方法返回不会误释放。只有当计数归零后才真正删除整个 key。这样无论业务方法嵌套多少层,只要加解锁成对出现,锁的最终状态都是正确的。
9. 公平锁的实现思路
普通分布式锁在竞争激烈时,客户端通常采用循环重试的方式,谁能抢到锁并不取决于等待时间,而取决于请求到达 Redis 的时机。这可能导致某些客户端长期抢不到锁,出现类似线程饥饿的现象。
公平锁的目标是让客户端按照请求顺序依次获得锁。实现思路通常是把所有等待者放进一个队列里排队,再配合计数器分配先后次序,最典型的是基于 Redis List 或 ZSet 构建等待队列。
以 Redisson 的公平锁为例,它在 Hash 锁之外维护了两个辅助结构:一个是排序集合,用于记录所有正在等待锁的线程及其优先级;另一个是普通队列,用于保存超时后需要被移除的线程信息。加锁时先检查锁是否被占用,如果被占用,就把当前线程信息加入排序集合排队。之后客户端定期检查排序集合,只有轮到自己而且锁仍然可用时,才会执行真正的加锁操作;如果等待超时或被取消,则从排序集合和超时队列中删除相应记录。
10. Redis 主从切换与锁丢失风险
前面介绍的 SET NX EX、Lua 原子解锁和 Redisson 看门狗,都默认锁数据被可靠保存。但当 Redis 采用主从复制或哨兵架构时,主节点与从节点之间的同步是异步的,锁在主节点上写入成功后,可能还未来得及同步到从节点,主节点就发生宕机。
典型时序如下:客户端 A 在主节点加锁成功;主节点在把这条写入同步给从节点之前就崩溃;哨兵检测到主节点不可用后,把一个尚未收到锁数据的从节点提升为新主节点;此时新主节点上并不存在 A 的锁,客户端 B 再次加锁就会成功。于是 A 和 B 都认为自己持有锁,互斥被破坏。
网络分区也可能造成类似问题。当客户端与主节点之间发生网络隔离,或者主节点与集群其余节点失联时,集群可能会触发新的主节点选举。旧的孤立主节点仍然认为自己是主节点,并在其上继续接受写请求,而新主节点上也可能存在同一把锁,最终形成脑裂和双重持锁。
因此,单实例 Redis 或简单主从架构下的分布式锁,只适合允许极低概率安全风险、对一致性要求不是绝对严格的业务。对于强一致诉求,需要结合多节点协议或业务层兜底方案。
11. RedLock 算法与红锁争议
为了降低主从切换导致锁丢失的风险,Redis 作者提出了 RedLock 多节点分布式锁算法。其核心思想是不依赖单个 Redis 实例,而是向多个相互独立的 Redis 主节点分别加锁,用多数派成功来换取更高的可用性。
RedLock 的典型步骤如下:部署 5 个不相关、不采用主从复制的 Redis 节点;客户端使用相同的 key 和唯一 value,以较短的有效时间依次向每个节点发起加锁;只有当超过半数节点加锁成功,并且加锁总耗时小于锁的有效时间时,才算加锁成功;加锁成功后,锁的真实有效时间为原始有效时间减去加锁总耗时;解锁时则向所有节点发送同一段 Lua 解锁脚本。
下面是一个简化版的多数派判断示例。
// 伪代码:向多个独立 Redis 节点尝试加锁 int successCount = 0; long start = System.currentTimeMillis(); for (RedisNode node : redisNodes) { try { if (node.tryLock(lockKey, requestId, ttlMillis)) { successCount++; } } catch (Exception e) { // 单节点异常不阻塞整体流程 } } long elapsed = System.currentTimeMillis() - start; if (successCount >= redisNodes.size() / 2 + 1 && elapsed < ttlMillis) { long finalTtl = ttlMillis - elapsed; System.out.println("RedLock 加锁成功,剩余有效时间:" + finalTtl + "ms"); } else { // 加锁失败,需要向所有节点发起解锁 unlockAll(redisNodes, lockKey, requestId); throw new IllegalStateException("RedLock 加锁失败"); }RedLock 提出后也引发了著名的红锁争议。分布式系统专家 Martin Kleppmann 认为,RedLock 依赖客户端时钟和租约失效时间,在进程长时间 GC 停顿、虚拟机暂停、时钟跳变等情况下,锁的有效时间判断并不可靠,甚至可能被提前释放或被错误延长,因此不能把它当作绝对正确的安全机制。Antirez 则回应称,RedLock 的目标是提供比单节点更好的安全性保证,并不承诺百分之百可靠。
综合来看,RedLock 的运维成本和请求延迟都明显更高,且仍然存在边界条件。大多数互联网业务并不需要引入 RedLock,真正需要极高隔离性的场景,也应优先通过数据库唯一约束、幂等控制、乐观锁或分布式共识系统等方式配合解决。
12. 生产环境选型建议与避坑指南
在日常业务中使用 Redis 分布式锁,应从简单可靠出发,遵循以下实践原则。
- 优先使用成熟客户端:Redisson 已内置原子加解锁、可重入、看门狗续期、公平锁等能力,比自研简单锁更完善;如必须轻量自研,至少要做到原子加锁、唯一 value、Lua 原子解锁。
- 锁的粒度要细:不同业务使用不同的 key,必要时采用“业务前缀 + 资源 ID”命名,避免多业务共用一把锁造成互相阻塞。
- 加锁必须设置超时:等待锁时应设置最大等待时间和快速失败策略,避免线程无限重试耗尽资源。
- 解锁必须放在 finally 中:无论业务成功还是异常,都要确保释放锁,并且只释放当前线程持有的锁,防止误删其他客户端的锁。
- 合理管理锁有效期:短任务显式设置 leaseTime;耗时不可预估的任务使用看门狗续期,同时在业务内部通过超时、熔断、限流避免长时间持锁。
- 重要操作使用业务层兜底:对于库存扣减、订单创建、金额转账等关键数据,不能只依赖分布式锁,还要配合数据库唯一约束、乐观锁、幂等设计,确保最终一致。
- 建立锁监控:关注锁的竞争次数、等待时间、持锁时间、加锁失败率等指标,及时发现异常热点和长尾请求。
13. 总结
本文从单机锁的局限性出发,依次梳理了 Redis 分布式锁的演进路径:从 SET NX EX 原子加锁,到 Lua 脚本保证解锁原子性,再到处理锁过期与业务耗时的矛盾,最后引入 Redisson 的可重入锁、看门狗续期和公平锁能力。
正确使用 Redis 分布式锁的关键可以概括为三点:加锁需要原子命令、唯一标识和合理过期时间;解锁需要原子地“比较再删除”;生产落地则要优先借助成熟框架,并通过监控、兜底和精细的锁粒度降低风险。对于主从切换下的锁丢失问题,应理解单实例锁的安全边界;对于 RedLock,则应结合业务一致性要求、性能与运维成本理性选择。
分布式锁并非解决并发问题的银弹。真正健壮的系统,既要合理使用分布式锁控制互斥,也要在数据层设计唯一约束、幂等和补偿机制,让一致性在多层防护下得到保障。