第八章 事务与 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 后可直接演练:
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) 1redis-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) 或 空数组 -------|示例:并发扣减库存
逻辑:只有库存 ≥ 购买数量时才扣减。
WATCH inventory:sku001
GET inventory:sku001
# 应用判断: 若值 >= n 则:
MULTI
DECRBY inventory:sku001 n
EXEC
# 若 EXEC 返回空,说明期间库存键被改过,重试整个流程注意:GET 与业务判断应在 MULTI 之外完成;真正修改放在 MULTI~EXEC 里,这样 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 命令基本语法
EVAL script numkeys key [key ...] arg [arg ...]| 部分 | 含义 |
|---|---|
script | Lua 源码字符串 |
numkeys | 后面紧跟的 key 参数个数 |
key ... | 脚本中通过 KEYS[1]、KEYS[2] 引用 |
arg ... | 脚本中通过 ARGV[1]、ARGV[2] 引用 |
示例(Lua 内设置一个键):
EVAL "return redis.call('SET', KEYS[1], ARGV[1])" 1 mykey helloredis.call() 在 Lua 中操作 Redis
redis.call('命令名', ...):调用 Redis 命令,错误会传播给 EVAL 的调用方(行为与直接执行该命令类似)。redis.pcall(...):错误以 Lua 表形式返回,便于在脚本内捕获处理。
脚本中应优先使用 KEYS / ARGV 传入键名和参数,避免在脚本里硬编码键名,便于集群与维护。
示例 1:原子性释放分布式锁
场景:只有「当前持有者」才能删锁(防止误删别人的锁)。常见做法是用 Lua 保证 GET 判断与 DEL 原子。
-- 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调用(示意):
EVAL "<上面整段脚本>" 1 mylock:user:1001 <your-uuid-token>若不用 Lua,先 GET 再 DEL 可能被别的客户端插队,导致安全问题。
示例 2:原子性限流
思路:用滑动窗口或简单计数器,在一次脚本里完成「读当前计数 → 判断是否超限 → 增加计数/设置过期」。
下面是一个简化示意(固定窗口计数,便于理解;生产可再优化):
-- 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 Lua | EVAL + redis.call;释放锁、限流等「读改写原子」场景的常用方案 |
| 选型 | 简单批量用事务;复杂原子逻辑用 Lua |
下一章预告
第九章:发布/订阅将介绍 Redis 的 PUBLISH、SUBSCRIBE 等能力:如何把消息从发布者送到多个订阅者,它与消息队列有什么异同,以及在使用时要注意的网络与可靠性问题。我们下一章见。