Redis 事务、WATCH 与 Lua:原子性到底到哪里

拆解 MULTI/EXEC 的排队执行与无回滚、WATCH 的乐观锁 check-and-set、Lua 脚本的原子执行与条件逻辑,说清 Redis 的"原子性"是执行不被穿插而不是 ACID 的可回滚,并覆盖脚本复制、EVALSHA 缓存和 Cluster 同槽限制。

两个客户端同时给同一个 Key 做“读余额、扣款、写回”。如果这段逻辑由应用端分三条命令完成,两个请求可能都读到 100、都扣 10、都写回 90——少扣了一次钱。要避免这种竞争,Redis 提供了事务(MULTI/EXEC)、乐观锁(WATCH)和 Lua 脚本三种机制。

但“事务”这个词在 Redis 里和关系型数据库里不是一个意思。数据库的 ACID 事务强调“要么全做、要么全不做”,出错可以回滚;Redis 的 MULTI/EXEC 只是把命令排队、执行时不穿插别的客户端,并不回滚。于是最常见的误解是:以为 Redis 事务失败会自动撤销,结果线上出现“事务里前两条命令生效、第三条失败”却没人意识到。

本文回答一个问题:MULTI、WATCH 和 Lua 各自提供什么程度的原子性,边界到底在哪里?答案可以提前说清:Redis 的“原子性”是执行期间不被其他命令穿插(隔离),不是 ACID 的“可回滚”。MULTI/EXEC 给隔离没有回滚,Lua 给更强的隔离(脚本里能写条件和循环)仍没有回滚,WATCH 给的是乐观并发控制、靠重试而不是锁。主要依据是 Redis 官方命令文档与脚本/事务说明,托管版行为以各自文档为准。

一、先把“原子性”拆成两层

“原子”在并发语境里有两种容易被混在一起的含义。一种是不可分割:一组操作要么全部发生,要么全部不发生,中途失败能退回原状——这是数据库事务的 A(Atomicity),靠回滚日志实现。另一种是不穿插:一组操作执行期间,其他操作不能插进来——这是隔离性(Isolation)的一部分。

Redis 的原子性主要指第二种。单条命令天然原子:因为普通命令大多在一条主执行路径上串行执行,一个 INCR 执行时不会有另一个命令插进来改同一个 Key。MULTI/EXEC 和 Lua 做的事情,是把“单条命令的原子性”扩展到“一组命令的原子性”:这一组命令作为一个整体,执行时不被其他客户端穿插。它不提供第一种——Redis 没有回滚日志,一组命令执行到一半失败,前面已经执行的不会撤销。

这个区别决定了所有后续判断。问“Redis 事务能不能回滚”,答案是能部分地“不执行”,不能“执行了再撤销”。分清这两件事,比记住命令语法更重要。

用一个具体场景区分两者:A 给 B 转账 拆成 A 扣 100、B 加 100 两条命令。MULTI/EXEC 保证这两条命令执行时不会被别的请求穿插(不穿插);但如果第一条成功、第二条因为 B 的 Key 类型错误而失败,A 的 100 已经扣掉、B 没加上(不回滚)。同样的两条命令,数据库事务会把 A 的扣款也一并撤销(可回滚),Redis 不会。差别不在语法,就在这一层。

二、MULTI/EXEC:排队执行、不穿插、不回滚

MULTI 之后的命令不会立即执行,而是被排队;EXEC 时整队按顺序一次性执行,期间不处理其他客户端的命令。DISCARD 清空队列、退出事务。

MULTI
SET account:1:balance 90
INCRBY account:1:paid 10
EXEC
# 两条命令作为一个整体执行,中间不穿插别的客户端

执行期间的“不穿插”是真实的隔离保证:另一个客户端在这两条命令之间发起任何请求,都只能等整队跑完。但“错误”的处理方式会打破很多人对事务的预期。Redis 区分两种错误。

入队时错误:命令本身写错了,比如参数个数不对、命令不存在。这类错误在 MULTI 排队阶段就被发现,EXEC 会直接返回错误、整队不执行。这是最接近“回滚”的一种情况,但它发生在执行之前。

MULTI
SET k1 v1
INCR            # 缺参数,入队即报错
EXEC
# EXEC 返回 EXECABORT,k1 也没有被设置

执行时错误:命令语法对、队列也排好了,但执行时对不上数据,比如对 String 类型的 Key 执行 LPUSH。这类错误不会回滚,失败的那条返回错误,其余命令照常执行、照常生效。

SET counter "abc"     # counter 是 String
MULTI
INCR counter          # 执行时报 WRONGTYPE
SET k2 v2
EXEC
# INCR 返回错误,但 k2 仍然被设置;没有回滚

官方文档把这个行为点得很明确:Redis 事务不支持回滚,原因是“支持回滚会显著拖慢 Redis”。所以 EXEC 返回一个包含错误的数组,不意味着“整组失败”。业务如果依赖“要么都成功要么都失败”,就不能用 MULTI/EXEC 来保证,得靠 Lua 脚本里的显式条件判断,或者把不变量设计成单条命令就能维护。

MULTI/EXEC 入队错误与执行错误的两种结局

这张图要记住的是“错误发生在哪个阶段”。入队错误是语法问题,Redis 在执行前就能拒绝,整队作废;执行错误是数据问题,命令已经排进队列、开始执行,Redis 不会为了它把前面已经生效的写撤销掉。判断一个 MULTI/EXEC 是否安全,不能只看它“是不是事务”,要看每条命令在它自己的类型和参数约束下,是否真的可能运行时失败。

“不穿插”这个保证来自 Redis 的串行执行模型:EXEC 时整队命令在同一个事件循环里连续执行完,中间不回到事件循环去处理其他连接,因此其他客户端的命令插不进来。这也是为什么把一长串慢命令塞进一个事务,会像一条慢命令一样拖住所有请求——原子隔离和队头阻塞是同一枚硬币的两面。

三、WATCH:用乐观锁做 check-and-set

MULTI/EXEC 只能保证“执行时不穿插”,不能解决“执行前读到的值已经变了”。典型的 check-and-set 是:先 GET 看余额,判断够不够,再在事务里扣款。两个客户端都读到 100,各自判断够,各自在事务里扣 10,结果仍是各扣各的——MULTI/EXEC 没拦住这种“读与写之间被改”的窗口。

WATCH 就是堵这个窗口的乐观锁。WATCH 一个或多个 Key 之后,在 EXEC 之前,如果被监视的 Key 被其他客户端改过,EXEC 返回 nil(事务被丢弃),什么都不会执行。应用拿到 nil 后重新读、重新判断、重试。

WATCH account:1:balance
GET account:1:balance        # 100
MULTI
DECRBY account:1:balance 50  # 余额足够才扣
EXEC
# 若期间别人改了 balance,EXEC 返回 nil,DECRBY 未执行

一个完整的扣款重试循环长这样:

loop:
  WATCH account:1:balance
  balance = GET account:1:balance
  if balance < amount: UNWATCH; return 余额不足
  MULTI
  DECRBY account:1:balance amount
  result = EXEC
  if result != nil: return 成功
  # result 为 nil 说明被并发修改,回到 loop 重试

WATCH 是乐观锁:它不阻止别人写,只是在提交时发现冲突就放弃,让发起方重试。冲突少的时候它比悲观锁高效;冲突一高,大量请求反复重试,反而可能比“排队”更差。UNWATCH 用于在不需要监视时主动解除;EXEC 和 DISCARD 都会自动解除所有 WATCH。

WATCH 乐观锁的 check-and-set 重试循环

这张图的红绿两条分支是理解乐观锁的关键:EXEC 返回 nil 不是系统错误,而是“提交前别人动过这个 Key”,它是协议的一部分,驱动发起方重新读、重新判断。这也意味着 WATCH 的正确性完全依赖应用端写对那个重试循环——只写 WATCH、不处理 nil、不重试,等于没加任何保护。

这里有个容易被忽略的边界:WATCH 监视的是“Key 是否被改”,不是“值是否变成某个数”。哪怕别人把 100 改成 100(原值写回),WATCH 也会认为发生了修改、丢弃事务。Redis 用 key 的版本(内部修改标记)判断,不比较值。

四、Lua:把条件和写放进一段原子脚本

WATCH + MULTI 的缺点是要写重试循环,而且“读、判断、写”分散在多个网络往返里。Lua 脚本(EVAL)把这段逻辑整体放进一段脚本,脚本在 Redis 服务端作为一个原子单元执行:脚本运行期间,其他客户端任何命令都不能插入。于是你可以在一段脚本里读、判断、再写,不需要 WATCH,也没有“读与写之间”的窗口。

EVAL "if redis.call('GET', KEYS[1]) >= ARGV[1] then
        return redis.call('DECRBY', KEYS[1], ARGV[1])
      else
        return -1
      end" 1 account:1:balance 50

脚本里的条件判断是在 Redis 主执行路径上完成的,整个过程原子。这解决了“读改写”的竞争,代价是脚本执行期间整个实例的其他命令都在排队等待——脚本不是“高并发友好的并行代码”,而是一段独占执行路径的串行逻辑。

Lua 脚本同样没有回滚。脚本执行到一半抛错,前面已经执行的写不会撤销:

EVAL "redis.call('SET', KEYS[1], 'new'); error('boom')" 1 k1
# 脚本报错,但 k1 已经被改成 new

所以“脚本原子”指的是“执行不被打断”,不是“出错自动撤销”。要保证一组写在脚本里要么全做要么不做,得自己在脚本开头检查前置条件、用业务逻辑提前返回,而不是指望脚本报错时 Redis 帮你还原。

脚本还有两个工程上的要点。一是 EVAL 每次都要传完整脚本体,开销随脚本变大;SCRIPT LOAD 可以先把脚本缓存起来,之后用 EVALSHA 按 SHA1 调用。二是脚本会独占主执行路径:一条 100 毫秒的脚本,会让这段时间内所有请求一起等 100 毫秒。脚本要做的事越少越好,重循环、大范围扫描、阻塞调用都不该放进脚本。

Lua 脚本原子执行、效果复制到副本

这张图把“脚本原子”和“复制”两件事并排看。左边是脚本在单节点上的独占执行,右边是脚本产生的写如何到达副本。两者用的是两套机制:原子性靠主执行路径的串行,复制靠把写命令发给副本。理解这一点,后面谈脚本复制时就不会把“脚本本身被复制”当成前提。

五、三种机制的边界放在一张表里

机制 执行时不穿插 失败回滚 条件逻辑 并发控制 主要代价
单条命令 是 否 无 无 表达力有限
MULTI/EXEC 是 否 无(命令预先确定) 需配合 WATCH 无回滚,读改写有窗口
WATCH + MULTI 是 否 应用侧判断 乐观锁 CAS 冲突要重试,多网络往返
Lua 脚本 是(更强) 否 脚本内任意逻辑 原子内条件写 独占执行路径,阻塞

这张表的核心是第二列和第四列:所有机制的“回滚”都是否。Redis 能给你的是“执行不穿插”和“条件判断放进原子单元”两种能力,给不了“执行了再撤销”。需要真回滚的场景——比如跨多行、跨多表的资金操作——Redis 不是合适的承载者,应该回到有事务语义的数据库。

如果拿数据库的隔离级别来对照,Redis 的 MULTI/EXEC 和 Lua 给的是最强的隔离:整组命令执行期间完全不穿插,等价于把这一组命令串行化。这比读已提交、可重复读这些“允许并发读、只约束写”的级别都强。但强隔离换不来另外两样东西:一是回滚(前面说过),二是事务级持久化——数据库的“提交”意味着事务日志落盘,Redis 的 EXEC 返回后,这组写的持久性仍取决于 RDB/AOF 的配置,和普通写命令没有任何区别。站内《Redis 持久化》讲的那套数据丢失边界,对事务和脚本产生的写同样成立。所以“Redis 事务”既不是 ACID 事务的弱化版,也不是它的子集,它是“强隔离 + 无回滚 + 无事务级持久化”的一个独立组合,要按这个组合去评估,而不是按数据库事务的框架去套。

六、脚本怎样复制到副本

Lua 脚本会在主节点执行、产生写操作,这些写要复制到副本。复制方式经过一次重要变化:旧版本默认把“整段脚本”复制给副本,让副本自己再跑一遍,这要求脚本必须是确定性的——脚本里调用随机数、时间、SRANDMEMBER 之类非确定性命令,主从结果就会分叉。为此 Redis 还引入了 redis.replicate_commands() 让脚本改用“效果复制”。

自 Redis 7 起,“效果复制”成为默认:主节点把脚本实际产生的写命令复制给副本,而不是复制脚本本身。副本重放的是命令,不是脚本,因此非确定性命令不再造成主从分叉。到了 Redis 8,逐字复制(verbatim replication)被彻底移除,脚本只按效果复制。这个变化对写脚本的人很关键:你不再需要为了让脚本可复制而回避时间、随机这类命令,脚本的确定性约束从“复制要求”变成了“业务正确性要求”。

具体到旧版的逐字复制,问题出在脚本结果依赖运行时环境。假设脚本用 redis.call('TIME') 取时间戳写入某个 Key:主节点执行得到 ...123,副本晚几毫秒再独立执行同一段脚本得到 ...456,主从这个 Key 的值就悄悄分叉了。效果复制则把主节点已经算好的写命令连同参数一起复制,副本不再重新跑脚本、不再产生第二次随机。所以“脚本能写非确定性命令”这个自由度,是效果复制带来的,也是 Redis 8 移除逐字复制后默认成立的事实。

EVALSHA 还有一个独立的坑:SHA1 脚本缓存是进程内存状态,主从切换或重启后,新主可能没有缓存里那段脚本,EVALSHA 返回 NOSCRIPT。健壮的客户端收到 NOSCRIPT 会回退到 EVAL 重新发送脚本体;只写 EVALSHA、不回退的应用,会在切换后持续报错。

七、Cluster 里的事务和脚本要同槽

上一篇 Cluster 已经说过:多 Key 命令、事务、Lua 脚本都要求所有涉及的 Key 落在同一个槽,否则返回 CROSSSLOT。MULTI/EXEC 和 Lua 脚本在 Cluster 下同样受这条约束,而且脚本还有一条额外要求:脚本访问的 Key 必须显式通过 KEYS[] 参数传入,不能藏在 ARGV 里拼出来。因为 Cluster 路由要靠 KEYS[] 判断脚本该发到哪个节点。

# 两个 Key 必须同槽:用 hash tag 把 user42 相关 Key 聚到同一槽
EVAL "..." 2 {user42}:balance {user42}:paid

这意味着单机下“随便写个脚本操作任意 Key”的习惯,迁到 Cluster 会立刻撞上 CROSSSLOT。把相关 Key 用 hash tag 聚到同槽,是 Cluster 下用事务和脚本的前提;这也正是上一篇 hash tag 章节里“人为制造热点槽”那个代价的另一面——同槽是功能需求,代价是分布倾斜,二者要一起权衡。

这个限制在“单机迁 Cluster”时最容易集中爆发。单机下事务和脚本可以随意操作任意 Key,迁到 Cluster 后,那些跨多个 Key 的 MULTI/EXEC、Lua 脚本和 Pipeline 里的多 Key 命令会逐个报 CROSSSLOT。迁移前值得做一次“多 Key 命令盘点”:把跨 Key 的事务和脚本列出来,确认它们要么用 hash tag 聚槽、要么改写成单 Key 操作。这一步放在迁移之前,比上线后逐个修 CROSSSLOT 便宜得多。

八、一个库存扣减的端到端对比

假设一个秒杀场景,库存 Key 是 stock:item:42,扣减要求“库存大于零才能扣,不能扣成负数”。

朴素读改写(错):GET 库存,应用判断 > 0,DECR。两个请求并发时都读到 1,各自判断够,各自 DECR,库存变成 -1。这是没有任何保护的基线,展示了“读与写之间”的窗口。

WATCH + MULTI(对,但要重试):WATCH 库存,读、判断,事务里 DECRBY,EXEC 返回 nil 就重试。并发下只有一方先提交成功,另一方看到 nil 后重读、发现库存已经 0、返回“售罄”。正确性没问题,代价是每个请求要跑“WATCH + GET + MULTI + EXEC”多个往返,冲突激烈时重试循环拉长尾延迟。

Lua 脚本(对,一次往返):脚本里 GET、判断、DECRBY 一次完成,天然没有窗口,也不需要重试。这是秒杀扣库存的标准做法:

EVAL "local stock = tonumber(redis.call('GET', KEYS[1]))
      if stock and stock >= tonumber(ARGV[1]) then
        return redis.call('DECRBY', KEYS[1], ARGV[1])
      else
        return -1
      end" 1 stock:item:42 1

脚本整体原子执行,两个并发请求不会穿插:先到的把库存从 1 扣到 0 并返回 0,后到的再进脚本时读到 0、走 -1 分支返回“库存不足”。代价是脚本独占执行路径——脚本本身很快没问题,但不要顺手在脚本里加日志、扫描或重循环,否则一次扣库存会拖住整个实例。

三种写法都能“扣库存”,只有后两种不会扣成负数。区别不在“谁更正确”,而在“正确性靠什么买到”:WATCH 靠重试,Lua 靠独占。选哪个看冲突频率、脚本复杂度和团队对 Lua 的维护意愿。

九、几个容易踩的误区

误区一:Redis 事务失败会回滚。 MULTI/EXEC 的执行时错误不回滚,Lua 脚本报错也不回滚。要“要么全做要么不做”,得靠脚本里的显式前置检查,或换有事务语义的存储。

误区二:WATCH 锁住了 Key。 WATCH 是乐观的,不阻止别人写,只是让本方的 EXEC 在冲突时返回 nil。它不提供排他锁,别指望它把并发“串行化”。

误区三:脚本原子所以可以写重逻辑。 脚本原子换来的是独占执行路径,写重逻辑等于让所有请求陪你一起等。脚本应该短小、只做与原子性相关的最小操作。

误区四:Redis 7 后脚本不用管确定性了。 效果复制让“复制”不再要求确定性,但如果脚本的逻辑本身依赖随机或时间(比如用时间生成订单号),主从虽然不会分叉,业务结果仍可能不一致或不可复现。确定性从复制约束变成了你自己的正确性约束。

误区五:EVALSHA 比 EVAL 快所以永远用 EVALSHA。 省的是每次传脚本身的那点网络开销,却引入了脚本缓存的 NOSCRIPT 状态。必须实现回退到 EVAL,否则切换后报错。

十、什么时候用哪个

需求 建议 理由
一组无依赖命令想一次提交、不穿插 MULTI/EXEC 简单,无回滚语义
读改写,冲突不高、逻辑简单 WATCH + MULTI 乐观锁够用,代价是重试
读改写,要求一次往返、无窗口 Lua 脚本 原子内条件写,秒杀扣库存的标准做法
需要真正回滚的多步操作 换有事务语义的数据库 Redis 给不了回滚
脚本里要跑重循环/大扫描 不要写进脚本 独占执行路径会拖垮整个实例

落到 WATCH 和 Lua 之间的选择,可以看三件事。一是逻辑复杂度:读和写之间要做的判断越复杂、中间步骤越多,越值得写进 Lua 一次完成,而不是把判断留在应用端、每次重试都重跑一遍。二是团队的维护面:Lua 脚本散落在应用代码里,版本、加载、回滚都比一段普通的 WATCH 重试循环难管,团队没有统一的脚本管理方式时,用 WATCH 反而更可控。三是冲突频率:冲突极低时两者差别不大;冲突很高时,WATCH 的重试风暴和 Lua 的独占执行都各有代价,真正的解法往往是把热点 Key 拆开,而不是在锁策略里打转。

判断 Redis 事务是否够用,就回到第一节那两层:你要的是“执行时不穿插”,还是“执行了能撤销”。前者 MULTI/EXEC 和 Lua 都能给,后者 Redis 一律给不了。把“原子性”这个词落到具体是“不穿插”还是“可回滚”,Redis 的事务边界才算真正想清楚了。

参考资料