Lua脚本解决多条命令原子性问题

一、前言:为什么 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!


九、结语

感谢您的阅读!如果你有任何疑问或想要分享的经验,请在评论区留言交流!

相关推荐
CoderCodingNo5 小时前
【GESP】C++ 二级真题解析,[2025年12月]第一题环保能量球
开发语言·c++·算法
独好紫罗兰5 小时前
对python的再认识-基于数据结构进行-a005-元组-CRUD
开发语言·数据结构·python
chilavert3185 小时前
技术演进中的开发沉思-356:重排序(中)
java·开发语言
devmoon5 小时前
为 Pallet 搭建最小化 Mock Runtime 并编写单元测试环境
开发语言·单元测试·区块链·智能合约·polkadot
Coder_Boy_5 小时前
Java开发者破局指南:跳出内卷,借AI赋能,搭建系统化知识体系
java·开发语言·人工智能·spring boot·后端·spring
Mr_Xuhhh5 小时前
介绍一下ref
开发语言·c++·算法
nbsaas-boot5 小时前
软件开发最核心的理念:接口化与组件化
开发语言
lsx2024065 小时前
Java 对象概述
开发语言
Mr_Xuhhh5 小时前
C++11实现线程池
开发语言·c++·算法