ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Redis事务真相:MULTI/EXEC与WATCH乐观锁机制解析

Redis事务真相:MULTI/EXEC与WATCH乐观锁机制解析

聊到 Redis 事务,很多人第一反应是 MySQL 那种 ACID 事务——要么全成功,要么全失败。但真正上手写过的人都知道,Redis 的东西压根不是这个套路,官方文档甚至直说它更接近一个“批处理队列”。那为什么它还叫事务?为什么面试里总爱追问 WATCH 和乐观锁?这两个词背后到底解决了什么问题、埋了多少坑?

这篇文章我打算把 Redis 事务、MULTI/EXEC/DISCARD、WATCH 乐观锁的底层机制、实操参数、以及线上常见的坑串起来聊一遍。内容适合几类人:一是正在用 Redis 做库存扣减、秒杀限流、排行榜更新这类并发场景的开发者;二是面试前想把原理搞扎实的候选人;三是已经用过但被 WATCH 失效、EXEC 返回 nil 坑过,想搞清楚原因的老手。

通篇没有教科书式的理论堆砌,我会直接按“先用起来 → 拆原理 → 上案例 → 列陷阱 → 聊取舍”的顺序走,你跟着实操一遍,基本就能在项目里放心用。

1. 先把“Redis 事务”这事想清楚

1.1 你以为的事务,可能不是你以为的样子

我第一次真正接触 Redis 事务,是在一个抢券系统里。当时产品提了个需求:用户点击按钮,系统先判断库存是否大于 0,大于才扣减并发放优惠券。我第一版代码写得很“数据库思维”:

if redis.get("coupon:count") > 0: redis.decr("coupon:count") send_coupon(user_id)

上线第二天就出问题:两个请求同时读到 count=1,两个都判定可以发放,最后库存变成负数,券超发了。问题根源很明显——get和decr是两条独立命令,中间隔了一个判断逻辑的空隙。Redis 虽然是单线程执行单条命令,但多条命令之间的状态完全可能被其他客户端插一脚。这个空隙就是并发问题的温床。

我当时第一反应是:那我把这两个操作包进事务里不就行了?于是写了类似MULTI包俩命令的代码。结果发现,事务里的命令执行顺序保证了,但“先读再写”的业务逻辑根本没法在事务里做决策——因为命令是入队后一起执行的,你没法在MULTI内部先读一个值再决定要不要执行下一条。这条路走不通,才引出了 WATCH 这个关键角色。

所以说,先纠正一个认知:Redis 事务不是一个“要么全成功要么全失败”的原子提交单元,它最大的价值在于“把一串命令按顺序打包执行,中间不插入别人的命令”。

1.2 事务的命令基础:MULTI / EXEC / DISCARD / WATCH

Redis 事务涉及的四个核心命令,用一张表先收着:

命令作用关键行为
MULTI开启事务开启后,后续命令进入队列而不是立即执行
EXEC执行事务队列按顺序执行队列中所有命令,返回结果数组
DISCARD丢弃事务清空队列,退出事务状态
WATCH监控一个或多个 key在 EXEC 之前若 key 被修改,EXEC 返回 nil

最基础的用法长这样:

127.0.0.1:6379> MULTI OK 127.0.0.1:6379> SET stock:10001 50 QUEUED 127.0.0.1:6379> DECR stock:10001 QUEUED 127.0.0.1:6379> EXEC 1) OK 2) (integer) 49

注意几个细节。MULTI之后的命令不会立即执行,Redis 会返回QUEUED,表示只是进队列了。直到EXEC,才一次性发出去执行。如果你中途反悔,用DISCARD直接把队列清空,事务状态结束。

还有一点容易被忽略:WATCH必须在MULTI之前执行。如果你在MULTI之后再发起WATCH,Redis 会直接报错。这就是典型的“命令顺序反了”的入门坑,后文我会专门展开。

1.3 Redis 事务到底保证什么

要理解 Redis 事务,四个“保证”和“不保证”需要刻在脑子里:

  • 保证队列中的命令按顺序执行,中间不会被其他客户端的命令插入。单线程模型决定了这一点。
  • 不保证事务中的命令都能执行成功。如果某条命令在执行时出错(比如对字符串做自增),Redis 会继续执行后面的命令,不会回滚,也不存在“要么全做要么全不做”的语义。
  • 不保证事务中涉及的所有 key 都在同一个节点上(集群模式)。跨 slot 的多 key 事务,Redis 集群会拒绝执行。
  • 不保证业务层面的“先检查后操作”逻辑天然安全,除非你用 WATCH 做“版本检查”。

简而言之,Redis 事务更像是一条“流水线”——给你一个不受干扰的执行通道,但它不负责你的业务判断,也不负责把错误恢复到初始状态。明白这一点,后面看 WATCH 的价值就顺了。

2. WATCH 乐观锁:核心原理与正确打开姿势

2.1 一个典型的并发扣减场景

回到开头那个抢券场景。用“先 get 再 decr”会超发,直接 MULTI 包俩命令也不能解决判断问题。那怎么办?WATCH 就是干这个的。

基本的思路是:先WATCH住库存 key,再读取库存值做业务判断,如果判断通过就开事务执行扣减,最后EXEC。在执行EXEC的一瞬间,Redis 会检查被 WATCH 的 key 在“开始监控之后”是否被改动过。如果改过,事务直接拒绝执行,返回 nil;如果没改过,事务正常执行。

这个机制就是典型的 CAS(Compare And Swap)乐观锁模型——我先记住你长什么样,动手之前再确认你还是不是那个样子,是才动手,不是就放弃。

2.2 WATCH 的版本检查机制是怎么工作的

很多人不理解“WATCH 之后 key 被修改”到底是怎么判定的。其实 Redis 内部为每个 key 维护了版本相关的元数据。WATCH 会向当前客户端注册一组“关注对象”,这些对象的元数据快照会保存下来。等到 EXEC 执行时,Redis 会逐一比对快照和当前状态,只要有一个 key 在 WATCH 之后被改动过——包括被其它客户端改动、被当前客户端改动、甚至 key 过期被删除——EXEC 就会失败,返回 nil。

这里有三个很容易误解的点:

  • 只要 key 被修改过一次,即使后来又改回原值,也算被修改过。版本号递增了,不是“值相等就行”。
  • WATCH 是在连接级别的,不是命令级别。一次 WATCH 会一直生效,直到你执行了 EXEC、DISCARD 或 UNWATCH。
  • 事务内自己修改了被 WATCH 的 key,同样会导致事务失败。因为你在 MULTI 之前 WATCH 了它,又在事务里写了它,版本一变,EXEC 直接判负。

这里有个实际工作中很容易踩的场景:有人写代码,先WATCH user:10001,然后MULTI,事务里又去更新user:10001,最后 EXEC 永远返回 nil,查了半天不知道原因,其实就是“自己监控了自己改”的乌龙。

2.3 为什么说 WATCH 是乐观锁,而不是悲观锁

悲观锁的思路是“我先把资源锁住,别人别想动,等我干完再放”。Redis 里有SETNX或者SET ... NX EX这类分布式锁,就属于悲观锁的玩法,适用于“写多读少、冲突概率高”的场景。

乐观锁的思路则是“我不锁资源,只在提交时验证版本,冲突了我就重试”。WATCH 属于后者。它适合“冲突概率低、重试成本低”的场景——比如库存比较充足时,大多数事务都能一次成功,只有极端情况才会冲突。

两者的取舍跟业务场景强相关。如果库存只有 10 件,1000 人同时抢,冲突概率很高,用 WATCH 会导致大量事务失败和重试,这时候反而不如直接用DECR配合LPUSH这类原子命令设计流程,或者用分布式锁来得直接。如果库存有 10 万,用户同时抢的概率不高,WATCH 就非常轻松。

2.4 一个可落地的重试代码模板

知道了原理,还得有能跑的代码。下面这个 Python 模板,演示了用 WATCH 做库存扣减的完整流程,包括冲突后的重试逻辑:

import redis r = redis.Redis(host="127.0.0.1", port=6379, decode_responses=True) def try_decrease_stock(user_id, key, amount=1, max_retry=3): # 乐观锁:冲突后重试,最多尝试 max_retry 次 for attempt in range(max_retry): try: # 每次循环都开新的 watch,上一次事务结束 watch 已自动取消 with r.pipeline() as pipe: try: # 开启 watch,需要将连接绑定到管道 pipe.watch(key) stock = int(pipe.get(key) or 0) if stock < amount: return False, "库存不足" # 开始事务 pipe.multi() pipe.decr(key, amount) # 执行事务,如果被 watch 的 key 发生变化会抛 WatchError pipe.execute() return True, "扣减成功" except redis.exceptions.WatchError: # 冲突了,不执行事务,进入下一轮重试 continue except Exception as e: return False, f"异常: {e}" return False, "重试次数耗尽"

这个模板里有几个细节值得展开。

with r.pipeline()不是开了事务,只是建了个管道连接。真正的事务是pipe.multi()之后才开始的。每条命令都在同一个连接上发送,避免“WATCH 在一个连接上、事务在另一个连接上”这种错误。

每个循环里都重新watch,因为上一次execute()成功或失败后,watch 状态都会重置。如果你把 watch 写在循环外面,第二次重试时会发现 watch 已经失效了。

WatchError不需要你手动处理事务中断的逻辑,因为冲突发生后,事务本身已经被 Redis 抛弃了,你只需要继续循环即可。而pipe.get(key)是在 watch 后执行的读取,这个读取的结果如果是None,说明 key 不存在,也算一种库存为 0。

我在实际项目里还会给这个函数加一个业务标识参数,比如订单号或用户 ID,扣减成功后统一记一笔操作日志,方便排查“谁扣了库存”的争议。这个不是 Redis 语法层面的需求,但线上运营时你会感谢当时多写了这行日志。

3. 实操案例:用事务 + 乐观锁写一个库存扣减接口

3.1 需求边界:什么时候该用 WATCH

先说结论:不是所有并发场景都值得上 WATCH。我见过不少项目,只是给用户做“点赞数 +1”这种操作,也套了一层 WATCH + 重试,纯属多此一举。单条INCR命令本身就是原子的,直接调就完事。

需要 WATCH 的场景,普遍具备两个特征:

  • 操作需要“先读旧值,再根据旧值做判断或计算”;
  • 多个操作必须打包执行,中间不能被别的客户端插队。

典型例子:库存扣减(先查库存够不够,再减);账号余额变动(先查余额足不足,再扣);积分兑换(先查积分够不够,再扣并记录流水);抽奖次数(先查剩余次数,再用掉一次并生成记录)。

如果只是“减一个数”“加一个数”,别用事务,原子命令就够了。这也是区分项目代码质量的一个隐性标准——能用一条命令解决的,别硬上复杂方案。

3.2 第一版实现:WATCH + MULTI/EXEC

下面演示一个更贴近真实业务的案例。假设要做一个图书库存扣减接口,Redis 里的 key 结构是:

book:stock:{book_id} # 剩余库存,整数 book:sold:{book_id} # 已售数量,整数

扣减库存时,需要同时完成“库存减一”和“已售加一”,并且库存不能扣成负数。用 WATCH + MULTI/EXEC 的思路:

WATCH book:stock:{book_id} GET book:stock:{book_id} # 假设结果为 5 MULTI DECR book:stock:{book_id} INCR book:sold:{book_id} EXEC

如果GET读到的结果是 5,而 EXEC 时发现这个 key 已经被其他请求改成 4,EXEC 就会返回(nil),事务不执行。这时候就需要在代码层重试,重新读库存、重新判断、重新提交。

这个方案天然解决了超卖问题,因为库存判定和扣减在一个原子提交区间里完成了。你不用担心“检查的时候是 5,减的时候变成 -1”——只要在 EXEC 时发现版本变动,整个事务就废掉,不存在“检查是 5 但实际扣的是已经变成 4 的库存”这种半吊子状态。

3.3 参数与命令细节:从连接、管道到 EXEC 的返回结果

实操中你会踩到几个“看起来没问题,实际有问题”的细节。

第一个是连接问题。WATCH 和 EXEC 必须在同一个连接上执行,否则 WATCH 监控的是连接 A 的视角,事务跑到连接 B 上,Redis 根本不知道你 WATCH 过什么。很多语言库的默认行为会用连接池,如果你分别调用watch()和multi(),很可能被池子分配到两个连接。所以正确做法是:用管道(pipeline)或显式获取一个连接,把 WATCH、GET、MULTI、EXEC 全部串在同一个连接上。我在上节给的 Python 模板里就是用pipeline()解决这一点。

第二个是 EXEC 的返回值。当 WATCH 监控的 key 发生变动,EXEC 不会报语法错误,而是返回 nil。很多新手就是卡在这:代码没抛异常,但库存没扣成,日志里啥也没记。所以在工程上,一定要对 EXEC 的返回值做判断——不是“有没有异常”,而是“返回的是不是 nil”。如果返回 nil,就是事务被乐观锁拦截了,该重试就重试。

第三个是管道里读旧值的时机问题。在pipe.multi()之前执行pipe.get(),是在 WATCH 已经生效的前提下读取的,读到的值是安全的。但如果把pipe.get()放在pipe.multi()之后,这个命令就变成入队执行,你拿不到返回值,因为它会在 EXEC 时才执行,返回的是一个结果数组,没法作为业务判断的输入。顺序搞反,代码就废了。

3.4 集群与主从环境下的注意事项

单机 Redis 完美运行,不代表集群环境下也没问题。我在某公司做促销系统时,就踩过集群模式下事务失败的坑。

在 Redis Cluster 模式下,事务中的多个 key 必须属于同一个 slot,否则会在 EXEC 时直接报CROSSSLOT错误。原因是事务中涉及的多条命令需要在同一个节点上排队执行,而跨节点是无法保证原子性的。解决办法一般是给 key 加 hash tag,比如book:stock:{10001}和book:sold:{10001}用花括号包住同一个数字,让两个 key 哈希到同一个 slot。注意,这种设计必须从 key 规范层面就做好,临时改 key 名称影响面很大。

主从切换环境下也要小心。WATCH 的监控状态是保存在单个 Redis 实例上的,如果发生故障转移,请求被路由到新的主节点,原来主节点上的 WATCH 信息就丢失了。这时候事务还能正常执行吗?答案是不一定。有些场景会静默失败,有些会直接报错。所以高可用架构下,如果对数据一致性要求很高,建议在业务层配合数据库落库做兜底,不能盲信 Redis 层的乐观锁就万无一失。

4. 陷阱清单:我从线上踩过的坑

4.1 在事务内做决策:一个经典反模式

很多初学者会写出这样的代码:

MULTI GET stock:10001 IF 返回值 > 0 DECR stock:10001 END EXEC

这个思路本身没毛病,但在 Redis 里根本实现不了。因为MULTI之后的命令全部走“入队”逻辑,GET不会立即返回结果,你拿不到返回值,自然没法做 if 判断。如果硬在 client 端写这种逻辑,客户端会先执行GET拿到一个“过期的”值,再把它拼进事务里,最后的判断就是基于旧数据的,问题更严重。

正确姿势是:把“读取旧值”放在MULTI之前(配合 WATCH 保证并发安全),把“写入操作”放在MULTI之后。业务决策在客户端完成,Redis 只负责两件事——监控 key 是否变动、无变动则执行预先排好的写操作。

4.2 WATCH 放在 MULTI 之后:必错

这个问题我在公司内部 review 代码时看到过好几次了。代码写的是:

pipe.multi() pipe.watch("stock:10001") pipe.execute()

执行时会直接报错。因为WATCH的语义是“监控某个 key 是否发生变化,如果变化则后续事务不执行”。它必须在事务开始之前就注册好监控状态,放在事务启动后就变成了一个事务内的普通命令,没有监控效果,还会打乱命令队列。

如果你有过调试这种问题的经历,应该能记住这个教训:WATCH 永远在 MULTI 之前,位置写错 = 白监控。

4.3 过期 key 与 WATCH 失效

这是比较隐蔽的一个坑。假设你 WATCH 了一个带过期时间的 key,在 EXEC 之前 key 刚好过期被删除,Redis 同样认为这个 key“被修改过”——从存在变成不存在,版本肯定变了——于是事务被拒绝。问题在于,这种失败在日志里看起来没有任何异常,只有 EXEC 返回 nil,业务端如果没做好重试或提示,用户会感受到“点击没反应”。

在实际业务里,库存类的 key 一般建议不要设过期时间,或者万一要设,过期时间要比业务周期长很多,避免在事务窗口内过期。如果确实设计成“活动结束后 key 自动失效”,那在代码层面对 EXEC 返回 nil 的处理要特别上心,不能静默吞掉。

4.4 连接复用下的 WATCH 残留

连接池复用的场景下,一个更隐蔽的问题是:WATCH 状态没有在事务结束时被正确清理。

理论上,EXEC、DISCARD、UNWATCH 都会释放监控状态。但如果你在代码里调用了 WATCH,之后因为异常路径没有走 EXEC 也没走 DISCARD,连接就被归还到池子里了,这个连接的监控状态可能残留。下一位用户从这个连接池拿到连接时,可能会带着上一个请求的 WATCH 状态,导致一批莫名其妙的“EXEC 返回 nil”。

防御性做法:代码里确保 WATCH 之后无论成功失败都显式清理连接状态,或者每次使用前先 UNWATCH。在高并发项目里,这个坑可以折磨你一整天。

4.5 陷阱速查表

陷阱现象原因解决方案
在事务内做 if 判断逻辑不生效或有旧数据MULTI 后命令全部入队,读不到实时值读取放 MULTI 前,WATCH 保护
WATCH 放在 MULTI 后运行报错位置语义不允许WATCH 必须在 MULTI 之前
EXEC 返回 nil 但没异常数据没变,日志看不出问题WATCH 的 key 被修改判断 EXEC 返回值,执行重试
连接池残留 WATCH后续请求莫名失败连接状态未清理每次用完 UNWATCH 或换连接
集群跨 slotCROSSSLOT 错误多个 key 哈希到不同节点用 hash tag 设计 key
主从切换事务不稳定失败WATCH 监控状态在旧节点高可用场景增加数据层兜底

5. 进阶:什么时候应该改用 Lua 脚本

5.1 从逻辑复杂度看 WATCH 的短板

WATCH 乐观锁在简单场景里非常优雅,但有一个明显的短板——冲突后要重试。重试带来的问题不只是 CPU 浪费,还有“重试过程中业务状态已经变化”的连锁问题。比如用户抢购,第一次事务失败,重试时库存已经没了,你还需要给用户返回“已售罄”,这个反馈链路要比一次成功复杂得多。

另一个短板是,WATCH 不能解决“需要在一个原子操作里完成多步业务逻辑”的问题。比如:判断库存 > 0 → 扣减库存 → 记录用户抢购名单 → 设置该用户已抢标记,这四步如果用 WATCH + MULTI/EXEC 实现,你得在客户端代码里先读多个 key 再层层判断,命令一多,可读性和稳定性都会下降。

5.2 Lua 方案对比和取舍

Redis 从很早就内置了 Lua 脚本支持,它最大的优势是:脚本在服务端一次性执行,中间不会被其他命令插入,天然满足原子性,而且可以写复杂的业务判断逻辑。同样的库存扣减,用 Lua 可以写成:

-- keys[1]: 库存 key -- keys[2]: 已售 key -- keys[3]: 用户抢购标记 key local stock = tonumber(redis.call("GET", KEYS[1]) or "0") if stock <= 0 then return 0 end redis.call("DECR", KEYS[1]) redis.call("INCR", KEYS[2]) redis.call("SET", KEYS[3], 1) return 1

这段脚本在服务端执行时,整个脚本是一个原子单元,其他客户端的命令不会插入,不需要 WATCH,也不需要重试。它在并发正确性上比 WATCH 更简单粗暴,而且性能往往更好。

那什么时候该用 Lua?一句话:当业务逻辑包含多个存取操作并且对原子性要求很高时,优先 Lua;当业务逻辑简单、只有一两个命令、或者你希望客户端更透明地控制流程时,用 WATCH 也行。

Lua 脚本也有自己的坑:脚本体积要控制,不能写太长导致阻塞主线程;脚本内的错误处理要仔细,尽量不依赖运行时异常;对 KEYS 和 ARGV 的使用要规范,别在脚本里拼接键名,否则集群模式下无法做 slot 预计算。

5.3 我的选型建议

做了这么多年 Redis 相关的开发,我的个人选型习惯是这样的:

  • 只有 1~2 个 key、逻辑判断只有一次的场景,用 WATCH 配合重试,简单直观。
  • 超过 2 个 key、逻辑步骤超过 2 步的场景,直接用 Lua 脚本,省心,不容易写出错。
  • 需要“先检查再写”的业务,不要试图用事务本身完成判断,要么 WATCH,要么 Lua,二选一。

还有一个很多人忽略的点:Lua 脚本的原子性不是事务的原子性。脚本执行期间,Redis 不会执行其他命令,这是比事务更强的“独占执行”。所以高并发秒杀场景,Lua 几乎是标准答案。WATCH 更像一个“轻量级并发控制工具”,Lua 则是“把逻辑下沉到服务端的重型武器”。

最后分享一个实操体会:不要迷信“用了事务就安全”这种说法。安全与否取决于你的数据模型和业务判断合不合理。Redis 事务和 WATCH 都只是工具,而工具的使用边界——什么时候该上、什么时候不该上、出了问题怎么快速定位——才是真正要在项目里不断打磨的能力。我建议每个团队把事务和 WATCH 的适用场景写进自己的开发规范里,别让每个新来的同事都重新踩一遍我上面列出来的坑。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进