Redis分布式锁核心原理:SET NX PX与Lua脚本原子性

很多刚接触分布式的同学,一听到“锁”就觉得很高大上,其实换个角度看,在Redis里实现分布式锁,核心就是抢座位。谁先抢到,谁就能干活,其他人只能等着或者滚蛋。

咱们先聊聊最基础的加锁。早期大家可能用过SETNX命令,意思是SET if Not eXists。这个命令确实能保证只有一个客户端能设置成功,但它有个致命伤:没法同时设置过期时间。如果你用SETNX抢到了锁,还没来得及执行EXPIRE设置超时时间,这时候你的服务直接宕机了,那这个锁就成“死锁”了,永远解不开。这在生产环境里就是个定时炸弹。

所以,Redis在2.6.12版本之后,官方推荐的做法是直接使用一条原子命令:SET key value NX PX timeout

这一条命令干了三件事:

注意,千万不要分开执行SETNXEXPIRE,这是新手最容易踩的坑。

那value值设什么呢?很多教程随便写个1,这不行。如果你设个固定的值,万一客户端A还没执行完,锁过期自动释放了,客户端B抢到了锁,这时候客户端A执行完了,去删锁,不就把客户端B的锁给干掉了吗?这就叫“误删”。

所以,加锁的时候,value必须是一个唯一标识,比如UUID或者UUID:线程ID

import redis.clients.jedis.Jedis; import java.util.Collections; import java.util.UUID; public class SimpleRedisLock { private Jedis jedis; private String lockKey; public SimpleRedisLock(Jedis jedis, String lockKey) { this.jedis = jedis; this.lockKey = lockKey; } /** * 尝试加锁 * @param requestId 唯一标识,可以用UUID * @param expireTime 锁的过期时间,单位毫秒 * @return 是否加锁成功 */ public boolean tryLock(String requestId, int expireTime) { // 使用 SET 命令的 NX 和 PX 选项 // 这里的 "NX" 表示不存在才设置,"PX" 表示设置毫秒级过期时间 String result = jedis.set(lockKey, requestId, "NX", "PX", expireTime); return "OK".equals(result); } /** * 释放锁(错误示范,存在原子性问题) * 这里先注释掉,为了引出下面的Lua脚本 */ /* public void unlockBad(String requestId) { // 这种写法有大问题!查和删不是原子的 String value = jedis.get(lockKey); if (requestId.equals(value)) { // 这里如果锁刚好过期,别的线程已经加锁了,你这一删就出大事了 jedis.del(lockKey); } } */ /** * 释放锁(正确姿势:使用Lua脚本保证原子性) * @param requestId 加锁时用的唯一标识 * @return 是否释放成功 */ public boolean unlock(String requestId) { // Lua脚本:先判断value是不是自己的,是的话再删除 // 这样可以保证“查询”和“删除”这两个操作是原子性的 String luaScript = "if redis.call('get', KEYS[1]) == ARGV[1] then " + " return redis.call('del', KEYS[1]) " + "else " + " return 0 " + "end"; Object result = jedis.eval(luaScript, Collections.singletonList(lockKey), Collections.singletonList(requestId)); return "1".equals(result.toString()); } public static void main(String[] args) { Jedis jedis = new Jedis("localhost", 6379); String lockKey = "stock_lock"; String requestId = UUID.randomUUID().toString(); SimpleRedisLock lock = new SimpleRedisLock(jedis, lockKey); try { // 尝试加锁,设置10秒过期 if (lock.tryLock(requestId, 10000)) { System.out.println("加锁成功,开始处理业务..."); // 模拟业务处理 Thread.sleep(3000); System.out.println("业务处理完成。"); } else { System.out.println("加锁失败,系统繁忙。"); } } catch (Exception e) { e.printStackTrace(); } finally { // 释放锁 boolean released = lock.unlock(requestId); System.out.println("锁释放状态: " + released); } jedis.close(); } }

上面这个代码里,解锁部分我特意用了Lua脚本。为啥?因为Redis执行Lua脚本的时候,会被当成一条命令,中间不会被其他命令插队。这就解决了“我查到锁是我的,还没来得及删,锁过期被别人抢了,然后我把别人的锁删了”这种并发问题。

📌 要点提醒:如果你只是做简单的Demo或者非核心业务,用上面的SET NX PX配合Lua脚本解锁基本够用了。但你要记住,这里的锁过期时间很难设。设短了,业务没跑完锁就没了;设长了,服务宕机了恢复得等半天。这也是为啥后面我们要引入Redisson的看门狗机制。

---

Redis 7.x 实战:基于Redisson实现可重入锁与自动续期(看门狗)

刚才咱们手写的锁虽然能用,但在生产环境里,你还得考虑可重入、续期、阻塞等待这些问题,自己造轮子太累了,而且容易出Bug。这时候,就得请出Redis Java客户端里的“大哥大”——Redisson

Redisson可不是简单的Jedis替代品,它实现了很多分布式Java对象和服务。对于分布式锁,它支持可重入、公平锁、读写锁,还有那个非常牛的Watch Dog(看门狗)机制。

咱们现在用的Redis都到7.2.5(2024年5月发布)了,Redisson的版本也一直在迭代,对Redis 7.x的支持非常完善。

换个角度看,Redisson的锁之所以牛,是因为它底层不是简单的SET NX,而是用了Hash结构

这样,同一个线程多次调用lock(),Value就加1,实现了可重入。解锁的时候减1,减到0才真正删除Key。

最爽的是看门狗(Watch Dog)。你加锁的时候如果不指定leaseTime(锁自动释放时间),Redisson默认会启动一个后台定时任务。这个任务每隔一段时间(默认是锁过期时间的1/3,比如你锁默认30秒,它每10秒检查一次)去续期。只要你的线程还活着,锁就一直不过期。除非你手动释放,或者整个JVM挂了,那锁才会等30秒后自动消失。

import org.redisson.Redisson; import org.redisson.api.RLock; import org.redisson.api.RedissonClient; import org.redisson.config.Config; import java.util.concurrent.TimeUnit; public class RedissonLockDemo { public static void main(String[] args) throws InterruptedException { // 1. 配置Redisson客户端,这里用单Redis节点做演示 // 如果是集群,改成 useClusterServers() 就行 Config config = new Config(); config.useSingleServer() .setAddress("redis://127.0.0.1:6379") .setConnectionPoolSize(10) .setConnectionMinimumIdleSize(5); // 2. 创建客户端 RedissonClient redisson = Redisson.create(config); // 3. 获取分布式锁对象 // 这里的 "order:lock:123" 就是锁的Key RLock lock = redisson.getLock("order:lock:123"); try { System.out.println(Thread.currentThread().getName() + " 尝试加锁..."); // 4. 加锁 // 这里不传过期时间,就是开启看门狗模式 // waitTime: 最多等待100秒获取锁 // leaseTime: 如果不传,默认-1,开启看门狗自动续期 boolean isLocked = lock.tryLock(100, TimeUnit.SECONDS); if (isLocked) { System.out.println(Thread.currentThread().getName() + " 加锁成功!开始处理订单..."); // 模拟业务耗时,这里故意搞个40秒,超过默认的30秒过期时间 // 如果是普通锁,早就过期被别人抢走了 // 但有了看门狗,它会自动续期,所以这里很安全 for (int i = 0; i < 4; i++) { System.out.println("业务执行中... " + (i + 1) + "/4"); Thread.sleep(10000); } // 测试可重入性 reentrantTest(lock); System.out.println("订单处理完成!"); } } catch (Exception e) { e.printStackTrace(); } finally { // 5. 释放锁 // 一定要判断是不是还持有锁,防止重复释放报错 if (lock.isHeldByCurrentThread()) { lock.unlock(); System.out.println(Thread.currentThread().getName() + " 锁已释放."); } } // 关闭客户端 redisson.shutdown(); } public static void reentrantTest(RLock lock) { // 同一个线程再次加锁,这就是可重入 lock.lock(); try { System.out.println("进入可重入方法,锁重入次数增加。"); } finally { lock.unlock(); // 这里解锁一次,重入次数减1 } } }

跑一下这个代码,你会发现,即便业务跑了40秒,锁依然稳稳地在你手里。这就是看门狗的威力。

📌 要点提醒:在实际开发中,用tryLock(waitTime, leaseTime, unit)的时候,如果你明确知道业务执行时间,可以指定leaseTime,这样Redisson就不会开启看门狗,减少一点开销。如果业务时间不确定,那就别传leaseTime,让它自动续期。另外,记得在finally块里判断lock.isHeldByCurrentThread(),避免因为业务异常导致没拿到锁却去释放锁的尴尬。

---

进阶:RedLock算法原理与集群高可用容错实现

前面咱们聊的Redisson锁,默认是基于单Master节点的。这在单机或者主从架构下没啥问题。但是,如果你用的是Redis Cluster,或者你对高可用性要求极高,单节点挂了咋办?这就引出了RedLock(红锁)算法。

RedLock是Redis作者Antirez提出来的。打个比方,它的思路就是:我不指望一个Redis节点靠谱,我多找几个独立的节点,只要大多数节点(过半数)都抢锁成功,我就认为我拿到锁了。

具体流程是这样的(假设我们有5个完全独立的Redis Master节点,它们没有主从关系,也不互相通信):

RedLock确实提升了可靠性,但它在社区里争议也很大。特别是那个Martin Kleppmann,他吐槽说RedLock依赖系统时钟,如果系统时间跳变(比如运维手动改了时间),可能会导致锁失效。而且,在极端网络延迟下,也可能出问题。

即便如此,在很多对一致性要求没那么变态(不需要像银行转账那样强一致),但又想防止单点故障的场景下,RedLock还是很有用的。

Redisson已经帮我们实现了RedLock,用起来非常简单,你只需要把多个RedissonClient实例传进去就行。

import org.redisson.Redisson; import org.redisson.api.RLock; import org.redisson.api.RedissonClient; import org.redisson.config.Config; import java.util.concurrent.TimeUnit; public class RedLockDemo { public static void main(String[] args) throws InterruptedException { // 模拟3个独立的Redis节点(实际生产环境应该是3台不同的机器或容器) // 这里为了方便,我们连同一个实例的不同DB,或者你搭3个端口 // 注意:真实RedLock要求节点完全独立,不能共享持久化或主从关系 Config config1 = new Config(); config1.useSingleServer().setAddress("redis://127.0.0.1:6379"); // 假设还有第二个节点 Config config2 = new Config(); config2.useSingleServer().setAddress("redis://127.0.0.1:6380"); // 假设你有6380端口的实例 // 假设还有第三个节点 Config config3 = new Config(); config3.useSingleServer().setAddress("redis://127.0.0.1:6381"); // 假设你有6381端口的实例 RedissonClient client1 = Redisson.create(config1); RedissonClient client2 = Redisson.create(config2); RedissonClient client3 = Redisson.create(config3); // 1. 构造RedLock // 把多个RLock实例传进去,这些实例必须来自不同的RedissonClient RLock lock1 = client1.getLock("resource_lock"); RLock lock2 = client2.getLock("resource_lock"); RLock lock3 = client3.getLock("resource_lock"); // RedissonRedLock 是 RedLock 算法的实现 RLock redLock = new org.redisson.RedissonRedLock(lock1, lock2, lock3); try { System.out.println("尝试使用RedLock加锁..."); // 2. 加锁 // waitTime: 等待锁的时间 // leaseTime: 锁的过期时间,这里设置30秒 boolean isLocked = redLock.tryLock(100, 30, TimeUnit.SECONDS); if (isLocked) { System.out.println("RedLock加锁成功!开始执行关键业务..."); // 模拟业务 Thread.sleep(5000); System.out.println("业务执行完毕。"); } else { System.out.println("RedLock加锁失败,可能存在冲突。"); } } catch (Exception e) { e.printStackTrace(); } finally { // 3. 解锁 // RedLock的解锁会向所有节点发送解锁请求 if (redLock.isHeldByCurrentThread()) { redLock.unlock(); System.out.println("RedLock已解锁。"); } } // 关闭客户端 client1.shutdown(); client2.shutdown(); client3.shutdown(); } }

🔧 实战技巧:虽然RedLock听起来很美,但别盲目上。如果你的场景是秒杀库存扣减,用普通的单节点Redisson锁(配合高可用主从)通常就够了,没必要引入RedLock的复杂度。RedLock更适合那种集群定时任务调度,比如你要确保集群里10台机器,只有1台能跑那个定时任务,这时候用RedLock就很合适。另外,根据2024年的技术趋势,社区也在讨论未来Redis 8.0+可能会把锁做成原生模块,到时候可能就不用这么折腾客户端了。现在嘛,Redisson的RedissonRedLock依然是最稳的选择之一。

4. 场景实战:电商库存扣减与接口幂等性防重提交

换个角度看,学分布式锁不能光看理论,得看它怎么在真实业务里“救命”。在电商和支付系统里,分布式锁主要干两件事:一是防超卖(库存扣减),二是防重单(接口幂等)。这两个场景要是没处理好,老板分分钟让你背锅。

4.1 场景一:电商秒杀库存扣减

咱们先说库存扣减。假设现在有个秒杀活动,库存只剩1个,结果有100个请求同时进来。如果没有锁,这100个请求都读到库存是1,然后都去减1,结果库存直接变负数,这就是经典的“超卖”。

虽然Redis的DECR命令本身是原子的,但在实际业务中,我们往往需要先查库存、再扣库存,或者扣完还要写日志,这就不是单个命令能搞定的了。这时候就得上分布式锁。

这里我直接用 Redisson 来演示。为啥不用Jedis手写?换个角度看,Redisson已经帮我们封装好了看门狗(Watch Dog)机制,也就是自动续期。在Redis 7.x时代,Redisson依然是最稳的客户端之一,它解决了“业务没执行完锁就过期”的痛点。

下面是一段完整的Spring Boot风格代码,模拟扣减库存:

import org.redisson.Redisson; import org.redisson.api.RLock; import org.redisson.api.RedissonClient; import org.redisson.config.Config; import java.util.concurrent.TimeUnit; public class StockService { private RedissonClient redisson; private String stockKey = "product:1001:stock"; // 假设商品ID是1001 public StockService() { // 1. 初始化Redisson客户端(这里以单机为例,Redis 7.2.5环境) Config config = new Config(); config.useSingleServer().setAddress("redis://127.0.0.1:6379"); this.redisson = Redisson.create(config); } public boolean deductStock(String productId) { // 2. 定义锁的key,通常是业务唯一标识 String lockKey = "lock:stock:" + productId; RLock lock = redisson.getLock(lockKey); try { // 3. 尝试加锁 // 参数说明: // waitTime: 最多等待5秒(如果拿不到锁就放弃) // leaseTime: -1 代表不指定过期时间,交给Redisson的看门狗自动续期(默认30秒,每10秒续一次) // 如果这里填了具体数字,比如 10,看门狗就失效了,锁10秒后强制过期 boolean isLocked = lock.tryLock(5, -1, TimeUnit.SECONDS); if (!isLocked) { System.out.println(Thread.currentThread().getName() + ":没抢到锁,请求太拥挤了!"); return false; } // 4. 核心业务逻辑:扣减库存 // 这里模拟从Redis获取库存并扣减 // 实际场景可能还要查数据库,这里为了演示简单,直接用Redis操作 Long stock = redisson.getAtomicLong(stockKey).get(); if (stock > 0) { redisson.getAtomicLong(stockKey).decrementAndGet(); System.out.println(Thread.currentThread().getName() + ":扣减成功,剩余库存:" + (stock - 1)); // 模拟业务耗时 Thread.sleep(100); return true; } else { System.out.println(Thread.currentThread().getName() + ":库存不足!"); return false; } } catch (InterruptedException e) { Thread.currentThread().interrupt(); return false; } finally { // 5. 释放锁 // 核心要点:一定要判断锁是否被当前线程持有,以及是否处于锁定状态,防止报错 if (lock.isHeldByCurrentThread() && lock.isLocked()) { lock.unlock(); System.out.println(Thread.currentThread().getName() + ":锁已释放"); } } } }

⚡ 效率提示:在写库存扣减代码时,千万别把leaseTime随便设一个很短的时间(比如3秒)。我见过太多新手因为业务处理稍微慢了点,锁就自动释放了,结果后面的线程进来把数据搞乱了。直接用lock.tryLock(等待时间, -1, 单位)让看门狗帮你干活,省心。

4.2 场景二:订单接口幂等性防重提交

再来说说接口幂等。用户点击“提交订单”,结果因为网络卡顿,用户手抖连点了两次。如果不做处理,后端可能生成两笔一模一样的订单。这时候分布式锁就派上用场了,我们可以用“订单号”或者“用户ID+业务类型”作为锁的维度。

这里我们用原生Redis命令配合Lua脚本来实现,这样更轻量,适合不想引入重客户端(如Redisson)的场景。

import redis.clients.jedis.Jedis; import java.util.Collections; import java.util.UUID; public class IdempotentService { private Jedis jedis; public IdempotentService() { // 连接 Redis 7.2.5 this.jedis = new Jedis("127.0.0.1", 6379); } /** * 模拟提交订单,利用分布式锁防重 */ public String submitOrder(String userId, String orderId) { String lockKey = "order:lock:" + userId + ":" + orderId; // 生成唯一value,用于防止误删 String requestId = UUID.randomUUID().toString(); try { // 1. 加锁:SET key value NX PX 30000 // NX表示不存在才设置,PX表示过期时间30秒 String result = jedis.set(lockKey, requestId, "NX", "PX", 30000); if ("OK".equals(result)) { // 2. 模拟业务处理:检查订单是否已存在,不存在则创建 System.out.println("处理订单逻辑,RequestId: " + requestId); Thread.sleep(50); // 模拟耗时 return "订单提交成功"; } else { return "请勿重复提交订单"; } } catch (Exception e) { e.printStackTrace(); return "系统异常"; } finally { // 3. 解锁:使用Lua脚本保证原子性 // 脚本逻辑:如果value相等,说明是自己的锁,就删除;否则不操作 String luaScript = "if redis.call('get', KEYS[1]) == ARGV[1] then " + "return redis.call('del', KEYS[1]) " + "else return 0 end"; // 执行Lua脚本 jedis.eval(luaScript, Collections.singletonList(lockKey), Collections.singletonList(requestId)); } } }

💡 经验总结:手写解锁逻辑时,一定要用Lua脚本,千万别先getdel。简单来说,在高并发下,get到值之后,del之前,锁可能刚好过期被别人抢走了,你这一del就把别人的锁给删了,这就出大事故了。

---

5. :锁超时解决、Fencing Token与RedLock争议

做分布式锁这一块,坑是真的多。很多新手以为加个锁就万事大吉了,结果线上一跑,各种诡异问题。这一章咱们聊聊那些年我们踩过的坑,以及社区里吵得不可开交的RedLock争议。

5.1 锁超时了,业务还没跑完怎么办?

这是最经典的问题。比如你设置了锁10秒过期,结果业务代码里有个慢查询或者调了第三方接口,15秒才跑完。这时候锁早就自动释放了,其他线程已经进来了,你的业务跑完再去释放锁,可能就把别人的锁给干掉了。

解决方案有两个:

5.2 Fencing Token(栅栏令牌)

这个东西是分布式系统大神Martin Kleppmann提出来的,专门用来解决“客户端暂停(比如GC停顿)”导致锁失效的问题。

场景是这样的

怎么解决?

引入一个单调递增的令牌(Fencing Token)。每次加锁成功,Redis不仅返回成功,还返回一个递增的Token(比如用INCR生成)。客户端在写存储(比如数据库或Zookeeper)的时候,必须带上这个Token。存储层会检查这个Token是不是比之前记录的Token大,如果不是,直接拒绝写入。

虽然Redis本身没有内置Fencing Token机制,但我们可以在业务层实现:

// 伪代码逻辑,展示Fencing Token思路 public void processWithFencing() { // 1. 获取锁的同时,获取一个递增的Token // 假设我们用一个独立的Key来维护Token计数器 long token = jedis.incr("global:fencing:token"); // 2. 加锁,把Token作为Value的一部分存进去 String lockValue = "client_id:" + token; jedis.set("resource_lock", lockValue, "NX", "EX", 30); // 3. 访问共享资源(比如数据库) // 在数据库操作前,检查当前Token是否是最新的 // 这里需要数据库支持,比如加个version字段或者检查存储的token值 // if (database.getMaxToken() >= token) { throw new Exception("操作过时"); } // 4. 执行业务... }

5.3 RedLock争议:到底还能不能用?

说到RedLock,这可是分布式锁界的“罗生门”。RedLock是Redis作者Antirez基于多个独立Master节点设计的算法,目的是在集群环境下提高可靠性。

RedLock的逻辑

客户端向N个(通常是5个)独立的Redis Master节点申请锁,如果超过半数(>= N/2 + 1)的节点加锁成功,且总耗时小于锁过期时间,才算加锁成功。

但是,Martin Kleppmann提出了质疑(这也是面试考点梳理):

我的建议

📖 学习建议:别盲目跟风用RedLock。在Redis 7.x环境下,如果你用的是Redis Cluster,其实普通的分布式锁(基于单个Master节点)配合合理的重试机制,在99%的场景下都是稳的。RedLock带来的性能损耗和复杂度,往往得不偿失。

---

6. 2024趋势:Redis Functions与云原生Serverless锁优化

技术圈更新太快了,作为老鸟,咱们不能只盯着SETNX看。2024年到2026年,Redis分布式锁也在进化。特别是Redis 7.0+引入的Functions特性,以及云原生环境下的新玩法,咱们得跟上节奏。

6.1 Redis Functions:Lua脚本的进化版

以前我们写锁逻辑,要么在客户端写Lua脚本,要么用Redisson。但Lua脚本有个痛点:它是“无状态”的,每次执行都要把脚本发给Redis,或者依赖EVALSHA缓存。而且脚本多了不好管理,版本控制也是个麻烦事。

Redis 7.0引入了Functions(虽然7.2.5已经很稳定了),打个比方,把Lua脚本作为一等公民存储在Redis服务端。你可以像调用普通命令一样调用Function。

用Function封装锁逻辑的好处:

下面是一个利用Redis Functions封装加锁逻辑的示例(假设在Redis服务端已经加载了这个函数):

-- 这是存储在Redis里的Function,名字叫 "acquireLock" -- 加载命令:redis-cli FUNCTION LOAD "#!lua name=acquireLock \n redis.call('SET', KEYS[1], ARGV[1], 'NX', 'PX', ARGV[2])" -- 实际开发中,Function的加载通常由运维或初始化脚本完成

客户端调用(Java示例):

import redis.clients.jedis.Jedis; import redis.clients.jedis.args.FunctionFlags; public class RedisFunctionLock { private Jedis jedis; public RedisFunctionLock() { this.jedis = new Jedis("127.0.0.1", 6379); } public boolean tryLockWithFunction(String lockKey, String value, int expireMs) { try { // 调用名为 acquireLock 的函数 // 注意:这里演示的是调用逻辑,实际Function需要先通过 redis-cli 加载到Redis 7.2.5中 Object result = jedis.fcall("acquireLock", 1, lockKey, value, String.valueOf(expireMs)); if ("OK".equals(result)) { System.out.println("通过Function加锁成功!"); return true; } return false; } catch (Exception e) { // 如果Function不存在,会报错,这里需要处理降级逻辑 System.err.println("Function调用失败,可能需要加载脚本: " + e.getMessage()); return false; } } }

虽然现在大部分公司还在用Lua脚本,但Redis Functions绝对是未来的趋势,特别是当你需要把锁逻辑标准化、服务化的时候。

6.2 Serverless与云原生锁优化

现在搞云原生,Serverless(无服务器架构)越来越火。但这对Redis锁提出了新挑战:

2024年的优化方向:

💡 经验总结:如果你在做Serverless开发,千万别在代码里频繁创建和销毁Jedis/Redisson客户端。尽量使用单例模式或者外部连接池(如果平台支持),或者考虑使用云服务商提供的Serverless Redis实例,它们通常针对这种场景做了优化。

其实,技术选型没有最好的,只有最合适的。Redis 7.2.5依然是目前最主流的选择,但咱们得知道,除了SETNX,还有Functions,还有云原生的新玩法,这样出去面试或者做架构设计,才能显得你不仅懂原理,还懂趋势。