Redis 分布式锁与 Lua 原子性实践
学习整理,如有错误,欢迎指正
分布式锁的原子性
分布式锁的典型实现(SETNX + DEL)在“获取锁、判断锁归属、释放锁”这一系列操作中,并非原子操作。
在高并发场景下,多个线程可能发生竞态条件(race condition),从而导致:
- 误删他人锁
- 锁提前释放
- 多线程同时进入临界区
因此,我们选择可以原子操作的LUA脚本
Redis 官方规定:
Lua 脚本在 Redis 中是单线程、原子执行的。
这是因为Redis 是单线程执行模型,Lua 脚本在执行期间不会被打断,也不会插入其他命令。
Redis提供了Lua脚本功能,在一个脚本中编写多条Redis命令,确保多条命令执行时的原子性。
意味着:
- Redis 收到 Lua 脚本后
- 立刻独占执行,不插入任何其他命令
- 执行完才会处理其他请求lua操作redis
文档
lua文档:
Lua 教程 | 菜鸟教程
3.4.1 - 算术操作 | Lua 5.4 中文参考手册
lua操纵redis
文档
使用 Lua 脚本 | 文档 - Redis 文档
从脚本与redis交互
redis.call('命令名称','key','其它参数',...)例:
存储
redis.call('set','username','jack')获取并返回,使用local定义局部变量
localusername=redis.call('get','username')returnusername脚本参数化
用来动态获取参数,具备拓展性
redis>EVAL"return { KEYS[1], KEYS[2], ARGV[1], ARGV[2], ARGV[3] }"2key1 key2 arg1 arg2 arg31)"key1"2)"key2"3)"arg1"4)"arg2"5)"arg3"函数搭配参数化
>EVAL"return redis.call('SET', KEYS[1], ARGV[1])"1foo bar OK业务实现
用lua脚本实现分布式锁释放时
1、获取锁中的线程标示
2、判断是否与指定的标示(当前线程标示)一致
3、如果一致则释放锁(删除)
4、如果不一致则什么都不做
实现:
-- 这里的 KEYS[1] 就是锁的key,这里的ARGV[1] 就是当前线程标示-- 获取锁中的标示,判断是否与当前线程标示一致if(redis.call('GET',KEYS[1])==ARGV[1])then-- 一致,则删除锁returnredis.call('DEL',KEYS[1])end-- 不一致,则直接返回return0注:
这里 return redis.call(‘DEL’, KEYS[1])
返回值:
- 1:删除成功(说明锁属于当前线程)
- 0:删除失败(锁不存在或不属于当前线程)
java执行lua脚本
RedisTemplate中,可以利用execute方法去执行lua脚本
publicSimpleRedisLock(Stringname,StringRedisTemplatestringredisTemplate){this.stringredisTemplate=stringredisTemplate;this.name=name;}publicvoidunLock(){stringredisTemplate.execute(UNLOCK_Script,Collections.singletonList(KEY_PREFIX+name),ID_PREFIX+Thread.currentThread().getId());}释放锁时执行lua脚本,实现了原子性
注意:
1、KEYS 必须是集合(List):因为 Lua 规范要求 KEYS 是数组,Redis 规定所有 Redis 的 key 必须放进 KEYS 数组,所以必须用 Collections.singletonList 包一层。
ARGV 不用是集合:因为 Spring 做了自动封装,你传单个值,它会自动帮你转成数组
另外,KEYS 不能自动封装!必须你自己传 List
KEYS 是 Redis 规定的 “Redis 键集合”,不是普通参数。
Spring 不敢帮你自动封装 KEYS,怕你传错、传乱,导致 Redis 集群直接报错
总结:
Redis Lua 脚本参数分为两类:
- KEYS:用于传递 Redis Key(必须是 List)
- ARGV:用于传递普通参数
在 Spring Data Redis 中:
- KEYS必须手动封装为 List
- ARGV可以自动封装
2、Collections.singletonList(xxx)把 1 个值,变成一个 “只有 1 个元素的数组 / List”
问题
1、不可重入
问题本质:同一个线程,拿了锁之后,不能再拿同一把锁
- 比如:线程 A 获取了锁
lock:order,执行业务逻辑时,又调用了一个同样需要获取lock:order的方法。 - 普通
SETNX实现的锁,因为 key 已经存在,会直接返回获取失败。 - 结果:线程 A 自己把自己 “卡死” 了,业务无法继续执行。
为什么这是个问题?
很多业务场景天然需要可重入锁:
- 递归调用
- 嵌套方法调用(外层拿了锁,内层方法也要拿同一把锁)
- 框架内部封装导致的重复加锁(比如事务 + 锁)
普通SETNX锁不支持这种场景,会导致线程死锁。
2、不可重试
问题本质:抢锁失败一次,就直接放弃,没有重试机制
- 当锁被其他线程占有时,当前线程调用
SETNX会直接返回false。 - 实现里如果不加任何重试逻辑,线程就直接放弃,业务流程直接失败。
为什么这是个问题?
分布式环境下,锁竞争很常见,很多时候只是暂时抢不到。
不重试会导致:业务成功率低,系统可用性差。
正确的做法通常是:抢锁失败后,等待一段时间再重试(自旋 / 轮询),直到超时。
3、 超时释放(锁过期问题)
问题本质:锁超时释放虽然能防死锁,但会带来更危险的问题
锁加过期时间,初衷是为了防止死锁:
- 线程 A 拿了锁,业务执行中宕机,没释放锁 → 锁会自动过期释放,不会永久占住。
但它有致命缺陷:
业务执行时间 > 锁过期时间
- 线程 A 拿到锁,锁过期时间设为 30 秒。
- 但业务逻辑执行需要 40 秒。
- 30 秒时锁自动释放,线程 B 拿到了锁,开始执行业务。
- 第 40 秒,线程 A 业务执行完,释放锁(此时释放的是线程 B 的锁)。
- 线程 C 又拿到锁,同一时间多个线程执行业务,锁完全失效。
释放了别人的锁
- 线程 A 的锁超时释放后,线程 B 拿到锁。
- 线程 A 还在执行,执行完后调用
DEL释放锁,结果把线程 B 的锁删了。 - 后续线程又能拿到锁,导致并发问题。
核心矛盾:
锁的过期时间,无法和业务执行时间强绑定。
你设短了,业务没执行完锁就没了;设长了,宕机后锁要很久才释放,影响性能。
4、主从一致性问题
问题本质:Redis 主从集群下,锁数据同步延迟会导致锁失效
Redis 主从集群的工作方式:
- 主节点写数据(SETNX),然后异步同步给从节点。
- 客户端读请求可以走从节点,提高性能。
但锁场景下,会出大问题:
- 线程 A 向主节点请求加锁,主节点写入成功,但数据还没同步到从节点。
- 主节点突然宕机,哨兵 / 集群自动将从节点提升为新主节点。
- 新主节点里,还没有线程 A 的锁数据。
- 线程 B 向新主节点请求加锁,成功拿到了锁。
- 结果:线程 A 和线程 B 同时持有锁,锁完全失效。
为什么这是个严重问题?
Redis 主从同步是异步的,存在时间窗口。
锁的核心要求是强一致性,而 Redis 主从集群在故障切换时,无法保证锁数据不丢失。
基于主从架构的普通分布式锁,无法保证极端故障下的锁安全。
总结
总结对比下,lua脚本仍存在问题
为了解决:
- 可重入问题
- 自动续期问题
- 主从一致性问题
工程中通常直接使用成熟方案,例如:Redisson
Redisson 提供:
- 可重入锁(RLock)
- 自动续期(WatchDog)
- 公平锁 / 读写锁
- 基于 Lua 的原子操作封装
| 实现方式 | 优点 | 缺点 |
|---|---|---|
| SETNX + DEL | 简单 | 非原子、误删锁 |
| SET NX EX | 支持过期时间 | 仍可能误删 |
| Lua脚本 | 原子释放锁 | 不支持重入 |
| Redisson | 功能完善 | 依赖重 |
具体可参考我的博客
Redisson 分布式锁原理简析(可重入、续期与高可用)
