
1. 项目概述为什么我们需要Redis分布式锁在分布式系统里干活最头疼的问题之一就是“抢资源”。想象一下你和你的团队在维护一个电商系统大促时秒杀商品或者一个定时任务在多个服务器节点上同时被触发去更新同一个用户的账户余额。如果没有一个可靠的协调机制轻则数据错乱重则库存超卖、资金损失直接就是线上事故。这就是分布式锁要解决的场景在分布式或多线程环境下确保对共享资源的访问是串行化的同一时刻只有一个客户端或线程能持有锁并执行业务逻辑。而Redis凭借其高性能、单线程命令执行模型和丰富的数据结构成为了实现分布式锁的热门选择。今天要聊的就是最经典、最基础也是面试必问的Redis分布式锁实现方案使用SET命令的NX和PX参数。别看它命令简单里面的门道可不少从锁的获取、持有到释放每一步都藏着细节和“坑”。我见过不少团队在初期直接抄个网上代码就上生产结果在流量稍大或者网络抖动时频频翻车。所以这篇文章我会结合我这些年踩过的坑和积累的经验把SET NX PX这套方案的原理、实现、隐患以及应对策略掰开揉碎了讲清楚目标是让你不仅能写出可用的代码更能写出在生产环境扛得住压的健壮代码。2. 核心思路与设计考量2.1 分布式锁的基本要求在动手写代码之前我们必须明确一个合格的分布式锁应该具备哪些特性。这是评估任何实现方案的标尺互斥性这是最基本的要求在任意时刻最多只有一个客户端能持有锁。安全性锁只能由持有它的客户端释放其他客户端不能释放别人的锁。这是防止锁被意外删除导致混乱的关键。避免死锁即使持有锁的客户端崩溃或者发生网络分区锁最终也能被释放不会导致系统永久阻塞。这通常通过给锁设置一个过期时间来实现。高可用与高性能锁服务本身要足够可靠和快速不能成为系统的瓶颈。Redis的高性能在这方面有天然优势。可重入性可选但重要同一个客户端在持有锁的期间可以再次成功获取该锁。这在一些复杂的业务逻辑中很有用但基础的SET NX方案并不原生支持需要额外设计。2.2 为什么选择 Redis SET NX PX实现分布式锁有很多方式比如基于数据库、ZooKeeper等。选择Redis的SET NX PX组合主要基于以下几点考量性能极致Redis基于内存操作命令执行速度极快单节点每秒可处理数万甚至十万级别的请求能满足绝大多数高并发场景。命令原子性SET key value NX PX milliseconds这个命令是原子性的。它一次性完成了“判断key是否存在NX”、“设置值value”、“设置过期时间PX”三个操作。原子性至关重要它确保了在并发条件下锁的获取和过期时间设置是作为一个不可分割的整体完成的避免了先SETNX再EXPIRE可能因客户端崩溃导致的死锁问题。实现简单核心逻辑只需要一个Redis命令理解和维护成本低。利用单线程模型Redis处理命令是单线程的这天然保证了命令执行的串行性对于实现锁这种需要强一致语义的场景来说是一个很大的便利。当然它也有明显的短板最主要的就是可靠性。因为Redis通常采用异步复制在主从切换时可能会丢失锁信息后面会详细讲CAP理论与Redlock。但对于很多对锁的绝对可靠性要求不是极端苛刻的场景如控制任务重复执行、缓解并发冲击SET NX PX因其简单高效依然是首选方案。注意这里的选择是基于单Redis实例。在生产环境中为了高可用我们通常会使用Redis Sentinel哨兵或Redis Cluster集群。但基础的SET NX PX锁在哨兵主从切换时存在安全隐患这是使用前必须清楚的。3. 核心命令拆解与参数精讲实现分布式锁核心就是下面这一条命令SET lock_key unique_value NX PX 30000我们来逐个参数拆解理解其背后的意图lock_key锁的唯一标识。例如order_lock:12345表示对订单ID为12345的操作加锁。key的设计要有业务意义通常包含业务前缀和资源ID。unique_value一个全局唯一的客户端标识。这是实现“安全性”只能自己释放锁的关键绝对不能使用固定值如“1”或“locked”。通常使用UUID、雪花算法ID或者结合客户端标识如IP线程ID来生成。释放锁时需要先获取key的值判断是否与自己的unique_value相等相等才能删除。NXif Not eXists的缩写。仅当lock_key不存在时才设置值。这保证了互斥性。PX 30000设置键的过期时间为30000毫秒30秒。这是避免死锁的关键即使客户端持有锁后崩溃30秒后锁也会自动释放。过期时间的设置需要仔细权衡太短业务没执行完锁就释放了会导致并发问题太长客户端崩溃后资源被锁定的时间过长影响系统可用性。一个常见的错误示范是分两步操作SETNX lock_key value # 第一步尝试加锁 EXPIRE lock_key 30 # 第二步设置过期时间这两步不是原子的如果在执行完SETNX后客户端崩溃EXPIRE没有执行这个锁就永远不会释放导致死锁。因此必须使用SET ... NX PX ...这个原子命令。4. 完整实现与代码剖析理论讲完了我们来看一个完整的、包含获取锁、释放锁和异常处理的Java实现示例。这里使用Jedis客户端库。4.1 工具类实现import redis.clients.jedis.Jedis; import redis.clients.jedis.params.SetParams; import java.util.UUID; import java.util.concurrent.TimeUnit; public class RedisDistributedLock { private Jedis jedis; // Redis客户端连接 private String lockKey; // 锁的Key private String lockValue; // 锁的Value唯一标识 private long expireTimeMs; // 锁的过期时间毫秒 private static final SetParams SET_PARAMS_WITH_NX_PX SetParams.setParams().nx().px(30000); // 原子命令参数 /** * 构造函数 * param jedis Redis连接 * param lockKey 锁的业务Key * param expireTimeMs 锁持有超时时间毫秒 */ public RedisDistributedLock(Jedis jedis, String lockKey, long expireTimeMs) { this.jedis jedis; this.lockKey lockKey; this.expireTimeMs expireTimeMs; this.lockValue UUID.randomUUID().toString(); // 生成唯一标识 } /** * 尝试获取分布式锁非阻塞 * return true-获取成功false-获取失败锁已被占用 */ public boolean tryLock() { try { // 核心原子命令SET lockKey uniqueValue NX PX expireTime String result jedis.set(lockKey, lockValue, SET_PARAMS_WITH_NX_PX); // SET命令在成功时返回OK失败时NX条件不满足返回null return OK.equals(result); } catch (Exception e) { // Redis连接异常或命令执行异常按获取失败处理 // 实际生产环境应有更细致的异常处理和日志记录 e.printStackTrace(); return false; } } /** * 尝试获取分布式锁阻塞式带超时 * param waitTimeMs 最大等待时间毫秒 * return true-获取成功false-超时未获取到 * throws InterruptedException */ public boolean tryLock(long waitTimeMs) throws InterruptedException { long endTime System.currentTimeMillis() waitTimeMs; while (System.currentTimeMillis() endTime) { if (tryLock()) { return true; // 获取成功 } // 短暂休眠避免频繁轮询给Redis造成压力 TimeUnit.MILLISECONDS.sleep(50 (long)(Math.random() * 50)); // 加入随机抖动 } return false; // 超时未获取到 } /** * 释放分布式锁 * 必须确保是锁的持有者才能释放 */ public void unlock() { // 使用Lua脚本保证判断锁归属和删除操作的原子性 String luaScript if redis.call(get, KEYS[1]) ARGV[1] then return redis.call(del, KEYS[1]) else return 0 end; try { // 执行Lua脚本确保原子性 Object result jedis.eval(luaScript, 1, lockKey, lockValue); // 如果返回1表示删除成功返回0表示锁已不属于当前客户端可能已过期或被其他客户端持有 if (Long.valueOf(1).equals(result)) { System.out.println(锁释放成功: lockKey); } else { System.out.println(锁释放失败锁可能已过期或不属于当前客户端: lockKey); // 这里可以记录日志或告警用于排查问题 } } catch (Exception e) { // 释放锁时发生异常如网络中断需要记录严重日志并告警 // 因为锁可能因过期而自动释放也可能残留如果刚好在过期前一刻崩溃这是一个风险点 e.printStackTrace(); // 生产环境应接入监控告警系统 } } public String getLockValue() { return lockValue; } }4.2 关键代码解析与避坑指南唯一标识lockValue在构造函数中通过UUID.randomUUID().toString()生成。确保每个锁实例都有唯一标识这是安全释放的基础。在集群部署中可以考虑加上机器IP、进程ID等信息进一步增强唯一性。阻塞获取锁的优化在tryLock(long waitTimeMs)方法中获取失败后我们让线程休眠一段时间再重试。这里有两个技巧休眠时间不宜过短如1ms会给Redis带来不必要的压力也不宜过长影响获取锁的实时性。50-100ms是一个常见的折中选择。随机抖动50 (long)(Math.random() * 50)加入了随机延迟。这非常重要可以避免在锁释放的瞬间大量等待线程同时被唤醒并发起请求导致Redis瞬间压力激增惊群效应。释放锁的原子性——Lua脚本这是整个实现中最关键的安全保障。unlock()方法没有使用简单的“先GET判断再DEL”三步操作因为这三步不是原子的。考虑如下时序客户端A获取锁值设为uuid_a。客户端A执行业务锁过期自动释放。客户端B获取锁值设为uuid_b。客户端A此时执行到GET发现值不是uuid_a已经是uuid_b按照逻辑不应该删除。但是如果在GET之后DEL之前锁又过期了并且客户端C设置了新值uuid_c那么客户端A的DEL命令就会错误地删除客户端C的锁 虽然这个时序非常极端但在高并发下并非不可能。使用Lua脚本因为Redis会单线程执行整个脚本所以GET和DEL之间的判断是原子的彻底杜绝了上述问题。异常处理在tryLock()和unlock()中都有基本的异常处理。在生产环境中这里需要接入完善的日志和监控告警系统。特别是unlock()失败可能意味着锁状态异常需要人工介入排查。5. 典型应用场景与实战示例让我们用一个具体的业务场景来串联上面的代码防止定时任务重复执行。假设我们有一个每天凌晨执行的统计报表生成任务部署在多个应用实例上通过K8s或负载均衡器。我们不希望所有实例都运行这个任务只需要一个实例执行即可。public class ReportGenerationScheduler { private static final String LOCK_KEY scheduler:lock:daily_report; private static final long LOCK_EXPIRE_MS 5 * 60 * 1000; // 锁持有5分钟足够报表生成 private static final long LOCK_WAIT_MS 30 * 1000; // 等待锁30秒 Scheduled(cron 0 0 2 * * ?) // 每天凌晨2点执行 public void generateDailyReport() { // 每个实例使用独立的Redis连接或连接池 try (Jedis jedis new Jedis(redis-host, 6379)) { RedisDistributedLock lock new RedisDistributedLock(jedis, LOCK_KEY, LOCK_EXPIRE_MS); System.out.println(实例[ getInstanceId() ] 尝试获取定时任务锁...); boolean locked lock.tryLock(LOCK_WAIT_MS); if (locked) { System.out.println(实例[ getInstanceId() ] 成功获取锁开始执行报表生成任务。); try { // 这里是核心业务逻辑生成报表 generateReportInternal(); System.out.println(实例[ getInstanceId() ] 报表生成任务执行完毕。); } catch (Exception e) { // 业务逻辑异常记录日志锁会在过期后自动释放 System.err.println(报表生成失败: e.getMessage()); e.printStackTrace(); } finally { // 无论如何最终都尝试释放锁 lock.unlock(); } } else { System.out.println(实例[ getInstanceId() ] 获取锁失败其他实例正在执行任务本实例跳过。); } } catch (Exception e) { System.err.println(连接Redis或锁操作异常: e.getMessage()); e.printStackTrace(); } } private void generateReportInternal() { // 模拟耗时操作 try { TimeUnit.SECONDS.sleep(120); // 假设生成报表需要2分钟 } catch (InterruptedException e) { Thread.currentThread().interrupt(); } } private String getInstanceId() { // 获取当前应用实例的标识例如Pod名称、主机名等 return ManagementFactory.getRuntimeMXBean().getName(); // 简单示例返回进程ID主机名 } }在这个示例中多个部署实例会在每天凌晨2点同时触发generateDailyReport()方法。通过Redis分布式锁只有一个实例能成功获取scheduler:lock:daily_report这把锁并执行真正的报表生成逻辑generateReportInternal()。其他实例在等待一段时间30秒后发现获取锁失败就会安静地跳过本次执行从而实现了任务的幂等性控制。6. 深入隐患与进阶考量基础的SET NX PX实现虽然简单但在生产环境面临几个经典挑战6.1 锁过期与业务执行时间的博弈这是最核心的矛盾。我们给锁设置了过期时间如30秒但如果业务执行时间超过了30秒怎么办后果锁提前自动释放其他客户端可以获取锁并进入临界区。此时系统中可能同时有两个客户端在执行需要互斥的业务逻辑导致数据错误。场景业务涉及复杂计算、远程RPC调用、数据库慢查询等。应对策略合理评估并设置足够长的超时时间根据业务的历史执行时间P99P999来设置并留出足够余量。但这治标不治本万一某次执行特别慢呢锁续期Watch Dog在获取锁成功后启动一个后台守护线程看门狗定期比如每隔过期时间的1/3去检查锁是否还存在且值是自己设置的如果是则通过PEXPIRE命令重置过期时间。这要求业务代码在锁释放或客户端关闭时能正确停止看门狗线程。Redisson等成熟的客户端库内置了此功能。设计可中断的业务逻辑在业务代码中定期检查锁的持有状态。如果发现锁即将过期或已不属于自己应能安全地中止或回滚当前操作。这对业务逻辑的设计要求较高。6.2 Redis节点故障与数据丢失在Redis主从架构或哨兵模式下写操作SET先到主节点然后异步复制到从节点。隐患客户端A在主节点上成功获取锁。在主节点将锁数据同步到从节点之前主节点宕机。哨兵机制选举出一个从节点升级为新主节点但这个新主节点上没有客户端A的锁数据。此时客户端B向新主节点申请同一把锁SET NX会成功这就导致了互斥性被破坏两个客户端同时持有锁。本质这是CAP理论中的取舍。Redis主从异步复制追求的是AP可用性和分区容错性牺牲了强一致性C。SET NX PX锁在这种架构下无法保证绝对安全。应对策略接受风险如果业务场景可以容忍在极端故障情况下出现短时间的锁失效比如控制非核心任务的执行、作为优化手段缓解并发那么简单的单节点或主从Redis锁是可以接受的。它的失效概率低但后果可能严重需要评估。使用Redlock算法这是Redis官方提出的分布式锁算法用于在多个独立的Redis主节点上实现更高可靠性的锁。核心思想是同时向N个通常为5个独立的Redis实例申请锁当从大多数N/21实例上获取成功时才算真正持有锁。这降低了因单个实例故障导致锁失效的概率。但Redlock实现复杂性能有损耗且存在争议如时钟跳跃问题。通常用于对锁可靠性要求极高的场景。使用其他强一致性协调服务如果业务对锁的可靠性要求是100%那么应该考虑使用ZooKeeper或etcd等基于一致性协议如Zab、Raft的协调服务。它们通过牺牲一部分性能写延迟来换取强一致性锁信息在集群内达成共识后才返回成功从根本上避免了主从切换丢数据的问题。6.3 客户端停顿导致的锁失效假设客户端A获取锁后发生了长时间的GC停顿例如Full GC持续数秒或者进程被操作系统挂起。在这段时间内锁过期了。客户端B获取了锁并开始修改共享资源。随后客户端A从停顿中恢复继续执行临界区代码并最终调用unlock方法。由于它使用了Lua脚本发现锁的值已经不是自己的uuid_a所以不会删除客户端B的锁。但是客户端A在停顿后执行的临界区代码已经和客户端B的代码产生了数据竞争破坏了业务逻辑的正确性。应对策略 这个问题很难从锁服务端完全解决因为它源于客户端自身的不确定性。优化客户端环境优化JVM参数减少长时间GC保证服务器资源充足。设置合理的锁超时时间超时时间应远大于业务逻辑的最大可能执行时间为GC等停顿留出缓冲。业务逻辑幂等与状态检查让业务逻辑本身具备一定的容错能力。例如在修改数据前再次检查状态是否已被其他进程修改过。但这通常很复杂不是所有业务都能做到。7. 生产环境 checklist 与选型建议在决定使用SET NX PX方案前请对照以下清单进行评估[ ]业务容忍度评估你的业务是否能接受在Redis主从切换等极小概率事件下出现短时间的锁失效如果失效后果是资金损失、数据永久不一致请慎用。[ ]超时时间设置是否充分评估了业务逻辑的P99、P999耗时并设置了足够长且有安全余量的锁超时时间[ ]客户端唯一标识是否使用了足够唯一如UUID的lockValue[ ]释放锁的原子性是否使用Lua脚本或事务MULTI来保证GET和DEL的原子性[ ]异常处理与监控是否对tryLock和unlock的异常进行了妥善处理和监控告警特别是unlock失败需要重点监控。[ ]连接管理是否使用了连接池来管理Redis连接避免每次加解锁都创建新连接[ ]重试与退避在阻塞获取锁时是否加入了随机等待时间来避免惊群效应选型建议总结追求简单、高性能可接受极低概率风险用于控制缓存重建、幂等性校验、非核心定时任务等场景单节点或主从Redis的SET NX PX配合Lua脚本释放是很好的选择。可以考虑使用Redisson库它封装了看门狗、可重入锁等高级特性。对锁的可靠性要求高业务影响大用于扣减库存、秒杀、核心资金操作等场景建议评估使用Redlock算法部署多个独立Redis主节点或者直接使用ZooKeeper/etcd。新项目或复杂度可控如果团队熟悉ZooKeeper且业务对延迟不敏感直接使用Curator等客户端库提供的分布式锁可能是更省心的选择。我个人在实际项目中对于绝大多数“确保动作只发生一次”或“降低并发压力”的场景都会首选基于单Redis主从的SET NX PX方案因为它简单、快、运维成本低。同时我会在业务层面增加一层校验或告警作为兜底比如任务执行后记录日志监控是否有异常重复执行。对于真正的核心资金链路我们会不惜成本使用更可靠的方案比如基于数据库行锁或引入更重的协调服务。技术选型没有银弹权衡利弊适合自己业务的就是最好的。