Skip to content

第八章 事务与 Lua 脚本

前面章节里,我们多数时候是「一条命令一条命令」地操作 Redis。有些场景下,你希望多条命令要么一起生效,要么都不生效——听起来像数据库里的「事务」。Redis 也提供了事务能力,但它的语义和传统关系型数据库并不相同

本章先讲清 Redis 事务怎么用、能做什么、不能做什么;再介绍 Lua 脚本,它是许多「必须原子执行」场景下的推荐做法。


8.1 Redis 事务基础

MULTI / EXEC / DISCARD 基本用法

命令作用
MULTI开启事务:之后的命令先入队,不立即执行
EXEC执行队列中所有命令,并清空事务状态
DISCARD放弃队列,不执行任何已入队的命令

典型顺序:

客户端                    Redis 服务器
   |                           |
   |-------- MULTI ---------->|  标记:进入事务
   |<-------- OK -------------|
   |                           |
   |-------- SET a 1 -------->|  入队(不执行)
   |<-------- QUEUED ----------|
   |-------- INCR b --------->|  入队
   |<-------- QUEUED ----------|
   |                           |
   |-------- EXEC ------------>|  依次执行队列中的命令
   |<-------- [OK, 1] ---------|  返回每个命令的结果

要点:从 MULTI 到 EXEC 之间,普通写读命令只是排队;真正执行发生在 EXEC 时。

命令入队 → 一次性执行的流程

        MULTI
          |
          v
    +-------------+
    |  命令入队    |  <-- SET / GET / INCR ... 都变成 QUEUED
    +-------------+
          |
    +-----+-----+
    |           |
    v           v
 DISCARD      EXEC
 (清空放弃)   (顺序执行全部)
  • 入队阶段若某条命令语法错误(Redis 在入队时就能发现),可能影响整个事务行为(见 8.3)。
  • EXEC 成功后,队列中的命令按入队顺序依次执行。

redis-cli 完整示例

在终端连接 Redis 后可直接演练:

text
127.0.0.1:6379> MULTI
OK
127.0.0.1:6379(TX)> SET book:1:stock 100
QUEUED
127.0.0.1:6379(TX)> INCR book:1:sales
QUEUED
127.0.0.1:6379(TX)> EXEC
1) OK
2) (integer) 1

redis-cli 在事务中会显示 (TX),提示当前处于事务模式。EXEC 的返回值是一个数组,与入队命令一一对应


8.2 WATCH 实现乐观锁

事务默认不知道别的客户端有没有改过你关心的键。若你要实现「读到的值没被别人改过,再提交修改」,需要 WATCH

WATCH + MULTI + EXEC 模式

1. WATCH key1 key2 ...   -- 监视这些键
2. 读数据(普通命令,在 MULTI 之外)
3. MULTI
4. 根据读到的值决定写入命令(入队)
5. EXEC
  • 若在 WATCH 之后、EXEC 之前,任意被监视的键被其他客户端修改,则 EXEC 返回空(事务不执行)。
  • 应用层收到空结果后,通常重试:重新 WATCH、再读、再 MULTI、再 EXEC。
  客户端 A                    客户端 B
      |                           |
      |-- WATCH stock ----------->|
      |-- GET stock (=10) ------->|
      |-- MULTI ----------------->|
      |-- DECRBY stock 1 (排队) ->|
      |                           |-- DECRBY stock 5 (先执行)
      |-- EXEC ----------------->|  若 stock 被 B 改过 → EXEC 失败
      |<-- (nil) 或 空数组 -------|

示例:并发扣减库存

逻辑:只有库存 ≥ 购买数量时才扣减。

text
WATCH inventory:sku001
GET inventory:sku001
# 应用判断: 若值 >= n 则:
MULTI
DECRBY inventory:sku001 n
EXEC
# 若 EXEC 返回空,说明期间库存键被改过,重试整个流程

注意:GET 与业务判断应在 MULTI 之外完成;真正修改放在 MULTIEXEC 里,这样 WATCH 才能检测到冲突。


8.3 Redis 事务的特点与局限

不支持回滚(与 MySQL 事务对比)

对比项MySQL 等关系库事务Redis 事务
ACID通常强调原子性 + 可回滚无回滚
出错后可 ROLLBACK已执行的命令不会撤销
典型用途强一致业务数据高性能缓存、简单批处理

Redis 的「原子性」含义是:EXEC 时队列里的命令连续执行,中间不会插入其他客户端的命令;并不是说「有一条失败就全部作废」。

编译错误 vs 运行错误

  • 入队前/入队时的错误(命令名错误、参数个数不对等):Redis 可能拒绝入队或标记事务有问题,导致 EXEC 整体不执行(具体行为与版本、错误类型有关,以实际为准)。
  • 运行期错误(如对字符串执行 INCR):该条命令失败,但同事务中其他已排队命令仍会执行,且没有回滚

初学者务必记住:不要把 Redis 事务当成「可回滚的银行转账」模型。

为什么 Redis 选择不支持回滚

设计上的考量大致包括:

  • 简单与性能:回滚需要日志与复杂状态,与 Redis 追求低延迟、实现简单的目标不一致。
  • 错误多为编程错误:官方文档倾向认为运行期错误应通过修复代码解决,而不是在服务器里模拟数据库式回滚。
  • 使用场景:多数缓存场景允许用业务层或 Lua 表达更精确的原子逻辑,而不是依赖通用两阶段提交。

8.4 Lua 脚本 —— 原子性操作的正确姿势

当你需要「读-判断-写在同一次原子执行中完成」,且逻辑稍复杂时,Lua 脚本往往比「事务 + WATCH」更合适:整个脚本在 Redis 里单线程执行天然原子

EVAL 命令基本语法

text
EVAL script numkeys key [key ...] arg [arg ...]
部分含义
scriptLua 源码字符串
numkeys后面紧跟的 key 参数个数
key ...脚本中通过 KEYS[1]KEYS[2] 引用
arg ...脚本中通过 ARGV[1]ARGV[2] 引用

示例(Lua 内设置一个键):

bash
EVAL "return redis.call('SET', KEYS[1], ARGV[1])" 1 mykey hello

redis.call() 在 Lua 中操作 Redis

  • redis.call('命令名', ...):调用 Redis 命令,错误会传播给 EVAL 的调用方(行为与直接执行该命令类似)。
  • redis.pcall(...):错误以 Lua 表形式返回,便于在脚本内捕获处理。

脚本中应优先使用 KEYS / ARGV 传入键名和参数,避免在脚本里硬编码键名,便于集群与维护。

示例 1:原子性释放分布式锁

场景:只有「当前持有者」才能删锁(防止误删别人的锁)。常见做法是用 Lua 保证 GET 判断与 DEL 原子

lua
-- KEYS[1]: 锁的 key
-- ARGV[1]: 期望的 token(如 UUID)
if redis.call('GET', KEYS[1]) == ARGV[1] then
  return redis.call('DEL', KEYS[1])
else
  return 0
end

调用(示意):

bash
EVAL "<上面整段脚本>" 1 mylock:user:1001 <your-uuid-token>

若不用 Lua,先 GETDEL 可能被别的客户端插队,导致安全问题。

示例 2:原子性限流

思路:用滑动窗口或简单计数器,在一次脚本里完成「读当前计数 → 判断是否超限 → 增加计数/设置过期」。

下面是一个简化示意(固定窗口计数,便于理解;生产可再优化):

lua
-- KEYS[1]: 限流 key,如 rate:user:1001
-- ARGV[1]: 窗口内最大请求数
-- ARGV[2]: 窗口秒数
local current = redis.call('GET', KEYS[1])
if current == false then
  redis.call('SET', KEYS[1], 1, 'EX', tonumber(ARGV[2]))
  return 1
end
current = tonumber(current)
if current >= tonumber(ARGV[1]) then
  return 0
end
redis.call('INCR', KEYS[1])
return 1

返回值 1 表示允许,0 表示拒绝。整段逻辑在执行期间不会被其他命令打断。

Lua 脚本 vs 事务的对比与选择

维度Redis 事务Lua 脚本
原子范围EXEC 时连续执行队列整段脚本一次跑完
条件分支困难(不能根据上一条结果在服务端分支)可以 if/while
与「读改写」常配合 WATCH,可能重试单次原子,一般无需 WATCH
复杂度适合简单批量命令适合锁、限流、库存校验等
注意点无回滚、语义不同于 SQL脚本宜短,避免长时间占用

实用建议

  • 只是「多条命令一起执行、无复杂判断」→ 事务够用。
  • 「必须先读再算再写,且中间不能被打断」→ 优先 Lua(或 Redis 6+ 的 Function,思路类似)。

本章小结

小节核心要点
8.1 事务基础MULTI 入队、EXEC 一次执行、DISCARD 放弃;redis-cli 中显示 (TX)
8.2 WATCH乐观锁;被监视键在 EXEC 前被他人修改则 EXEC 失败,常配合重试
8.3 特点与局限无回滚;编译/入队错误与运行错误行为不同;与 MySQL 事务语义不同
8.4 LuaEVAL + redis.call;释放锁、限流等「读改写原子」场景的常用方案
选型简单批量用事务;复杂原子逻辑用 Lua

下一章预告

第九章:发布/订阅将介绍 Redis 的 PUBLISHSUBSCRIBE 等能力:如何把消息从发布者送到多个订阅者,它与消息队列有什么异同,以及在使用时要注意的网络与可靠性问题。我们下一章见。

坚持是一种品格