Redis Lua 脚本的原子性:为什么 Lua 脚本能保证原子性,与事务 MULTI/EXEC 的区别

在 Redis 中,原子性 是指一组操作要么全部执行成功,要么全部不执行,中间状态对外不可见。Lua 脚本与 Redis 内置事务(MULTI/EXEC)都能实现原子性,但底层机制和适用场景存在显著差异。理解这些差异,是编写可靠限流脚本的关键。

1. Lua 脚本的原子性原理

Redis 使用单线程事件循环处理命令。当执行一个 Lua 脚本时,Redis 会将该脚本作为一个整体放入事件循环中,在脚本执行完毕之前,不会处理任何其他客户端命令、AOF 写入或复制操作。这种“阻塞式单次执行”机制保证了原子性:

  • 脚本内所有 Redis 命令(如 redis.call()、redis.pcall())在脚本执行期间按顺序执行,不会被其他命令插入。
  • 脚本执行过程中,其他客户端看到的数据状态要么是脚本执行前的状态,要么是脚本执行后的状态,不存在中间状态。
  • 脚本执行失败(如运行时错误、语法错误)时,Redis 不会回滚已执行的写操作,但会丢弃脚本的后续命令,并返回错误给调用方。由于脚本是原子执行的,失败时已执行的操作仍然可见,这与传统数据库事务的“全有或全无”略有不同。
关键点: Lua 脚本的原子性来源于 Redis 单线程模型 + 脚本的阻塞执行。脚本执行期间,Redis 不处理任何其他请求,因此脚本内的所有操作天然不可分割。

2. MULTI/EXEC 事务的原子性原理

Redis 事务通过 MULTI、EXEC、DISCARD、WATCH 命令实现。其原子性机制与 Lua 脚本不同:

  • 命令入队阶段: 客户端发送 MULTI 后,后续命令不会立即执行,而是被放入一个队列中。
  • 执行阶段: 客户端发送 EXEC 时,Redis 会一次性、顺序地执行队列中的所有命令。在执行期间,同样不会被其他客户端命令插入。
  • 原子性保证: 事务内的所有命令要么全部执行,要么全部不执行(如 DISCARD 或 WATCH 监测的 key 被修改导致事务取消)。
  • 不支持回滚: 如果事务中某个命令执行失败(如类型错误),Redis 不会回滚已成功的命令,而是继续执行后续命令。这与 Lua 脚本的行为一致。

3. 核心区别对比

对比维度 Lua 脚本 MULTI/EXEC 事务
执行模型 脚本整体作为一个任务提交,Redis 解析并执行整个脚本 命令先入队,EXEC 时批量执行
原子性范围 整个脚本(包含逻辑判断、循环、计算) 仅限 EXEC 时执行的命令队列
逻辑能力 支持 if/else、循环、变量、函数等完整编程逻辑 无逻辑能力,只能顺序执行预定义命令
条件执行 脚本内可根据运行时状态决定是否执行某条 Redis 命令 无法根据中间结果动态决定后续命令
网络往返 一次脚本提交,一次结果返回 MULTI + N 条命令 + EXEC + 结果读取,多次往返
错误处理 redis.pcall() 可捕获错误,脚本可自定义处理逻辑 命令失败后继续执行后续命令,无法自定义处理
WATCH 乐观锁 不支持(脚本内可直接读取最新值做判断) 支持,用于检测 key 是否被其他客户端修改
典型场景 限流、计数器、分布式锁、复杂数据操作 简单批量写入、保证多个 key 同时更新

4. 为什么限流脚本必须用 Lua?

限流场景要求“读取当前计数 → 判断是否超限 → 更新计数”这三个步骤必须原子执行。如果使用 MULTI/EXEC,由于事务内无法根据读取结果动态决定是否执行后续命令,你无法实现“如果超过阈值则拒绝请求”的逻辑。而 Lua 脚本可以在脚本内完成判断和条件写入:

-- 限流脚本示例:滑动窗口计数
local key = KEYS[1]
local now = tonumber(ARGV[1])
local window = tonumber(ARGV[2])
local limit = tonumber(ARGV[3])

-- 移除窗口外的旧记录
redis.call('ZREMRANGEBYSCORE', key, 0, now - window)

-- 获取当前窗口内的请求数
local current = redis.call('ZCARD', key)

if current < limit then
    -- 未超限,添加当前请求并设置过期时间
    redis.call('ZADD', key, now, now)
    redis.call('EXPIRE', key, window)
    return 1  -- 允许请求
else
    return 0  -- 拒绝请求
end

这个脚本在 Redis 中原子执行,不会出现多个客户端同时读到 current < limit 为 true 而同时放行的情况。

注意: Lua 脚本虽然原子执行,但不会自动回滚已执行的写操作。如果脚本中途出错,已执行的 ZREMRANGEBYSCORE 和 ZADD 操作仍然生效。因此,在编写脚本时,应尽量将写操作放在脚本末尾,或使用 redis.pcall() 捕获异常并手动处理。

5. 原子性执行流程对比图

下图展示了 Lua 脚本与 MULTI/EXEC 事务在 Redis 单线程模型下的执行流程差异:

Lua 脚本 vs MULTI/EXEC 事务执行流程对比 Lua 脚本 1. 客户端发送 EVALSHA 脚本 2. Redis 加载并解析脚本 3. 脚本内 redis.call() 顺序执行 4. 脚本内逻辑判断 / 条件执行 5. 返回结果给客户端 6. 脚本执行完毕,释放事件循环 MULTI/EXEC 事务 1. 客户端发送 MULTI 2. 后续命令入队(不执行) 3. 客户端发送 EXEC 4. 队列中命令顺序执行 5. 返回所有命令结果 6. 事务结束,释放事件循环 Lua 脚本在步骤 3-4 之间可包含任意逻辑判断;MULTI/EXEC 在步骤 2 入队后无法改变执行计划

6. 选择建议

  • 优先使用 Lua 脚本 当需要条件判断、循环、复杂计算或根据中间结果决定后续操作时(如限流、分布式锁、计数器)。
  • 使用 MULTI/EXEC 当只需要批量执行一组独立命令,且不需要根据中间结果做决策时(如同时设置多个 key 的过期时间)。
  • 避免混合使用 不要在事务中调用 Lua 脚本,也不要在 Lua 脚本中开启事务,这会增加复杂性和性能开销。
  • 注意脚本超时 Lua 脚本默认最长执行 5 秒(可通过 lua-time-limit 配置),超时后 Redis 会强制终止脚本并返回错误。限流脚本应保持轻量,避免长时间循环。
总结: Lua 脚本的原子性 + 逻辑能力使其成为实现限流、缓存保护等复杂业务逻辑的首选方案。MULTI/EXEC 事务更适合简单批量操作。在限流场景中,Lua 脚本是唯一能保证“读取-判断-写入”原子性的正确选择。