
你在做一个电商系统的'秒杀扣库存'功能。
你的 Java 代码逻辑:
int stock = redis.get("sku:1001:stock"); (获取库存)
if (stock > 0) { redis.decr("sku:1001:stock"); } (库存大于 0 则扣减)
灾难降临:
这两步操作之间有网络延迟,并且不是原子的。
当库存只剩 1 个时,100 个并发线程同时执行到了第 1 步,都发现 stock = 1 > 0,于是全部执行了第 2 步。
结果: 库存变成了 -99,超卖了 99 件商品,老板连夜找你谈话。
破局之道:
使用 Redis Lua 脚本。将'查库存 + 判断 + 扣减'打包成一段脚本发给 Redis。因为 Redis 执行 Lua 脚本是单线程且排他的,这 100 个请求只能乖乖排队一个个执行,完美解决超卖问题。
1. 核心原理:为什么是 Lua?
Redis 官方内置了 Lua 解析器。它能带来两大无可替代的优势:
- 绝对的原子性 (Atomicity): Redis 会将整个 Lua 脚本作为一个整体执行。在脚本运行期间,Redis 不会执行其他任何客户端的命令。不用加任何分布式锁,天然防止并发冲突。
- 减少网络开销 (Reduce Network Overhead): 原本需要 3 次请求(请求头 + 网络传输 + 解析)的操作,现在合并成 1 次请求发送给 Redis,极大地降低了网络延迟。
2. 从'菜鸟'到'老鸟':EVAL vs SCRIPT LOAD
执行 Lua 脚本有两个命令,代表了两种不同的段位。
菜鸟写法:每次都传整个脚本 (EVAL)
EVAL "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end" 1 mylock myrandomvalue
- 痛点: 你的业务逻辑脚本可能长达上百行(几 KB)。如果秒杀时 QPS 是 10 万,你每秒钟都要在网络上传输
10 万次 * 几 KB 的一段重复字符串,这会瞬间打满服务器的网卡带宽。
老鸟写法:脚本预加载 (SCRIPT LOAD + EVALSHA)
为了解决带宽浪费,Redis 提供了'缓存脚本'的机制。
第一步:预加载脚本 (SCRIPT LOAD)
在应用启动时(或者首次执行时),把长达百行的 Lua 脚本发送给 Redis。
SCRIPT LOAD "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end"
Redis 编译这段代码,把它缓存在内存中,并返回一个 40 位的 SHA1 摘要(Hash 值),例如:b8059ba43fd6bb4979e8eb3c1a84c8fb23eabcc1。
第二步:通过 Hash 值执行 (EVALSHA)
真正的高并发请求到来时,客户端只需要发送那个短小精悍的 SHA1 值即可!
EVALSHA b8059ba43fd6bb4979e8eb3c1a84c8fb23eabcc1 1 mylock myrandomvalue
- 收益: 网络传输量从几 KB 骤降到只有 40 个字节,完美榨干网卡性能。
3. 生产环境的'兜底逻辑' (NOSCRIPT 错误)
脚本预加载非常棒,但有一个坑:Redis 重启,或者执行了 SCRIPT FLUSH,缓存的脚本会丢失。
此时如果你继续用 EVALSHA 去请求,Redis 会报错:NOSCRIPT No matching script. Please use EVAL.
标准工业级代码的执行流水线:
- 客户端带着 SHA1 尝试执行
EVALSHA。
- 如果成功,直接返回。
- 如果捕获到
NOSCRIPT 异常,客户端立刻降级,使用原本的长脚本执行一次 EVAL。
EVAL 执行成功的同时,Redis 会自动将该脚本重新缓存。后续的请求就又能开心地用 EVALSHA 了。
(注:主流的 Redis 客户端,如 Java 的 Redisson/Jedis,Go 的 go-redis,底层已经自动帮你封装了这个'EVALSHA 失败后降级 EVAL'的逻辑。)
4. 三大高频实战场景与 Lua 源码
场景一:分布式锁的原子释放 (Check-and-Delete)
痛点: 释放锁时,必须判断'这把锁是不是我自己加的'(防止误删别人的锁)。查锁和删锁必须原子化。
Lua 脚本:
if redis.call("GET", KEYS[1]) == ARGV[1] then
return redis.call("DEL", KEYS[1])
else
return 0
end
场景二:极速秒杀扣库存 (Check-and-Deduct)
痛点: 高并发下判断库存是否大于购买量,够才扣除。
Lua 脚本:
local stock = tonumber(redis.call('GET', KEYS[1]))
local num = tonumber(ARGV[1])
if (stock == nil) then
return -1
end
if (stock >= num) then
redis.call('DECRBY', KEYS[1], num)
return 1
else
return 0
end
场景三:滑动窗口限流 (Sliding Window Rate Limiter)
痛点: 限制某个 IP 在 60 秒内只能访问 100 次,使用 ZSet 记录时间戳,清理旧数据和统计当前数据必须原子完成。
Lua 脚本:
local current_time = tonumber(ARGV[1])
local window_size = tonumber(ARGV[2])
local max_requests = tonumber(ARGV[3])
local window_start = current_time - window_size
redis.call('ZREMRANGEBYSCORE', KEYS[1], 0, window_start)
local current_requests = redis.call('ZCARD', KEYS[1])
if current_requests < max_requests then
redis.call('ZADD', KEYS[1], current_time, current_time .. tostring(current_requests))
redis.call('PEXPIRE', KEYS[1], window_size)
return 1
else
return 0
end
5. 避坑指南:Lua 脚本的致命红线
- 绝对不要写慢脚本!
由于 Redis 单线程执行 Lua,如果你的脚本里有一个死循环,或者涉及大量的 keys 遍历(耗时超过毫秒级),整个 Redis 节点会被死死卡住,任何其他的
GET/SET 命令都进不来!
- 避免使用外部依赖和随机状态。
在 Redis 5.0 之前(按脚本复制模式),如果你的脚本里调用了获取系统时间的函数,或者生成了随机数,会导致主从节点执行结果不一致。虽然 5.0 之后引入了'效果复制'(只复制脚本引起的写命令),但尽量让脚本保持**纯函数(确定性)**依然是好习惯。
- 合理区分 KEYS 和 ARGV。
在集群模式(Redis Cluster)下,Redis 必须在解析命令时就知道这条脚本会操作哪些 Key,以便进行路由。所以,所有会被脚本操作的 Key,必须明确放到
KEYS 数组里传进去,绝对不能在脚本里面硬编码拼装 Key。