跳到正文

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(默认 5000 ms)后,Redis 开始对普通客户端返回 BUSY,但不会自动杀掉脚本。只有脚本尚未执行写命令时才能 SCRIPT KILL;已经写过就只能 SHUTDOWN NOSAVE。
  • Cluster 下脚本访问的所有 key 必须在同一个 slot,通常用 hash tag 保证。

Pipeline、MULTI/EXEC、Lua 的区别 ​

能力PipelineMULTI/EXECLua
减少 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:k

WATCH 的典型用法:

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,保证没有中间状态被读到;真要跨步骤失败恢复,得靠业务补偿,或者换支持事务的数据库。

把一门技术讲透到既懂也会用