Lua 脚本深入
提出问题
Redis 提供了 MULTI/EXEC 事务和 Lua 脚本两种"原子操作"手段,但很多开发者并不清楚它们之间的真正分界:为什么有了事务还要引入 Lua?Lua 脚本的"原子性"到底意味着什么?在集群模式下,脚本执行又有哪些坑?生产环境中,一个 Lua 脚本写不好,可能造成整个 Redis 实例阻塞数秒。这些问题不仅是面试高频考点,更是日常开发中容易踩雷的地方。
分析问题
Lua 脚本的原子性保证
Redis 是单线程命令处理器,EVAL 执行 Lua 脚本时,脚本内部的全部命令会连续执行,不会被其他客户端的命令插入。这种原子性本质上是"单线程执行 + 不触发事件循环"的产物。
但需要区分两个维度:
- 原子执行:脚本执行期间不会有其他命令插入,这是 Redis 单线程模型天然保证的
- 原子提交:脚本内部可以包含条件判断(if/else)、循环、甚至
redis.log调试输出,但 Redis 不支持回滚——如果脚本前半段写入了数据,后半段崩溃,前面已写入的数据不会自动撤销
-- 这个脚本展示"原子执行"但"不可回滚"的特性
-- 如果 KEYS[1] 是字符串类型,redis.call('INCR', KEYS[1]) 会报错
-- 但前面的 SET 操作不会被撤销
redis.call('SET', KEYS[1], 'hello')
redis.call('SET', KEYS[2], 'world')
redis.call('INCR', KEYS[1]) -- 这里会报错,但 KEYS[1] 和 KEYS[2] 已经被写入了
return 'partial write done'生产经验:在 Lua 脚本里做写操作前,先做充分的数据类型校验,避免执行到一半才报错。脚本前段应该做"验证"而非"写入"。
脚本阻塞的量化分析
这是面试中容易被追问的点。Redis 官方文档说"脚本应该快速执行,否则会阻塞所有其他操作",但"快"到底多快?
关键数字:
- Redis 默认
lua-time-limit是 5000 毫秒 - 超过这个时间,Redis 不会自动终止脚本,而是开始接受其他客户端的
SCRIPT KILL命令 - 如果脚本已经执行了写操作,
SCRIPT KILL也无效,只能SHUTDOWN NOSAVE重启实例 - 一个 10 万次循环的 Lua 脚本,在 2.5GHz CPU 上大约耗时 200-300ms,期间 Redis 完全无法响应任何其他请求
真实事故案例:某电商公司双十一期间,库存扣减脚本里写了 redis.call('KEYS', 'stock:*')。KEYS 命令本身是 O(N) 操作,生产环境 stock:* 前缀下有 50 万个 key,脚本执行耗时 1.8 秒。这 1.8 秒里,该 Redis 实例上所有其他请求(包括用户会话查询、商品缓存读)全部排队等待,导致上游服务雪崩式超时。
-- 错误范例:脚本内使用 KEYS 命令,O(N) 扫全库
-- 1.8 秒阻塞,RTO 内无法恢复
local keys = redis.call('KEYS', 'stock:*')
for _, k in ipairs(keys) do
local v = redis.call('GET', k)
-- 处理逻辑...
end
-- 正确做法:把 key 列表通过 KEYS 参数传入
-- 脚本只做原子操作,不扫库
-- KEYS = {user:1000, user:1001, ...} 由客户端提供
for _, key in ipairs(KEYS) do
local v = redis.call('GET', key)
-- 处理逻辑...
end脚本执行时间底线:生产环境 Lua 脚本应控制在 10ms 以内。超过 50ms 的脚本必须做性能复审。监控手段:Redis 的 slowlog 会记录脚本执行耗时,配置 slowlog-log-slower-than 10000(10ms)即可捕获慢脚本。
KEYS 与 ARGV 的传参机制
Lua 脚本通过 EVAL 接收两个数组:KEYS 和 ARGV。这不仅是风格问题,更是 Redis 集群模式下的强制要求。
-- 正确的写法
-- KEYS[1] = 库存 key, KEYS[2] = 订单 key
-- ARGV[1] = 扣减数量, ARGV[2] = 订单号
local stock = tonumber(redis.call('GET', KEYS[1]))
if not stock or stock < tonumber(ARGV[1]) then
return 0
end
redis.call('DECRBY', KEYS[1], ARGV[1])
redis.call('SET', KEYS[2], ARGV[2])
return 1# 调用方式
EVAL <script> 2 stock:001 order:001 5 "ORD20260721"
# ↑ key 的数量为什么必须用 KEYS 而不是在脚本里硬编码 key?
- 集群模式:Redis Cluster 根据 key 的 hash slot 决定脚本路由到哪个节点。如果脚本里硬编码了
redis.call('GET', 'stock:001'),Redis 只能在执行时才知道它访问了哪些 key,无法在路由阶段做 hash slot 检查。而通过KEYS传参,集群代理可以用key中的{hash_tag}做 slot 计算,确保所有 key 落在同一个节点 - 主从复制:脚本的
KEYS参数被记录在 AOF 和复制流中,从库可以正确重放
key 的分布限制:集群模式下,EVAL 脚本所有 KEYS 必须属于同一个 hash slot。如果脚本需要操作多个不同 slot 的 key,必须使用 hash tag({tag})强制它们落在同一个 slot,或者拆分为多个单 key 脚本。这也是为什么 Redis 官方建议"脚本尽量只操作一个 key 或少量的同 slot key"。
EVAL / EVALSHA / SCRIPT LOAD 的完整流程
脚本的执行有三种方式,理解它们的区别对性能优化至关重要:
# 1. EVAL — 每次传完整脚本
EVAL "return redis.call('GET', KEYS[1])" 1 mykey
# 2. SCRIPT LOAD — 预加载脚本,得到 SHA
SCRIPT LOAD "return redis.call('GET', KEYS[1])"
# 返回: "2b1c5c99b2a7e5b8e5b8e5b8e5b8e5b8e5b8e5b8"
# 3. EVALSHA — 用 SHA 执行,避免传输脚本内容
EVALSHA 2b1c5c99b2a7e5b8e5b8e5b8e5b8e5b8e5b8e5b8 1 mykeyEVALSHA 的注意事项:如果 Redis 重启(或脚本被 SCRIPT FLUSH 清除),之前缓存的 SHA 就失效了。调用 EVALSHA 会返回 NOSCRIPT 错误,此时客户端需要 fallback 回 EVAL(EVAL 会自动重新缓存脚本)。
// Java 客户端中的最佳实践:Jedis 自动处理了 NOSCRIPT fallback
String sha = jedis.scriptLoad(script);
try {
jedis.evalsha(sha, keys, args);
} catch (JedisNoScriptException e) {
// fallback 到 EVAL,顺便重新缓存
jedis.eval(script, keys, args);
}Spring Boot 集成 Lua 脚本的完整示例:
@Component
public class RedisLuaScriptRunner implements CommandLineRunner {
private static final String LUA_STOCK_DEDUCT =
"local stock = tonumber(redis.call('GET', KEYS[1])) " +
"if not stock or stock < tonumber(ARGV[1]) then " +
" return 0 " +
"end " +
"redis.call('DECRBY', KEYS[1], ARGV[1]) " +
"return 1";
private final StringRedisTemplate redisTemplate;
private final DefaultRedisScript<Long> stockScript;
public RedisLuaScriptRunner(StringRedisTemplate redisTemplate) {
this.redisTemplate = redisTemplate;
this.stockScript = new DefaultRedisScript<>();
this.stockScript.setScriptText(LUA_STOCK_DEDUCT);
this.stockScript.setResultType(Long.class);
}
@Override
public void run(String... args) {
// 应用启动时预热:SCRIPT LOAD 缓存脚本
redisTemplate.execute((RedisCallback<Object>) connection -> {
connection.scriptLoad(LUA_STOCK_DEDUCT.getBytes());
return null;
});
log.info("Lua stock script loaded to Redis");
}
/**
* 扣减库存,原子操作
* @param stockKey 库存 key
* @param quantity 扣减数量
* @return true 扣减成功,false 库存不足
*/
public boolean deductStock(String stockKey, int quantity) {
Long result = redisTemplate.execute(
stockScript,
List.of(stockKey),
String.valueOf(quantity)
);
return Long.valueOf(1).equals(result);
}
}脚本缓存的生命周期:Redis 使用 LRU 淘汰脚本缓存(每个实例最多缓存约 256 个脚本)。如果脚本数量超过限制,最久未使用的脚本会被淘汰。生产环境建议在应用启动时通过 SCRIPT LOAD 预热所有脚本,并用 SCRIPT EXISTS 定期检查缓存是否还在。
替代 MULTI/EXEC 的场景分析
Lua 脚本能覆盖多数 MULTI/EXEC 的使用场景,并且做得更好:
| 场景 | MULTI/EXEC | Lua 脚本 | 推荐 |
|---|---|---|---|
| 批量 SET 无依赖 | ✅ 可用 | ✅ 杀鸡用牛刀 | MULTI |
| 条件性操作(if-then) | ❌ 不支持,WATCH 重试 | ✅ 原生支持 | Lua |
| 读取 → 计算 → 写入 | ❌ 需要 WATCH+CAS 重试 | ✅ 脚本内完成 | Lua |
| 循环/复杂逻辑 | ❌ 不支持 | ✅ 支持 | Lua |
| 自定义返回值 | ❌ 返回所有命令结果 | ✅ 自由控制 | Lua |
| 非常简单的原子操作 | ✅ 简洁直观 | ✅ 也可 | 看习惯 |
一个典型的替代场景:Redis 分布式锁的释放。用 MULTI/EXEC 需要配合 WATCH 做 CAS,而 Lua 脚本一行就能解决:
-- 原子释放锁:只有 value 匹配时才删除
-- KEYS[1] = lock key, ARGV[1] = 持有者标识
if redis.call('GET', KEYS[1]) == ARGV[1] then
return redis.call('DEL', KEYS[1])
end
return 0# 这是 Redisson 等分布式锁框架内部实际使用的脚本
# 相比 WATCH + MULTI 方案,更简洁、更可靠
EVAL <script> 1 my:lock "client-001"集群模式下的 key 分布限制
这是 Lua 脚本在生产环境中最容易踩的坑。Redis Cluster 要求:
EVAL/EVALSHA中所有KEYS参数必须属于同一个 hash slot- 脚本内部
redis.call()操作的 key 也必须属于同一个 slot - 如果用
KEYS数组传入的 key 不在同一个 slot,Redis 会返回CROSSSLOT错误
# 假设 key1 在 slot 1234,key2 在 slot 5678
EVAL "redis.call('GET', KEYS[1]); redis.call('GET', KEYS[2])" 2 key1 key2
# 错误: (error) CROSSSLOT Keys in request don't hash to the same slot解决方案:
- hash tag:
{user:1000}:cart和{user:1000}:orders因花括号内的user:1000落在同一个 slot - 拆分脚本:把多 key 操作拆成多个单 key 脚本,在客户端协调
- 本地变量:如果脚本内部计算的 key 不依赖外部入参,可以通过
KEYS[1]衍生出同 slot 的 key
-- 利用 hash tag 让多个 key 落在同 slot
-- 实际使用时确保所有 key 包含相同的 hash tag
-- KEYS[1] = "user:{1000}:cart", KEYS[2] = "user:{1000}:orders"
-- 这样两个 key 保证落在同一个 slot
local cart = redis.call('HGETALL', KEYS[1])
local orders = redis.call('HGETALL', KEYS[2])
-- 两个 key 都包含 {1000},共享同一 slot生产踩坑案例
坑 1:随机性导致主从不一致
redis.call('SPOP', KEYS[1]) 或 redis.call('SRANDMEMBER', KEYS[1]) 在脚本内部使用时,每次执行结果可能不同。Redis 复制时从库会重新执行脚本,但 SPOP 的随机性可能导致主从数据不一致。Redis 5.0 之前通过 redis.replicate_commands() 解决,Redis 5.0+ 默认开启。
-- 在 Redis 5.0+ 中,使用随机命令是安全的(默认开启 replicate commands)
-- 但在 Redis 5.0 以下,必须显式声明
redis.replicate_commands() -- Redis 5.0 以下需要这行
local member = redis.call('SRANDMEMBER', KEYS[1], 1)
-- 从库会复制命令的效果而非命令本身坑 2:脚本超时与 SHUTDOWN NOSAVE
某海外社交平台在高峰时段执行了一个 Lua 脚本,循环中调用了 100 万次 redis.call('INCR', ...)。脚本执行了 8 秒,期间所有用户请求无法响应。运维发现 SCRIPT KILL 无效(脚本已执行写操作),最终只能 SHUTDOWN NOSAVE 重启,丢失了该实例上所有未持久化的数据。RPO 损失约 30 秒数据。
复盘教训:
- 脚本中的循环必须有上限,建议用
redis.setresp(3)配合redis.pcall捕获异常,但循环次数仍应控制在 1000 以内 lua-time-limit配置只是一个"软限制",超时后只允许SCRIPT KILL,不能自动终止- 写操作的脚本必须做充分的前置校验,避免执行到一半才报错
坑 3:EVALSHA 的 NOSCRIPT 在 Sentinel 切换后的雪崩
某团队在 Redis Sentinel 模式下使用 EVALSHA 执行脚本,正常情况下运行良好。某次 Sentinel 主从切换后,新主库没有缓存脚本,所有 EVALSHA 调用全部返回 NOSCRIPT。客户端 Jedis 的 fallback 机制在重试时逐条 fallback 到 EVAL,每秒数万次 NOSCRIPT 错误被记录到日志,导致日志系统 write 风暴,磁盘 IO 打满。
解决方案:应用程序启动时,通过 RedisConnectionFactory 获取新连接后主动执行一次 SCRIPT LOAD。Sentinel 切换后,jedis 或 lettuce 的 TopologyRefresh 会建立新连接,应用需监听 ConnectedEvent 重新预热脚本。
总结
- Lua 脚本的原子性是"单线程连续执行不被中断",但不可回滚,写操作前应先做校验
- 脚本执行时间应控制在 10ms 以内,超过 50ms 必须复审;避免在脚本内使用
KEYS、SCAN等 O(N) 命令 KEYS传参是集群模式下的强制要求,不能用硬编码 key 替代;EVALSHA配合SCRIPT LOAD可减少网络传输- 集群环境下所有
KEYS必须属于同一 hash slot,否则报CROSSSLOT;使用 hash tag 或拆分脚本解决 - Lua 脚本在条件判断、读取-计算-写入、自定义返回值等场景全面优于 MULTI/EXEC,是分布式锁、限流、库存扣减等场景的首选方案
- 生产部署建议:应用启动时
SCRIPT LOAD预热脚本,客户端实现EVALSHA→EVAL的 NOSCRIPT fallback 逻辑,监听 Sentinel 切换事件重新加载脚本 - 主从复制场景下使用随机命令(
SPOP、SRANDMEMBER)需确认 Redis 版本 ≥ 5.0,否则需显式调用redis.replicate_commands()
参考
参考:Redis 官方文档 — Scripting with Lua、Redis 源码
src/script.c、src/cluster.c中关于 CROSSSLOT 检查的逻辑、Redislua-time-limit配置说明