第五篇:分布式锁实战——Lua脚本原子操作与库存扣减的强一致性

前言

在秒杀系统的第一篇中,我提到用"Redis Lua脚本原子扣减库存"来保证不超卖。但当时只是一笔带过,没有展开讲。

面试中,分布式锁是一个高频深水区问题。面试官不会只问你"用过Redis分布式锁吗",而是会追着问:

"setnx和expire为什么要写在一起?分开写会有什么问题?"

"你释放锁的时候,怎么保证删的是自己的锁而不是别人的?"

"Redis主从切换会导致锁丢失吗?"

"你为什么用Lua脚本而不是Redisson?"

这些问题,只有真正踩过坑、做过技术选型的人,才能答出有判断力的回答------不只是"知道这个方案",而是"知道每种方案的适用边界"。

本文完整复盘我在秒杀项目中分布式锁的选型思路、Lua脚本的设计细节、以及与Redisson方案的对比分析。

本文核心问题:

  1. 为什么需要分布式锁?synchronized在分布式环境下为什么失效?
  2. setnx + expire分开写有什么致命缺陷?如何解决?
  3. 释放锁时为什么要判断value?误删别人的锁是什么场景?
  4. Redis Lua脚本如何保证原子性?为什么能替代分布式锁?
  5. 秒杀项目的库存扣减Lua脚本是怎么设计的?
  6. 为什么选Lua脚本而不是Redisson?各自的适用场景是什么?
  7. Redis集群下分布式锁的安全隐患是什么?
  8. 整个方案的边界和不足在哪?什么场景需要升级?

读完本文,你将对分布式锁的选型拥有从原理到实践的完整理解,面试时能说清楚"选Lua脚本而不是Redisson"的底层考量。


一、为什么需要分布式锁?

疑问:单机应用用synchronized或ReentrantLock就够了,为什么还需要分布式锁?

回答:因为synchronized的锁信息存在JVM内存的对象头中,多个JVM实例之间无法共享锁状态。

1.1 单机锁在分布式环境下的失效

复制代码
单机部署(一个JVM):
  线程A → synchronized(obj) → 获取锁(JVM1内存中)
  线程B → synchronized(obj) → 等待锁释放
  ✅ 互斥有效,因为锁状态在同一个JVM中

分布式部署(多个JVM):
  实例1:线程A → synchronized(obj) → 获取锁(存储在JVM1内存的对象头中)
  实例2:线程B → synchronized(obj) → 获取锁(存储在JVM2内存的对象头中)
  ❌ 两个JVM各有自己的锁内存,互不影响 → 同一时刻两个线程同时持有锁

锁信息存在哪里决定了它能锁多大范围。JVM内存只能锁住一个进程内的线程,跨进程必须用外部的锁服务。

秒杀系统是微服务架构,订单服务部署了多个实例。如果用户对同一个课程的库存做并发扣减请求,这些请求可能被负载均衡分发到不同实例上。单机锁只能保护一个实例内的串行执行,无法阻止两个实例同时操作Redis库存------这就需要分布式锁或者等效的原子操作机制。

1.2 分布式锁要满足的三个条件

  1. 互斥性:同一时刻只有一个客户端能持有锁
  2. 可用性:锁服务本身不能是单点故障
  3. 容错性:有机制处理锁持有者崩溃导致锁永不释放

Redis作为独立进程,所有服务实例共享访问;自身有哨兵和集群等高可用方案;可以对锁设置过期时间防止死锁------满足这三个条件。


二、setnx + expire的经典陷阱------分开设置 = 可能死锁

疑问:我先用setnx获取锁,成功后再用expire设置过期时间,有什么问题?

回答:有两个命令之间有一段"真空期"。如果在这段真空期中服务宕机或网络中断,锁没有超时时间,永远不会释放------形成死锁。

java 复制代码
// ❌ 这种写法有死锁风险
Boolean locked = redisTemplate.opsForValue()
    .setIfAbsent("lock:stock:1001", "value");
redisTemplate.expire("lock:stock:1001", 30, TimeUnit.SECONDS);

死锁发生的具体时间线

  • T1:线程A执行setIfAbsent → 成功,锁已获取
  • T2:服务宕机------expire还没来得及执行
  • T3:Redis中lock:stock:1001存在,但never expire
  • T4:其他所有线程尝试获取锁 → setIfAbsent返回false → 永远等不到锁

这种Bug在测试环境几乎不会被发现------测试环境的服务器负载低、执行快,两个命令之间几乎不可能遇到宕机。但在生产中,任何一个未知的GC停顿、网络抖动或进程卡顿都会让这段真空期暴露出来。这种Bug也不是必现,而是偶发的死锁------排查难度大,危害却致命。

解决方案 :用原子命令将加锁和设过期时间合并------SET key value NX EX 30。NX确保key不存在时才设置,EX确保30秒后自动过期。两个操作在一个命令中原子完成,不再有真空期。


三、释放锁的隐患------你以为删的是自己的锁?

疑问:释放锁不就是DELETE key吗?为什么还要先判断value?

回答:如果不判断value,一个线程可能会删掉另一个线程持有的锁------导致互斥失效。

3.1 误删的场景

复制代码
时间线:
T1:线程A获取锁,超时30秒
T2:线程A执行业务------32秒后完成(超过30秒的锁过期时间)
T3:锁在30秒时自动过期
T4:线程B获取锁(此时线程A还在处理中,锁已经重新分配)
T5:线程A执行完毕,调用 DELETE lock_key
T6:线程B的锁被线程A删掉!
T7:线程C获取锁 → 同一时刻线程B和线程C都持有锁

线程A删掉的是线程B持有的锁,互斥被打破。

3.2 解决方案:Lua脚本原子验证+删除

java 复制代码
// ✅ 释放锁的Lua脚本:先判断是不是自己的锁,再删除
String UNLOCK_LUA = 
    "if redis.call('get', KEYS[1]) == ARGV[1] then " +
    "    return redis.call('del', KEYS[1]) " +
    "else " +
    "    return 0 " +  // 不是自己的锁,不删除
    "end";

// 加锁时设置唯一标识
String lockValue = UUID.randomUUID().toString();
Boolean locked = redisTemplate.opsForValue()
    .setIfAbsent("lock:stock:1001", lockValue, 30, TimeUnit.SECONDS);

// 解锁时传入这个唯一标识
redisTemplate.execute(
    new DefaultRedisScript<>(UNLOCK_LUA, Long.class),
    Collections.singletonList("lock:stock:1001"),
    lockValue
);

为什么get+del也必须用Lua原子化? 和前面setnx+expire分开的问题一样------如果先get验证value,再del删除,验证通过和删除之间如果插入了锁过期并被新线程获取,del同样会误删。验证和删除必须在同一个Lua原子操作中完成。


四、我在秒杀项目中的实践------Lua脚本原子扣减替代分布式锁

疑问:你在秒杀项目中到底用的是传统分布式锁还是Lua脚本?为什么选这个方案?

回答:我用的是Lua脚本直接执行库存扣减------利用Redis单线程执行命令的特性保证原子性,用原子操作替代了锁机制。

4.1 为什么选Lua脚本而不是Redisson?

秒杀场景的特点决定了方案选择:

  • 扣减步骤固定:查库存→扣库存→写流水标记,业务流程极短
  • RT要求极高:10ms内必须返回,加锁+解锁的网络往返是不可接受的额外成本
  • 锁会成为性能瓶颈:同一个课程的库存Key会被所有秒杀请求竞争,加锁阻塞不如直接利用Redis单线程排队执行Lua脚本

Redisson解决的是"业务执行时间不可控时的锁自动续期"问题。秒杀接口在10ms内返回、业务流程固定,不存在"锁突然过期"的隐患------引入Redisson的WatchDog后台线程和续期开销是过度设计。

4.2 秒杀项目的库存扣减Lua脚本

java 复制代码
// RedisLuaUtil.java------项目中实际使用的扣减脚本

String DEDUCT_STOCK_LUA = 
    "local stockKey = KEYS[1] " +                   // seckill:stock:1001
    "local logKey = KEYS[2] " +                     // seckill:log:userId:1001
    "local deductCount = tonumber(ARGV[1]) " +      // 扣减数量
    "local userId = ARGV[2] " +                      // 用户ID
    "local timestamp = ARGV[3] " +                   // 时间戳
    "local currentStock = redis.call('get', stockKey) " +
    "if not currentStock then " +
    "    return -2 " +                              // -2:库存key不存在
    "end " +
    "currentStock = tonumber(currentStock) " +
    "if currentStock < deductCount then " +
    "    return -1 " +                              // -1:库存不足
    "end " +
    "local remainStock = redis.call('decrby', stockKey, deductCount) " +
    "redis.call('setex', logKey, 3600, userId .. ':' .. deductCount .. ':' .. timestamp) " +
    "return remainStock";                          // >=0:剩余库存

// Java调用
public Long deductStock(Long skuId, Long userId, Integer count) {
    String stockKey = "seckill:stock:" + skuId;
    String logKey = "seckill:log:" + userId + ":" + skuId;
    
    DefaultRedisScript<Long> script = new DefaultRedisScript<>();
    script.setScriptText(DEDUCT_STOCK_LUA);
    script.setResultType(Long.class);
    
    Long result = redisTemplate.execute(
        script,
        Arrays.asList(stockKey, logKey),
        String.valueOf(count),
        String.valueOf(userId),
        String.valueOf(System.currentTimeMillis())
    );
    
    if (result == -2L) throw new BizException("秒杀活动不存在");
    if (result == -1L) throw new BizException("库存不足");
    return result;
}

4.3 这段Lua脚本为什么能保证不超卖?

保障层面 机制 效果
Redis单线程 执行Lua脚本期间,其他任何命令不能插入 天然串行化
原子操作 查库存+扣库存+写流水标记打包为一条命令 无中间状态
返回码判断 -2表示key不存在,-1表示库存不足 业务层精确反馈
流水标记 setex写入扣减记录 为后续补偿对账提供凭证

和传统分布式锁的本质区别:传统方案是"先获取锁→执行业务→释放锁",锁保护的不是数据本身,而是代码块的访问权。Lua脚本是"直接在Redis内部原子执行所有数据操作",省掉了获取和释放锁的网络往返,也不需要担心锁过期------操作要么全部完成,要么全部不执行。


五、为什么不用Redisson?------方案选型的逻辑链

疑问:Redisson不是更成熟的分布式锁方案吗?WatchDog自动续期、可重入、公平锁------功能这么全为什么不用?

回答:选择技术方案,不是看谁功能多,而是看谁恰好解决当前问题,且不引入不必要的新风险。

5.1 场景特征对比

场景特征 Redisson适用 秒杀项目实际情况
业务耗时 不确定,可能需要续期 固定10ms内完成
锁持有时间 可能超过过期时间 远小于任何合理超时设置
可重入需求 复杂业务可能递归调用 单一扣减操作,无嵌套
公平锁需求 需要按顺序处理 利用Redis单线程天然FIFO
额外开销 WatchDog后台线程+续期 不必要的网络开销和内存消耗
运维复杂度 需要Redisson版本和Redis版本匹配 直接用Lua脚本,无额外依赖

在秒杀场景下,Redisson的每一个高级功能都是一项不必要的开销。 WatchDog的续期不需要------操作10ms完成;可重入不需要------没有嵌套调用;公平锁不需要------Redis单线程天然有序。Lua脚本恰好解决了问题,且没有多余的一行代码。

5.2 什么场景会选Redisson?

如果业务逻辑需要调用外部服务、耗时不确定、锁持有时间可能超过过期时间------Redisson的WatchDog自动续期就成了必要保障。例如创建订单需要调用风控服务、优惠券服务等外部模块,整个流程可能从10ms变成200ms甚至更多。

知道一个工具的适用边界,比会用它更重要。


六、Redis集群下分布式锁的安全隐患

疑问:Redis主从架构下,主库宕机可能导致锁丢失吗?

回答:是的。这是Redis分布式锁在集群模式下最核心的安全隐患------主库上的锁数据还没来得及同步到从库,主库就宕机了。

6.1 主从切换导致锁丢失

复制代码
T1:客户端A在Redis主库上 SET lock_key NX EX 30 → 成功获取锁
T2:Redis主库宕机(锁数据还没来得及同步到从库)
T3:哨兵将从库提升为新主库
T4:客户端B向新主库请求获取同一个锁 → 新主库中没有锁数据 → 成功获取锁
T5:A和B都持有锁!

6.2 我的秒杀项目怎么处理这个风险?

Lua脚本扣减库存不是锁机制,并不依赖主从复制保证一致性。即使主从切换导致极端情况下多扣了一件库存,定时对账任务会在下一周期检查流水表和订单表做补偿。对于秒杀场景而言,这是业务层面可容忍的代价------相比引入RedLock带来的复杂度和性能开销,用定时对账兜底是更务实的方案。


七、分布式锁选型速查表

场景 推荐方案 原因
秒杀库存扣减 Lua脚本原子操作 操作简单、RT要求极高、Redis天然单线程提供原子性
订单创建(多服务调用) Redisson + WatchDog 业务耗时不可控,需要锁续期
定时任务幂等 SET NX EX 操作低频,基础方案够用
金融级互斥 ZooKeeper/etcd 强一致性CP系统,Redis是AP,不能保证绝对互斥
简单防重复提交 SET NX EX + UUID 无需续期,无需可重入,基础方案即可

总结

  • synchronized的锁信息存在JVM内存中,跨进程无法共享,分布式环境必须用外部锁服务
  • setnx+expire分开设置的真空期可能导致死锁 ------SET NX EX原子命令是基础方案的第一步修正
  • 释放锁不验证value可能误删别人的锁------验证和删除必须放在Lua原子操作中,避免race condition
  • 秒杀项目用Lua脚本替代分布式锁------利用Redis单线程+脚本原子执行,在不加锁不减性能的情况下保证数据一致性
  • Redisson不是"更好的Lua方案",而是解决不同问题的工具------业务耗时不可控时用WatchDog,RT极短时用Lua脚本,"更好的工具"取决于场景
  • Redis集群下分布式锁存在主从切换锁丢失风险------秒杀项目用定时对账兜底,不需要引入RedLock的复杂度
  • 技术选型不是选功能最全的,而是选用最少的代码恰好解决当前问题的那一个

系列回顾:从第一篇异步削峰到本篇分布式锁,秒杀系统专栏已覆盖消息队列削峰、JVM调优、慢SQL治理、多级缓存、分布式锁五大核心战场。五篇文章形成完整的秒杀架构技术链------写操作用Lua原子扣减+消息队列削峰保证一致性,读操作用三级缓存保证性能,JVM调优和SQL治理保证稳定性。

相关推荐
直奔標竿2 小时前
MySQL与Redis数据一致性实战方案(避坑指南)
java·数据库·spring boot·redis·mysql·spring·缓存
笨鸟先飞的橘猫5 小时前
lua——哈希表详细学习
学习·lua·散列表
庞轩px6 小时前
第一篇:Redis数据结构底层——String、List、Hash、Set、ZSet各自用什么实现的?
数据结构·redis·list·set·hash·string·zset
Devin~Y7 小时前
大厂Java面试:Spring Boot + Redis/Kafka + Spring Cloud + JVM + RAG/向量检索(小Y翻车实录)
java·jvm·spring boot·redis·spring cloud·kafka·mybatis
大迪deblog7 小时前
系统架构设计-Redis设计-缓存穿透、缓存击穿、缓存雪崩
数据库·redis·系统架构
Irissgwe8 小时前
redis之哨兵(Sentinel)
数据库·redis·sentinel·主从复制·哨兵
庞轩px8 小时前
第二篇:Redis的过期删除与内存淘汰——数据过期了怎么删?内存满了怎么办?
数据库·redis·缓存·内存·lru·内存淘汰·过期删除
薪火铺子1 天前
Redis 缓存三大问题与解决方案
redis·spring·缓存
人道领域1 天前
【黑马点评日记】RedisGEO实战:黑马点评附近商铺功能
java·数据库·redis·adb