Skip to content

Lua 脚本深入

提出问题

Redis 提供了 MULTI/EXEC 事务和 Lua 脚本两种"原子操作"手段,但很多开发者并不清楚它们之间的真正分界:为什么有了事务还要引入 Lua?Lua 脚本的"原子性"到底意味着什么?在集群模式下,脚本执行又有哪些坑?生产环境中,一个 Lua 脚本写不好,可能造成整个 Redis 实例阻塞数秒。这些问题不仅是面试高频考点,更是日常开发中容易踩雷的地方。

分析问题

Lua 脚本的原子性保证

Redis 是单线程命令处理器,EVAL 执行 Lua 脚本时,脚本内部的全部命令会连续执行,不会被其他客户端的命令插入。这种原子性本质上是"单线程执行 + 不触发事件循环"的产物。

但需要区分两个维度:

  • 原子执行:脚本执行期间不会有其他命令插入,这是 Redis 单线程模型天然保证的
  • 原子提交:脚本内部可以包含条件判断(if/else)、循环、甚至 redis.log 调试输出,但 Redis 不支持回滚——如果脚本前半段写入了数据,后半段崩溃,前面已写入的数据不会自动撤销
lua
-- 这个脚本展示"原子执行"但"不可回滚"的特性
-- 如果 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 实例上所有其他请求(包括用户会话查询、商品缓存读)全部排队等待,导致上游服务雪崩式超时。

lua
-- 错误范例:脚本内使用 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 接收两个数组:KEYSARGV。这不仅是风格问题,更是 Redis 集群模式下的强制要求

lua
-- 正确的写法
-- 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
bash
# 调用方式
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 的完整流程

脚本的执行有三种方式,理解它们的区别对性能优化至关重要:

bash
# 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 mykey

EVALSHA 的注意事项:如果 Redis 重启(或脚本被 SCRIPT FLUSH 清除),之前缓存的 SHA 就失效了。调用 EVALSHA 会返回 NOSCRIPT 错误,此时客户端需要 fallback 回 EVALEVAL 会自动重新缓存脚本)。

java
// 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 脚本的完整示例

java
@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/EXECLua 脚本推荐
批量 SET 无依赖✅ 可用✅ 杀鸡用牛刀MULTI
条件性操作(if-then)❌ 不支持,WATCH 重试✅ 原生支持Lua
读取 → 计算 → 写入❌ 需要 WATCH+CAS 重试✅ 脚本内完成Lua
循环/复杂逻辑❌ 不支持✅ 支持Lua
自定义返回值❌ 返回所有命令结果✅ 自由控制Lua
非常简单的原子操作✅ 简洁直观✅ 也可看习惯

一个典型的替代场景:Redis 分布式锁的释放。用 MULTI/EXEC 需要配合 WATCH 做 CAS,而 Lua 脚本一行就能解决:

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
bash
# 这是 Redisson 等分布式锁框架内部实际使用的脚本
# 相比 WATCH + MULTI 方案,更简洁、更可靠
EVAL <script> 1 my:lock "client-001"

集群模式下的 key 分布限制

这是 Lua 脚本在生产环境中最容易踩的坑。Redis Cluster 要求:

  1. EVAL/EVALSHA 中所有 KEYS 参数必须属于同一个 hash slot
  2. 脚本内部 redis.call() 操作的 key 也必须属于同一个 slot
  3. 如果用 KEYS 数组传入的 key 不在同一个 slot,Redis 会返回 CROSSSLOT 错误
bash
# 假设 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
lua
-- 利用 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+ 默认开启。

lua
-- 在 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 必须复审;避免在脚本内使用 KEYSSCAN 等 O(N) 命令
  • KEYS 传参是集群模式下的强制要求,不能用硬编码 key 替代;EVALSHA 配合 SCRIPT LOAD 可减少网络传输
  • 集群环境下所有 KEYS 必须属于同一 hash slot,否则报 CROSSSLOT;使用 hash tag 或拆分脚本解决
  • Lua 脚本在条件判断、读取-计算-写入、自定义返回值等场景全面优于 MULTI/EXEC,是分布式锁、限流、库存扣减等场景的首选方案
  • 生产部署建议:应用启动时 SCRIPT LOAD 预热脚本,客户端实现 EVALSHAEVAL 的 NOSCRIPT fallback 逻辑,监听 Sentinel 切换事件重新加载脚本
  • 主从复制场景下使用随机命令(SPOPSRANDMEMBER)需确认 Redis 版本 ≥ 5.0,否则需显式调用 redis.replicate_commands()

参考

参考:Redis 官方文档 — Scripting with Lua、Redis 源码 src/script.csrc/cluster.c 中关于 CROSSSLOT 检查的逻辑、Redis lua-time-limit 配置说明

手撕 → 框架 → 生产化,一步步把 AI Agent 工程化搞透。