聊到 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)上线第二天就出问题两个请求同时读到 count1两个都判定可以发放最后库存变成负数券超发了。问题根源很明显——get和decr是两条独立命令中间隔了一个判断逻辑的空隙。Redis 虽然是单线程执行单条命令但多条命令之间的状态完全可能被其他客户端插一脚。这个空隙就是并发问题的温床。我当时第一反应是那我把这两个操作包进事务里不就行了于是写了类似MULTI包俩命令的代码。结果发现事务里的命令执行顺序保证了但“先读再写”的业务逻辑根本没法在事务里做决策——因为命令是入队后一起执行的你没法在MULTI内部先读一个值再决定要不要执行下一条。这条路走不通才引出了 WATCH 这个关键角色。所以说先纠正一个认知Redis 事务不是一个“要么全成功要么全失败”的原子提交单元它最大的价值在于“把一串命令按顺序打包执行中间不插入别人的命令”。1.2 事务的命令基础MULTI / EXEC / DISCARD / WATCHRedis 事务涉及的四个核心命令用一张表先收着命令作用关键行为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之后再发起WATCHRedis 会直接报错。这就是典型的“命令顺序反了”的入门坑后文我会专门展开。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如果没改过事务正常执行。这个机制就是典型的 CASCompare 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(host127.0.0.1, port6379, decode_responsesTrue) def try_decrease_stock(user_id, key, amount1, max_retry3): # 乐观锁冲突后重试最多尝试 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 已经被其他请求改成 4EXEC 就会返回(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 的适用场景写进自己的开发规范里别让每个新来的同事都重新踩一遍我上面列出来的坑。