[toc]
| 单词 | 词性 | 音标 | 含义 |
|---|---|---|---|
| distributed | adj. | /dɪˈstrɪbjuːtɪd/ | 分布式的(多台机器共同完成,分布式锁跨实例互斥) |
| lock | n./v. | /lɒk/ | 锁(SETNX 抢锁、DEL 释放) |
| reentrant | adj. | /ˌriːˈentrənt/ | 可重入的(同一线程可重复获取同一把锁) |
| lua | n. | /ˈluːə/ | 一种轻量脚本语言,Redis 用 EVAL 执行,保证原子性 |
| atomically | adv. | /əˈtɒmɪkli/ | 原子地(要么全执行、要么全不执行,中间无其它命令插入) |
| redisson | n. | /ˈredɪsən/ | 官方推荐的 Java 分布式锁客户端 |
| watchdog | n. | /ˈwɒtʃdɒɡ/ | 看门狗(Redisson 自动给锁续期,防止锁过期被误释放) |
| multi | adj. | /ˈmʌlti/ | 多(MULTI 开启 Redis 事务) |
Lecture08-Redis-分布式锁与Lua
目标:理解"为什么单机锁不够",掌握基于 Redis 的分布式锁,以及用 Lua 脚本保证多命令的原子性。
1、为什么需要分布式锁
1、单机 synchronized 的局限
public synchronized void deductStock() { ... }
synchronized/Lock只对同一 JVM 进程内的多个线程互斥。- 真实项目是多台服务器(集群/微服务)运行,多进程之间无法用 JVM 锁互斥——A 机器的线程和 B 机器的线程会同时进入。
2、场景举例
- 扣库存、下单、秒杀:多台应用服务器并发执行,必须保证"同一时刻只有一个实例在执行关键段"。
结论:需要一个所有实例共享的互斥机制——用 Redis 的一个 key 充当锁。
2、概念补充:原子性
原子性(Atomicity):一个操作"要么全部完成、要么一点都不做",中间不会被其它操作插队。就像转账——扣款和入账要么都成功、要么都失败,不会停在"只扣了没到账"的中间状态。
Java 中的 i++ 本质上是 i+1 然后给 i 赋值,所以是两步操作,不满足原子性。同样多行代码执行写操作也不满足原子性。
为了让多个操作满足原子性,我们就需要用加锁的方式把多行代码封装为一个逻辑上的整体。
为什么要先讲它?因为分布式锁的核心就是"原子地抢锁"——如果"判断锁是否存在"和"设置锁"这两步之间能被别人插进来,锁就形同虚设。
Redis 有一个重要特性:
核心主线程执行数据操作,是单线程执行的!
两个层面的原子性,别混淆:
| 层面 | 含义 | 例子 |
|---|---|---|
| 单条命令的原子性 | Redis 单条命令本身就是原子的,不会被并发打断 | SET key value NX EX 30(判断 + 设置 + 过期,一条搞定) |
| 多条命令的原子性 | 多条命令要当"一个整体"执行,中间不能插入别的命令 | 用 Lua 脚本打包(如"比对持有者 + 删除") |
单条命令可以保证原子性,但是如果多条命令是一个逻辑上的整体,需要具备原子性怎么办?
A 逻辑要执行的命令:黄色球
B 逻辑要执行的命令:蓝色球
此时相当于在执行 A 逻辑的同时,有 B 逻辑的操作命令插入进来了,这就无法保证原子性!

比如上面图例中,我们希望黄色和蓝色命令能够各自独立执行,在执行过程中不受到其它命令的插入,不会被打乱顺序

反例 vs 正例(体会为什么"一条命令 / 一个脚本"很重要):
# ❌ 判断和设置分两步:中间可能被别人插队,两个客户端都以为抢到了锁
if not exists lock → set lock owner
# ✅ 一条命令搞定:判断 + 设置 + 过期 是一个原子操作
set lock owner NX EX 30
# ❌ GET 和 DEL 分开:比对完、还没删,锁可能已过期被别人拿到 → 把别人的锁删了
get lock → 比对 → del lock
# ✅ Lua 打包:GET + 比对 + DEL 一气呵成,中间不会插入其它命令
EVAL "if redis.call('GET',KEYS[1])==ARGV[1] then return redis.call('DEL',KEYS[1]) else return 0 end" 1 lock owner
一句话记忆:原子性 = "不可分割、不被插队";Redis 靠单条命令或 Lua 脚本来提供它,这正是分布式锁能正确工作的前提。
3、基于 SETNX 的简单分布式锁
1、加锁
利用 SET key value NX EX 秒数 的原子性:key 不存在才设置成功(加锁成功),且带过期时间防止死锁。
# 抢锁:成功返回 OK,失败返回 (nil)
127.0.0.1:6379> set lock:order:001 owner-tom nx ex 30
OK
127.0.0.1:6379> set lock:order:001 owner-tom nx ex 30
(nil) # 已被占用,加锁失败
2、释放锁
# 简单释放
127.0.0.1:6379> del lock:order:001
(integer) 1
⚠️ 问题:如果 A 拿到锁还没执行完,锁过期被 Redis 删除;此时 B 抢到锁开始执行;A 执行完后
del——把 B 的锁删掉了!→ 需要"释放前校验持有者"。
3、加锁 + 校验持有者 + 释放(用 Lua 保证原子)
释放锁必须"先比对 value 是不是自己,是才删",这两步要原子——用 Lua 脚本:
-- lua/release.lua
if redis.call('GET', KEYS[1]) == ARGV[1]
then
return redis.call('DEL', KEYS[1])
else
return 0
end
@Bean
public RedisScript<Long> releaseLockScript() {
return RedisScript.of(new ClassPathResource("lua/release.lua"), Long.class);
}
Long result = redis.execute(releaseLockScript,
List.of("lock:order:001"), "owner-tom");
if (result != null && result == 1L) {
System.out.println("释放成功(确认是自己的锁)");
}
4、Lua 脚本:多命令原子性
1、为什么用 Lua
- Redis 单线程执行脚本,脚本内多条命令作为一个整体,中间不会有其它命令插入 → 原子性。
- 一次请求替代多次网络往返 → 性能更好。
2、Lua 基础
Lua 是一个小巧的脚本语言,Lua脚本可以很容易的被C/C++ 代码调用,也可以反过来调用C/C++的函数
Lua并没有提供强大的库,一个完整的 Lua 解释器不过 200k
所以Lua不适合作为开发独立应用程序的语言,而是作为嵌入式脚本语言
很多应用程序、游戏使用LUA作为自己的嵌入式脚本语言,以此来实现可配置性、可扩展性
这其中包括魔兽争霸地图、魔兽世界、博德之门、愤怒的小鸟等众多游戏插件或外挂。

-- KEYS[1]、KEYS[2]... 传入的 key(下标从 1 开始)
-- ARGV[1]、ARGV[2]... 传入的参数
-- redis.call('命令', ...) 执行 Redis 命令
local current = redis.call('GET', KEYS[1])
if current == ARGV[1] then
redis.call('SET', KEYS[1], ARGV[2])
return 1
end
return 0
典型应用:CAS(Compare And Set,先比对再设置)、扣库存防超卖、限流。第 7 讲"释放锁判断"就是典型 CAS。
3、用 Lua 扣库存(防超卖)
-- lua/deduct_stock.lua
-- KEYS[1]=库存key ARGV[1]=扣减数量
local stock = tonumber(redis.call('GET', KEYS[1]) or '0')
if stock >= tonumber(ARGV[1]) then
redis.call('DECRBY', KEYS[1], ARGV[1])
return 1 -- 扣减成功
end
return 0 -- 库存不足
Long ok = redis.execute(deductStockScript,
List.of("stock:sku:1001"), "1");
if (ok == 1L) {
System.out.println("扣减成功");
}
相比
GET后再DECR两条命令,Lua 版没有中间插入,彻底解决并发超卖。
4、在 SpringBoot 调用 Lua
[1]创建Lua脚本
- 外部调用 Lua 脚本时可以传入:
- 多个key
- 多个参数
- KEYS[下标]:从多个key中取下标对应的那一个,下标从1开始
- ARGV[下标]:从多个参数中取下标对应的那一个,下标从1开始
- redis.call():执行Redis命令

local current = redis.call('GET', KEYS[1])
if current == ARGV[1]
then redis.call('SET', KEYS[1], ARGV[2])
return 1
end
return 0
[2]创建 Lua 脚本对应的 bean
- RedisScript:Lua 脚本封装的 Java 类型
- 泛型:Lua 脚本最终返回值的类型
- 调用RedisScript.of()方法创建RedisScript对象
- 参数1:加载Lua脚本文件的Resource对象
- 参数2:Lua脚本返回值类型

@Bean
public RedisScript<Boolean> redisScript() {
// 这里需要指定 Lua 脚本的价值路径,路径的基准是类路径的根目录
ClassPathResource resource = new ClassPathResource("lua/test.lua");
return RedisScript.of(resource, Boolean.class);
}
[3]测试
@SpringBootTest
public class DemoRedisScriptTest {
@Resource
private RedisScript<Boolean> redisScript;
@Resource
private StringRedisTemplate redisTemplate;
@Test
public void test() {
// 前置:保证 number = 555,否则脚本直接返回 0
redisTemplate.opsForValue().set("number", "555");
// execute(脚本, keys 列表, ARGV...)
Boolean executeResult = redisTemplate.execute(
redisScript, Arrays.asList("number"), "555", "666");
System.out.println(Boolean.TRUE.equals(executeResult) ? "已设置新值" : "未设置新值");
}
}
5、Redisson 分布式锁(生产推荐)
手写 SETNX + 过期有个痛点:锁过期了业务还没执行完。Redisson 的 watchDog 看门狗会自动续期,且支持可重入、公平锁等,生产更省心。
1、引入依赖
<dependency>
<groupId>org.redisson</groupId>
<artifactId>redisson-spring-boot-starter</artifactId>
<version>3.x.x</version> <!-- 按官方最新稳定版 -->
</dependency>
2、使用
spring:
data:
redis:
host: 192.168.100.100
port: 6379
@Autowired
private RedissonClient redisson;
public void deductStock() {
// 声明一个 key
// 这个 key 带有当前要访问的目标资源的 ID 体现了前面说的分开加锁
String key = "lock:stock:1001";
// 基于 key 加分布式锁
RLock lock = redisson.getLock(key);
try {
// 抢锁,默认 30 秒,每 10 秒续期一次;看门狗会持续续期直到 unlock
// 也就是说:我们自己不要设置一个固定的过期时间,一旦设置了,看门狗机制就不起做了!
// 不设置租期,就表示锁的过期时间交给看门狗来维护
lock.lock();
// 业务代码……扣库存
} finally {
lock.unlock(); // 释放
}
}
记住:Redisson 解决了续期(看门狗)和可重入问题,是生产首选。
6、Redis 事务(了解)
1、命令
| 命令 | 作用 |
|---|---|
MULTI | 开始事务,后续命令入队 |
EXEC | 顺序执行队列中的命令 |
DISCARD | 放弃已入队的命令 |
WATCH | 监视 key,被改则事务失败(乐观锁) |
2、示例
127.0.0.1:6379> multi
OK
127.0.0.1:6379(TX)> set number 100
QUEUED
127.0.0.1:6379(TX)> incr number
QUEUED
127.0.0.1:6379(TX)> exec
1) OK
2) (integer) 101
⚠️ 注意:Redis 事务不支持回滚(运行期报错不会撤销已执行命令);WATCH 是乐观锁,key 被改则
EXEC返回 nil。复杂业务原子性优先用 Lua,而不是事务。
7、对比与选型
| 方案 | 原子性 | 续期 | 可重入 | 适用 |
|---|---|---|---|---|
| SETNX + 过期 | 单条原子 | 无(自己续) | 无 | 简单场景/学习 |
| SETNX + Lua | 脚本原子 | 无 | 无 | 需要自定义原子逻辑 |
| Redisson | 有 | 有(看门狗) | 有 | 生产首选 |
| Redis 事务 | 顺序执行,不保证回滚 | - | - | 简单批量,业务用 Lua 替代 |
8、课堂练习
- 用
SET NX EX模拟"两个客户端抢同一把锁",一个成功一个(nil)。 - 写一个 Lua 脚本
compare_and_set:比对 key 值等于指定值才设置新值,返回是否成功。 - 用 Lua 写"扣库存"脚本并测试超卖防护。
- (进阶)引入 Redisson,用
RLock给"扣库存"加锁,体会看门狗续期。
9、本讲小结
- 多实例并发需要分布式锁,Redis 用
SETNX + EX抢锁。 - 释放锁要"先比对持有者再删",用 Lua 保证原子。
- Lua 让多条命令成为原子整体,是防超卖、CAS 等场景的关键。
- 生产用 Redisson(看门狗续期 + 可重入)。
10、本节验证
- 理解分布式锁解决什么问题(单机 synchronized 的局限)✅
- 会用 SETNX + 过期时间实现简单分布式锁(加锁/释放)✅
- 会用 Lua 保证多命令原子性(校验持有者+释放、扣库存防超卖)✅
- 会用 Redisson 生产级分布式锁,理解看门狗自动续期 ✅