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 脚本里的显式条件判断,或者把不变量设计成单条命令就能维护。
这张图要记住的是“错误发生在哪个阶段”。入队错误是语法问题,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。
这张图的红绿两条分支是理解乐观锁的关键: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 毫秒。脚本要做的事越少越好,重循环、大范围扫描、阻塞调用都不该放进脚本。
这张图把“脚本原子”和“复制”两件事并排看。左边是脚本在单节点上的独占执行,右边是脚本产生的写如何到达副本。两者用的是两套机制:原子性靠主执行路径的串行,复制靠把写命令发给副本。理解这一点,后面谈脚本复制时就不会把“脚本本身被复制”当成前提。
五、三种机制的边界放在一张表里
| 机制 | 执行时不穿插 | 失败回滚 | 条件逻辑 | 并发控制 | 主要代价 |
|---|---|---|---|---|---|
| 单条命令 | 是 | 否 | 无 | 无 | 表达力有限 |
| 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 的事务边界才算真正想清楚了。
参考资料
如果这篇文章对你有帮助