一、前言:为什么 Redis 的 MULTI/EXEC 不够用?
在开发高并发系统(如优惠券秒杀、库存扣减、分布式锁)时,我们常需要将多个 Redis 命令作为一个整体执行,确保中间不被其他客户端插入操作。
很多人第一反应是使用 Redis 的 MULTI/EXEC 事务,但很快发现:
- 它不支持条件判断(如 if-else)
- 无法保证"读-改-写"的原子性(WATCH 机制复杂且性能差)
真正的解决方案是:Lua 脚本!
本文将带你彻底掌握 如何用 Lua 脚本解决多命令原子性问题,并给出生产级代码示例。
二、Redis 的原子性边界在哪里?
Redis 是单线程模型,每个命令本身是原子的,例如:
bash
INCR counter # 原子
SET key value # 原子
但多个命令组合不是原子的:
java
// 非原子!两个命令之间可能被其他客户端插入
String stock = redis.get("stock");
if (Integer.parseInt(stock) > 0) {
redis.decr("stock"); // ← 危险!
}
💥 在高并发下,多个线程同时读到
stock=1,全部通过判断,导致超卖!
要实现"判断 + 修改"原子化,必须在一个命令内完成 ------ 这就是 Lua 脚本的价值。
三、Lua 脚本:Redis 的"原子函数"
从 Redis 2.6 开始,支持执行 Lua 脚本。其核心特性:
✅ 原子执行 :整个脚本运行期间,Redis 不会执行其他命令(包括来自其他客户端的)
✅ 支持逻辑控制 :if、for、while、函数等
✅ 高效:脚本可缓存,减少网络开销
📌 官方说明 :
"All the Redis commands that you call inside a Lua script are guaranteed to be executed atomically."
四、实战案例 1:优惠券库存原子扣减(防超卖)
❌ 错误做法(非原子):
java
int stock = redis.get("coupon:stock");
if (stock > 0) {
redis.decr("coupon:stock");
}
✅ 正确做法:Lua 脚本
Lua
-- check_and_decr.lua
local stock = tonumber(redis.call('GET', KEYS[1]))
if stock == nil or stock <= 0 then
return 0 -- 库存不足或不存在
end
redis.call('DECR', KEYS[1])
return 1 -- 扣减成功
Java 调用(Spring Boot):
java
private static final DefaultRedisScript<Long> DECREASE_SCRIPT;
static {
DECREASE_SCRIPT = new DefaultRedisScript<>();
DECREASE_SCRIPT.setScriptText(
"local stock = tonumber(redis.call('GET', KEYS[1])) " +
"if stock == nil or stock <= 0 then return 0 end " +
"redis.call('DECR', KEYS[1]) return 1"
);
DECREASE_SCRIPT.setResultType(Long.class);
}
public boolean tryDecreaseStock(String stockKey) {
Long result = redisTemplate.execute(DECREASE_SCRIPT,
Collections.singletonList(stockKey));
return result != null && result == 1;
}
✅ 效果:100% 防超卖,QPS > 10万
五、实战案例 2:安全释放分布式锁(防误删)
需求:只有加锁的线程才能删除锁
Lua
-- unlock.lua
if redis.call('GET', KEYS[1]) == ARGV[1] then
return redis.call('DEL', KEYS[1])
else
return 0
end
Java 调用:
java
public void unlock(String lockKey, String requestId) {
redisTemplate.execute(UNLOCK_SCRIPT,
Collections.singletonList(lockKey),
requestId); // ARGV[1]
}
✅ 彻底解决"线程 A 删除线程 B 的锁"问题
六、实战案例 3:一人一单 + 扣库存(复合操作)
将"检查是否已领取"、"扣库存"、"标记已领取"合并为一个原子操作:
Lua
-- receive_coupon.lua
local stock_key = KEYS[1]
local received_key = KEYS[2]
local user_id = ARGV[1]
-- 1. 检查是否已领取
if redis.call('SISMEMBER', received_key, user_id) == 1 then
return -1 -- 已领取
end
-- 2. 检查库存
local stock = tonumber(redis.call('GET', stock_key))
if not stock or stock <= 0 then
return 0 -- 库存不足
end
-- 3. 原子扣减 + 标记
redis.call('DECR', stock_key)
redis.call('SADD', received_key, user_id)
return 1 -- 成功
✅ 一条命令,解决超卖 + 一人一单两大难题!
七、Lua 脚本使用规范与注意事项
✅ 最佳实践:
| 建议 | 说明 |
|---|---|
| 使用 KEYS 和 ARGV | KEYS 传 Redis key,ARGV 传业务参数(避免拼接) |
| 脚本尽量简单 | 避免长时间运行(会阻塞 Redis) |
| 预加载脚本(可选) | 用 SCRIPT LOAD 缓存,调用时只传 SHA1 |
| 返回明确状态码 | 如 1=成功,0=库存不足,-1=已领取 |
⚠️ 注意事项:
- 不要在 Lua 中写死业务逻辑(难以维护)
- 避免在脚本中调用
KEYS *等慢命令 - 测试边界情况:key 不存在、value 非数字等
八、Lua vs Redis 事务(MULTI/EXEC)
| 特性 | Lua 脚本 | MULTI/EXEC |
|---|---|---|
| 支持条件判断 | ✅ | ❌ |
| 支持循环 | ✅ | ❌ |
| 原子性 | ✅(整个脚本) | ✅(但无法回滚) |
| 性能 | 极高(可缓存) | 中等 |
| 适用场景 | 复杂逻辑原子化 | 简单命令批量执行 |
🔍 结论 :涉及"读-判断-写"的场景,必须用 Lua!
九、结语
感谢您的阅读!如果你有任何疑问或想要分享的经验,请在评论区留言交流!