前言
在秒杀系统的第一篇中,我提到用"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设置过期时间,有什么问题?
回答:有两个命令之间有一段"真空期"。如果在这段真空期中服务宕机或网络中断,锁没有超时时间,永远不会释放------形成死锁。
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治理保证稳定性。