主题
Redis 事务
一句话:Redis 事务是「一组命令连续执行、不被其他客户端插入」的批处理,不是带回滚的 ACID 事务;运行期报错不会撤销已经执行的命令。
适用版本:Redis 7.x / 8.x,脚本引擎为 Lua 5.1。
为什么需要
有些操作必须一起做:扣库存和写订单、读余额再改余额。如果分多条命令发,中间可能被别的客户端插入,读到一半的状态。数据库用事务解决这个问题;Redis 的单线程执行天然能提供「不被打断」,但它选择了另一条路:不做回滚,只保证连续执行。
理解这一点,才能解释为什么「Lua 报错了,写进去的数据还在」。
核心机制
MULTI / EXEC:排队 + 一次性执行
命令先入队不执行,EXEC 才按入队顺序串行跑完,这就是「不被插入」的全部含义。
隔离性来自单线程:EXEC 执行队列时不会插入其他命令。但原子性只到「要么都尝试执行,要么都不执行」:
| 错误类型 | 检测时机 | 行为 |
|---|---|---|
| 入队错误:命令不存在、参数个数不对 | MULTI 期间 | 标记事务失败,EXEC 返回 EXECABORT,队列里的命令一条都不执行 |
运行期错误:对 String 执行 INCR、类型不匹配 | EXEC 期间 | 该命令返回错误,其余命令继续执行,已执行的写入不回滚 |
WATCH:乐观锁(CAS)
WATCH key 记录 key 的版本;EXEC 时如果该 key 在 WATCH 之后被其他客户端修改,整个事务放弃,EXEC 返回 nil。客户端通常重试「WATCH → 读 → 计算 → MULTI → EXEC」。
WATCH是连接级的;EXEC、DISCARD、UNWATCH都会清除已监视的 key。- 高并发下冲突会频繁重试,吞吐下降;需要条件判断的原子操作优先用 Lua。
Lua:原子执行,但不是回滚
整个脚本在执行期间不会被其他命令插入,这一点和 MULTI/EXEC 相同。区别在于脚本可以读、判断、再写,能做「库存不足就不扣」这类条件逻辑。
关键边界:
- 编译错误(语法错、脚本加载失败):脚本根本不执行。
- 运行期错误(
redis.call里的命令报错、对错误类型操作):脚本中止并把错误返回客户端,但之前已经执行的写命令保留。Redis 没有 undo log,不会回滚。 redis.pcall会捕获错误并继续执行后续逻辑;redis.call遇到错误直接中止脚本。- 脚本执行超过
lua-time-limit(默认5000ms)后,Redis 开始对普通客户端返回BUSY,但不会自动杀掉脚本。只有脚本尚未执行写命令时才能SCRIPT KILL;已经写过就只能SHUTDOWN NOSAVE。 - Cluster 下脚本访问的所有 key 必须在同一个 slot,通常用 hash tag 保证。
Pipeline、MULTI/EXEC、Lua 的区别
| 能力 | Pipeline | MULTI/EXEC | Lua |
|---|---|---|---|
| 减少 RTT | 是 | 是 | 是(一条命令) |
| 不被其他命令插入 | 否 | 是 | 是 |
| 条件判断 | 否 | 否(要配合 WATCH) | 是 |
| 运行期错误回滚 | 否 | 否 | 否 |
| 适合 | 批量无依赖读写 | 简单批量 + CAS | 复杂原子逻辑 |
最小上手
用 redis-cli 直接观察「运行期错误不回滚」:
bash
redis-cli
SET k v
MULTI
INCR k # k 不是整数,EXEC 时会报错
SET k2 ok
EXEC # 返回 [error, OK]
GET k2 # "ok":第二条命令仍然执行了Lua 同样不回滚:
bash
EVAL "redis.call('SET', KEYS[1], 'a'); redis.call('INCR', KEYS[1]); return 1" 1 lua:k
# 返回错误;但 lua:k 的值已经是 "a"
GET lua:kWATCH 的典型用法:
bash
WATCH balance
GET balance
MULTI
SET balance 150
EXEC # 若 balance 在 WATCH 后被其他客户端改过,返回 nil生产动作
- 需要「读-判断-写」原子性时用 Lua,不要用
MULTI/EXEC拼条件;WATCH只适合冲突不高的 CAS。 - 脚本要短:Lua 执行期间主线程被独占,禁止在脚本里做循环、网络请求或大量 key 遍历。
- 控制
lua-time-limit:默认 5000 ms;调大只会让阻塞更久,不能解决慢脚本。 - 入队错误在开发期暴露:
EXECABORT说明命令写错了,不要在生产靠重试兜底。 - Cluster 下用 hash tag:
{user:1}:balance、{user:1}:orders才能落在同一 slot。 - 不要把 Redis 事务当数据库事务:没有回滚、没有可选的隔离级别(
EXEC期间天然串行),持久性取决于 AOF 配置。
边界
- 没有回滚:运行期错误只影响当前命令,之前的写入保留;需要「全成功或全失败」只能靠业务补偿或换支持事务的存储。
WATCH是乐观锁:冲突多时重试成本高,且它监视的是 key 是否被改,不提供锁的互斥语义。- Lua 不是万能事务:脚本内写了一半报错同样不回滚;脚本还会阻塞主线程,长时间运行会拖垮整个实例。
MULTI/EXEC不保证持久:EXEC返回成功时,数据可能还在 AOF 缓冲区,everysec下仍可能丢约 1 秒。- Cluster 限制:跨 slot 的事务和脚本都不支持,设计 key 时就要考虑。
讲清楚
Redis 事务解决的是「一组命令连续执行、中间不被别人插入」,不是「失败就回滚」。
MULTI/EXEC在入队阶段发现命令写错会整组放弃,但执行阶段某条命令报错,只会跳过它,前面的写入照样生效。Lua 也是一样:脚本整体不被插入,但脚本跑到一半报错,已经写进去的数据不会撤销。所以用 Redis 做原子逻辑,重点不是找「回滚」,而是把「检查 + 修改」放进同一个 Lua,保证没有中间状态被读到;真要跨步骤失败恢复,得靠业务补偿,或者换支持事务的数据库。