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

第五篇:分布式锁实战——Lua脚本原子操作与库存扣减的强一致性 前言在秒杀系统的第一篇中我提到用Redis Lua脚本原子扣减库存来保证不超卖。但当时只是一笔带过没有展开讲。面试中分布式锁是一个高频深水区问题。面试官不会只问你用过Redis分布式锁吗而是会追着问“setnx和expire为什么要写在一起分开写会有什么问题”“你释放锁的时候怎么保证删的是自己的锁而不是别人的”“Redis主从切换会导致锁丢失吗”“你为什么用Lua脚本而不是Redisson”这些问题只有真正踩过坑、做过技术选型的人才能答出有判断力的回答——不只是知道这个方案而是知道每种方案的适用边界。本文完整复盘我在秒杀项目中分布式锁的选型思路、Lua脚本的设计细节、以及与Redisson方案的对比分析。本文核心问题为什么需要分布式锁synchronized在分布式环境下为什么失效setnx expire分开写有什么致命缺陷如何解决释放锁时为什么要判断value误删别人的锁是什么场景Redis Lua脚本如何保证原子性为什么能替代分布式锁秒杀项目的库存扣减Lua脚本是怎么设计的为什么选Lua脚本而不是Redisson各自的适用场景是什么Redis集群下分布式锁的安全隐患是什么整个方案的边界和不足在哪什么场景需要升级读完本文你将对分布式锁的选型拥有从原理到实践的完整理解面试时能说清楚选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 分布式锁要满足的三个条件互斥性同一时刻只有一个客户端能持有锁可用性锁服务本身不能是单点故障容错性有机制处理锁持有者崩溃导致锁永不释放Redis作为独立进程所有服务实例共享访问自身有哨兵和集群等高可用方案可以对锁设置过期时间防止死锁——满足这三个条件。二、setnx expire的经典陷阱——分开设置 可能死锁疑问我先用setnx获取锁成功后再用expire设置过期时间有什么问题回答有两个命令之间有一段真空期。如果在这段真空期中服务宕机或网络中断锁没有超时时间永远不会释放——形成死锁。// ❌ 这种写法有死锁风险BooleanlockedredisTemplate.opsForValue().setIfAbsent(lock:stock:1001,value);redisTemplate.expire(lock:stock:1001,30,TimeUnit.SECONDS);死锁发生的具体时间线T1线程A执行setIfAbsent → 成功锁已获取T2服务宕机——expire还没来得及执行T3Redis中lock:stock:1001存在但never expireT4其他所有线程尝试获取锁 → 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脚本原子验证删除// ✅ 释放锁的Lua脚本先判断是不是自己的锁再删除StringUNLOCK_LUAif redis.call(get, KEYS[1]) ARGV[1] then return redis.call(del, KEYS[1]) else return 0 // 不是自己的锁不删除end;// 加锁时设置唯一标识StringlockValueUUID.randomUUID().toString();BooleanlockedredisTemplate.opsForValue().setIfAbsent(lock:stock:1001,lockValue,30,TimeUnit.SECONDS);// 解锁时传入这个唯一标识redisTemplate.execute(newDefaultRedisScript(UNLOCK_LUA,Long.class),Collections.singletonList(lock:stock:1001),lockValue);为什么getdel也必须用Lua原子化和前面setnxexpire分开的问题一样——如果先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脚本// RedisLuaUtil.java——项目中实际使用的扣减脚本StringDEDUCT_STOCK_LUAlocal stockKey KEYS[1] // seckill:stock:1001local logKey KEYS[2] // seckill:log:userId:1001local deductCount tonumber(ARGV[1]) // 扣减数量local userId ARGV[2] // 用户IDlocal 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调用publicLongdeductStock(LongskuId,LonguserId,Integercount){StringstockKeyseckill:stock:skuId;StringlogKeyseckill:log:userId:skuId;DefaultRedisScriptLongscriptnewDefaultRedisScript();script.setScriptText(DEDUCT_STOCK_LUA);script.setResultType(Long.class);LongresultredisTemplate.execute(script,Arrays.asList(stockKey,logKey),String.valueOf(count),String.valueOf(userId),String.valueOf(System.currentTimeMillis()));if(result-2L)thrownewBizException(秒杀活动不存在);if(result-1L)thrownewBizException(库存不足);returnresult;}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 → 成功获取锁 T2Redis主库宕机锁数据还没来得及同步到从库 T3哨兵将从库提升为新主库 T4客户端B向新主库请求获取同一个锁 → 新主库中没有锁数据 → 成功获取锁 T5A和B都持有锁6.2 我的秒杀项目怎么处理这个风险Lua脚本扣减库存不是锁机制并不依赖主从复制保证一致性。即使主从切换导致极端情况下多扣了一件库存定时对账任务会在下一周期检查流水表和订单表做补偿。对于秒杀场景而言这是业务层面可容忍的代价——相比引入RedLock带来的复杂度和性能开销用定时对账兜底是更务实的方案。七、分布式锁选型速查表场景推荐方案原因秒杀库存扣减Lua脚本原子操作操作简单、RT要求极高、Redis天然单线程提供原子性订单创建多服务调用Redisson WatchDog业务耗时不可控需要锁续期定时任务幂等SET NX EX操作低频基础方案够用金融级互斥ZooKeeper/etcd强一致性CP系统Redis是AP不能保证绝对互斥简单防重复提交SET NX EX UUID无需续期无需可重入基础方案即可总结synchronized的锁信息存在JVM内存中跨进程无法共享分布式环境必须用外部锁服务setnxexpire分开设置的真空期可能导致死锁——SET NX EX原子命令是基础方案的第一步修正释放锁不验证value可能误删别人的锁——验证和删除必须放在Lua原子操作中避免race condition秒杀项目用Lua脚本替代分布式锁——利用Redis单线程脚本原子执行在不加锁不减性能的情况下保证数据一致性Redisson不是更好的Lua方案而是解决不同问题的工具——业务耗时不可控时用WatchDogRT极短时用Lua脚本更好的工具取决于场景Redis集群下分布式锁存在主从切换锁丢失风险——秒杀项目用定时对账兜底不需要引入RedLock的复杂度技术选型不是选功能最全的而是选用最少的代码恰好解决当前问题的那一个系列回顾从第一篇异步削峰到本篇分布式锁秒杀系统专栏已覆盖消息队列削峰、JVM调优、慢SQL治理、多级缓存、分布式锁五大核心战场。五篇文章形成完整的秒杀架构技术链——写操作用Lua原子扣减消息队列削峰保证一致性读操作用三级缓存保证性能JVM调优和SQL治理保证稳定性。