1. 从单机锁到分布式锁:为什么我们需要Redis Set NX
在单机应用的时代,处理并发资源竞争,我们通常会想到Java里的synchronized关键字或者ReentrantLock。这些锁机制在单个JVM进程内运行得非常好,因为它们依赖于进程内的内存状态来协调线程。但是,当你的应用从一台服务器扩展到两台、十台,甚至上百台服务器时,情况就完全变了。想象一下,一个商品秒杀活动,库存扣减的逻辑部署在十台服务器上,如果还用synchronized,那它只能锁住自己这台服务器上的线程,其他九台服务器的请求依然会一拥而上,导致库存超卖。这就是典型的分布式环境下的并发问题。
分布式锁就是为了解决这个问题而生的。它的核心目标是在分布式系统这个“无共享内存”的多个进程或主机之间,提供一种互斥机制,确保在同一时间,只有一个客户端能对某个共享资源进行操作。实现分布式锁的方案有很多,比如基于数据库的唯一索引、基于ZooKeeper的临时有序节点,而基于Redis的实现,因其高性能和相对简单的模型,成为了最流行的选择之一。
在Redis的众多命令中,SET命令配合NX和PX参数,是实现分布式锁最基础、最经典的方式。SET key value NX PX 30000这一行简单的命令,几乎成了面试中关于分布式锁的“开场白”。它看起来简单,但背后涉及了原子性、锁超时、死锁预防等一系列关键问题。很多人觉得会用这行命令就等于懂了分布式锁,但实际生产环境中,仅仅知道这行命令是远远不够的,从锁的获取、持有到释放,每一个环节都藏着“坑”。这篇文章,我就结合自己这些年趟过的雷,从头到尾拆解一下用RedisSET NX实现分布式锁的完整逻辑、那些容易忽略的细节,以及如何让它变得更健壮。
2. SET NX PX:一行命令背后的精妙设计
我们先来彻底理解这行核心命令:SET lock:order:1234 client_unique_id NX PX 30000。我们把它拆开看,每一个部分都不是多余的。
lock:order:1234: 锁的Key。这是锁的唯一标识,必须与你要保护的资源强关联。比如保护订单ID为1234的订单,Key就应该是lock:order:1234。这里有个最佳实践:使用业务前缀(如lock:),这样在Redis可视化工具里一目了然,也便于后续的监控和管理。Key的设计要保证全局唯一性,避免不同业务间的锁意外冲突。
client_unique_id: 锁的Value。这是整个方案中最容易被忽视,但也最关键的部分。Value必须是一个唯一值,且必须由加锁的客户端生成和持有。通常,我们可以使用UUID + 线程ID,或者更简单的,使用Redisson这类客户端库时,它们会生成一个UUID:threadId格式的字符串。为什么不能是固定的1或者"locked"?因为在你释放锁的时候,需要验证当前持有锁的是不是你自己。如果Value都一样,客户端A在超时后锁被自动释放,此时客户端B加锁成功,紧接着客户端A执行完逻辑又来释放锁(它用的是和B一样的Value),就会错误地把客户端B的锁给释放掉。所以,Value是客户端身份的证明。
NX: 键不存在时才设置。这是实现互斥性的核心。NX是“Not eXists”的缩写。只有当lock:order:1234这个Key在Redis中不存在时,SET命令才会执行成功,并将Value设置进去。如果这个Key已经存在(意味着锁已被其他客户端持有),那么本次SET操作将失败,返回nil。这就保证了在同一时刻,只有一个SET NX请求能成功创建这个Key,即只有一个客户端能获得锁。
PX 30000: 设置键的过期时间(毫秒)。这是预防死锁的生命线。PX 30000表示这个Key在30000毫秒(30秒)后会自动过期并被Redis删除。为什么必须设置过期时间?考虑一个场景:客户端A成功加锁后,在执行业务逻辑过程中宕机了,或者发生了长时间的Full GC,导致它永远无法主动来释放锁。如果没有过期时间,这个锁就会永远留在Redis里,其他所有客户端再也无法获得这个锁,共享资源就被永久锁死了,这就是死锁。设置一个合理的过期时间(如30秒),即使客户端崩溃,锁也会在超时后自动释放,系统具备了自我恢复的能力。
把这四个部分组合起来,这行命令的语义就是:“尝试将一个具有客户端唯一标识的值,设置到代表某个资源的键上,前提是这个键必须不存在(确保互斥),并且无论后续发生什么,这个键都将在30秒后自动消失(防止死锁)。” 这一行命令的调用本身是原子性的,Redis保证它要么全部执行成功,要么全部不执行,不存在只设置了Key没设置过期时间的中间状态,这是Redis单线程命令处理模型带来的天然优势。
3. 获取锁与释放锁:一个完整的流程与代码实现
理解了核心命令,我们来看一个完整的加锁、解锁流程应该如何实现。这里我用Java代码示例,并会指出每个步骤的意图和潜在风险。
3.1 加锁实现
加锁的逻辑相对直接,就是尝试执行那行核心命令。
import redis.clients.jedis.Jedis; public class SimpleRedisLock { private static final String LOCK_PREFIX = "lock:"; private static final int DEFAULT_EXPIRE_TIME = 30000; // 30秒 private static final String LOCK_SUCCESS = "OK"; private Jedis jedis; // Redis连接 private String lockKey; private String requestId; // 客户端唯一标识 private int expireTime; public SimpleRedisLock(Jedis jedis, String resourceKey) { this.jedis = jedis; this.lockKey = LOCK_PREFIX + resourceKey; this.requestId = java.util.UUID.randomUUID().toString() + "-" + Thread.currentThread().getId(); this.expireTime = DEFAULT_EXPIRE_TIME; } /** * 尝试获取分布式锁 * @return 是否获取成功 */ public boolean tryLock() { // 关键的一行:原子性操作 String result = jedis.set(lockKey, requestId, "NX", "PX", expireTime); return LOCK_SUCCESS.equals(result); } }关键点分析:
- 连接管理: 示例中为了简洁,直接使用了
Jedis对象。在生产环境中,你需要从连接池中获取连接,并在使用后正确归还,避免连接泄漏。 - 唯一标识生成:
requestId使用了UUID+线程ID,这在大多数场景下足以保证全局唯一。如果你的客户端可能会在多个进程间共享,则需要更复杂的标识,比如加上进程ID。 - 返回值判断:
jedis.set命令在成功时返回字符串"OK",失败时返回null。判断LOCK_SUCCESS.equals(result)是标准的成功判定方式。
3.2 释放锁实现——踩坑重灾区
释放锁的逻辑,比加锁要复杂得多,也是90%的坑所在。最天真的做法是直接DEL key,这会导致我们前面说的误删其他客户端锁的问题。正确的做法是:先验证,再删除。而且,这两个操作必须是原子的。
错误示范(非原子操作):
public void unlockWrong() { // 第一步:获取当前锁的Value String currentValue = jedis.get(lockKey); // 第二步:判断是不是自己的锁 if (requestId.equals(currentValue)) { // 第三步:如果是,则删除锁 jedis.del(lockKey); } }这个逻辑在单线程下看起来没问题,但在分布式并发下存在严重问题。考虑以下时序:
- 客户端A执行完
if判断,确认currentValue等于自己的requestId,准备执行del。 - 就在此时,锁因为过期时间到了,被Redis自动删除。
- 客户端B趁虚而入,成功执行
SET NX PX,获取了锁。 - 客户端A继续执行,调用了
del命令,结果把客户端B刚创建的锁给删除了!
问题的根源在于“判断-删除”这两个操作不是原子的,中间可能被其他客户端插入操作。为了解决这个问题,我们需要借助Lua脚本,因为Redis执行Lua脚本时是原子性的,不会被其他命令打断。
正确实现(使用Lua脚本):
public class SimpleRedisLock { // ... 其他代码同上 ... 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"; /** * 释放分布式锁 * @return 是否释放成功(成功删除返回true,锁已不属于自己或不存在返回false) */ public boolean unlock() { try { // 使用Lua脚本保证原子性:比较并删除 Object result = jedis.eval(UNLOCK_SCRIPT, 1, lockKey, requestId); // Lua脚本返回删除的键数量,成功为1,失败为0 return Long.valueOf(1L).equals(result); } catch (Exception e) { // 记录日志,但通常不抛出异常,避免影响主业务逻辑 e.printStackTrace(); return false; } } }关键点分析:
- Lua脚本的原子性:
jedis.eval会将整个Lua脚本发送到Redis服务器,服务器会一次性执行完整个脚本。在脚本执行期间,不会有其他命令被执行,从而完美解决了“判断-删除”的竞态条件问题。 - 脚本逻辑: 脚本首先用
get命令获取锁当前的Value,然后与传入的ARGV[1](即客户端的requestId)进行比较。如果相等,证明锁还是自己持有的,则执行del删除并返回1;如果不相等,说明锁可能已经过期被其他客户端获取,或者已经被释放,此时返回0,不做任何操作。 - 异常处理: 释放锁的操作不应该因为Redis网络波动等问题而抛出异常,导致主业务流程中断。通常的做法是捕获异常,记录日志,并返回释放失败。调用方可以根据业务重要性决定是否告警或重试。
- 返回值意义: 返回
true表示锁被成功释放(由本客户端删除);返回false表示释放操作未执行(因为锁不属于本客户端)。这给了调用方一个清晰的反馈。
4. 超时与续约:SET NX方案的核心挑战与应对
即使我们正确地实现了加锁和释放锁,SET NX PX方案依然面临一个本质性的挑战:业务逻辑执行时间的不确定性与锁过期时间的确定性之间的矛盾。
我们设定了30秒的过期时间PX 30000。这意味着,从锁获取成功那一刻起,一个30秒的倒计时就开始了。理想情况下,客户端在30秒内完成业务逻辑并释放锁。但现实是骨感的:
- 业务逻辑可能很复杂,涉及多个数据库操作、RPC调用。
- 网络可能突然变慢,下游服务响应延迟。
- 服务器可能发生Full GC,导致所有线程暂停数秒。
如果业务逻辑执行时间超过了30秒,会发生什么?Redis会在第30秒准时把锁删除。此时,客户端A还在懵懂地处理业务,而锁已经没了。客户端B可以成功获取到锁,并开始操作共享资源。于是,客户端A和客户端B同时进入了临界区,分布式锁失效了。
这就是SET NX方案最经典的问题。为了解决它,业界提出了“看门狗”(Watchdog)机制,或者叫锁续约(Lock Renewal)。
4.1 看门狗机制的原理
看门狗机制的核心思想是:在客户端持有锁期间,启动一个后台守护线程,定期(比如每隔过期时间的1/3)去检查锁是否还存在且仍属于自己,如果是,则刷新锁的过期时间。
这个过程就像养了一只狗,它每隔一段时间就“吠叫”一次(执行续约操作),告诉Redis:“我还活着,别删我的锁!”如果客户端进程崩溃了,看门狗线程也随之停止,“吠叫”中断,锁最终还是会因过期而被自动清理。
一个简单的看门狗实现思路:
- 成功获取锁后,启动一个定时任务(
ScheduledExecutorService)。 - 定时任务每隔10秒(假设锁过期时间为30秒)执行一次。
- 每次执行时,向Redis发送一个Lua脚本。这个脚本的逻辑是:如果锁存在且Value匹配,则重新设置过期时间为30秒(
pexpire命令)。 - 当客户端主动释放锁时,除了删除Redis中的Key,还要取消这个定时任务。
4.2 实现锁续约的Lua脚本
续约操作同样必须是原子的,也需要Lua脚本,因为它包含了“判断”和“设置”两个操作。
-- KEYS[1] 锁的key -- ARGV[1] 客户端唯一标识 -- ARGV[2] 新的过期时间(毫秒) if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('pexpire', KEYS[1], ARGV[2]) else return 0 end这个脚本先检查锁的持有者是否还是自己,如果是,则用pexpire命令重置过期时间;如果不是,则返回0,表示续约失败(可能锁已经丢失)。
注意: 引入看门狗大大增加了方案的复杂性。你需要管理后台线程的生命周期,确保在锁释放或客户端关闭时能正确停止续约,避免资源泄漏。因此,在实际生产中,除非必要,更推荐的做法是合理评估并设置一个足够长的过期时间,让业务逻辑在绝大多数情况下都能在这个时间内完成。同时,业务代码层面要做好幂等性设计,即使出现极端情况下的锁失效,也能通过幂等性来保证数据最终正确。
5. 锁的可重入性:在分布式场景下的考量
可重入锁指的是同一个线程可以多次获取同一把锁,而不会造成死锁。在单机ReentrantLock中,这是通过一个计数器实现的。在分布式锁中,我们是否也需要实现可重入性?
这取决于你的业务场景。如果你的一个方法methodA()内部调用了另一个需要同一把锁的methodB(),而这两个方法在同一个线程执行流里,那么就需要可重入锁。否则,线程在methodA里获得锁后,进入methodB再次尝试获取锁,如果锁不可重入,就会发生死锁——线程等待自己释放锁。
为Redis锁增加可重入性,需要在Value上做文章。不能只存客户端标识,还需要存储一个重入计数器。
一种简单的实现思路:
- Value结构: 将Value设计为
clientUUID:threadId:count的格式,例如"f81d4fae-7dec-11d0-a765-00a0c91e6bf6-1:3",末尾的3就是重入次数。 - 加锁逻辑:
- 首先用
GET命令查看锁是否存在。 - 如果不存在,则执行
SET NX PX,Value设置为clientUUID:threadId:1。 - 如果存在,则解析出Value中的客户端标识和重入次数。如果是当前客户端,则将重入次数+1,并用
SET命令(不带NX)更新回去,同时刷新过期时间(这一步也需要Lua脚本保证原子性)。
- 首先用
- 释放锁逻辑:
- 获取当前锁的Value。
- 如果是当前客户端,则将重入次数-1。
- 如果减1后次数大于0,则更新Value和过期时间。
- 如果减1后次数等于0,则删除Key。
可以看到,实现可重入性会让加锁和释放锁的逻辑变得复杂很多,每一次操作都可能需要GET、判断、SET/DEL,并且必须用Lua脚本包装以保证原子性。因此,我的建议是:除非业务逻辑明确存在嵌套加锁的需求,否则优先考虑通过代码设计来避免嵌套调用分布式锁。例如,将需要加锁的公共逻辑抽取成一个方法,让methodA和methodB都调用这个公共方法,而不是相互调用。这样可以大大简化分布式锁的实现和维护成本。像Redisson这样的成熟客户端库提供了可重入锁的实现,如果确实需要,直接使用这些库是更稳妥的选择。
6. 高可用与集群环境下的新问题:Redlock算法简介
我们之前的讨论都基于一个前提:Redis是单点或者是一个普通的主从架构。但在生产环境中,为了高可用,我们通常会使用Redis Sentinel(哨兵)或者Redis Cluster(集群)。这给分布式锁带来了新的挑战:主从异步复制导致的数据丢失问题。
考虑以下场景:
- 客户端A在Redis主节点(Master)上成功执行
SET NX PX,获得了锁。 - 在主节点将这条数据异步复制给从节点(Slave)之前,主节点宕机了。
- 哨兵机制触发,其中一个从节点被提升为新的主节点。
- 但是,这个新的主节点上没有客户端A刚才设置的那个锁Key!
- 此时,客户端B向新的主节点申请同一把锁,
SET NX会成功。于是,客户端A和客户端B都认为自己持有了锁,冲突再次发生。
为了解决这个问题,Redis的作者Antirez提出了Redlock算法。它的核心思想是不再依赖单个Redis实例,而是同时向多个独立的Redis实例(主节点)申请锁,只有当超过半数的实例都成功获得锁时,才算加锁成功。
Redlock算法简要步骤:
- 获取当前时间(毫秒)。
- 依次向N个独立的Redis实例发送加锁命令(
SET NX PX),并设置一个远小于锁超时时间的网络超时时间(例如5-50ms),避免长时间阻塞。 - 计算整个加锁过程消耗的时间。只有当客户端在大多数(N/2 + 1)实例上加锁成功,且总耗时小于锁的有效时间时,锁才获取成功。
- 如果锁获取成功,其有效时间需要重新计算:初始有效时间减去加锁过程消耗的时间。
- 如果锁获取失败(要么未获得多数票,要么总耗时已超),客户端需要向所有Redis实例发送释放锁的Lua脚本。
Redlock算法通过引入多节点和多数派机制,提高了锁在部分节点故障时的可靠性。但是,它也带来了显著的复杂性:需要部署多个独立的Redis主节点(通常建议5个),客户端逻辑变得复杂,性能也有所下降(需要多次网络往返)。此外,关于Redlock是否绝对安全,在分布式系统社区也有激烈的讨论,涉及系统时钟跳跃等极端情况。
因此,对于大多数业务场景,如果你的业务可以容忍在Redis主从故障切换的极短时间内出现锁失效(比如通过幂等性兜底),那么使用单Redis节点或主从架构配合合理的超时时间,是一个更简单实用的选择。如果你的业务对锁的强一致性要求极高,且愿意承担复杂性和性能开销,那么可以考虑实现或使用支持Redlock的客户端(如Redisson)。
7. 实践总结与选型建议
经过上面的拆解,我们可以看到,一个简单的SET NX PX命令背后,是一个完整的分布式锁解决方案的冰山一角。在实际项目中,我的经验是:
评估需求,避免过度设计: 首先问自己,是否真的需要分布式锁?是否可以用数据库乐观锁、状态机等更轻量的方式实现?如果确实需要,评估一下锁失效的后果是否严重。对于大多数秒杀扣库存、更新用户状态等场景,配合业务幂等性,即使出现极短时间的锁失效,也是可以接受的。
优先使用成熟客户端库: 不要重复造轮子。对于Java技术栈,Redisson是首选。它提供了可重入锁、公平锁、联锁、红锁等多种锁实现,内置了看门狗机制,处理了所有复杂的原子性和续约逻辑,并且与Spring框架集成良好。使用它,你只需要关注业务逻辑,而不用操心锁的实现细节。类似地,其他语言也有优秀的客户端,如
go-redsyncfor Go。如果非要自己实现,务必做到:
- Value唯一: 使用客户端唯一标识。
- 释放原子: 使用Lua脚本比较并删除。
- 设置超时: 必须设置一个合理的过期时间,并充分考虑业务执行时间。
- 异常处理: 加锁失败、释放锁异常要有降级或日志记录,不能影响主流程。
关于超时时间: 这是一个权衡。设置太短,容易因业务未完成而锁提前释放,导致并发问题。设置太长,一旦客户端宕机,资源被锁定的时间也长,影响系统可用性。一个好的实践是,通过压测和监控,统计出业务逻辑在99%情况下的最长耗时,以此为基础,再增加一定的缓冲时间(比如50%)作为锁的超时时间。
监控与告警: 对分布式锁的关键指标进行监控,如:锁的获取成功率、平均等待时间、锁持有时间等。如果发现锁竞争激烈(获取失败率高)或持有时间异常长,需要及时告警并排查原因,可能是业务逻辑瓶颈或死循环。
分布式锁是一个看似简单实则深邃的话题,SET NX是它的起点,但绝不是终点。理解其背后的原理、局限性和演进方案,能帮助我们在不同的业务场景下做出更合适的技术选型,构建出更健壮的系统。