Redis事务机制深度解析:命令、WATCH乐观锁与实战避坑

发布时间:2026/8/30 21:59:51
Redis事务机制深度解析:命令、WATCH乐观锁与实战避坑 Redis 的事务机制我在面试里被问过不下十次在实际项目里也踩过不少坑。网上讲 Redis 事务的文章很多但大多数只停留在“MULTI、EXEC、DISCARD、WATCH 这四个命令背一背”的层面真正把它放在生产环境里用过的经验分享却很少。这篇文章我想从“Redis 事务到底能解决什么问题”开始讲起把命令用法、执行机制、不支持回滚的原因、和 MySQL 事务的本质区别、以及面试中那些高频追问全部串起来最后再结合我自己的实战经验聊聊什么时候该用它什么时候千万别用它。不管你是准备面试还是在项目里正纠结要不要用 Redis 事务这篇应该都能给你一个比较完整的参考。1. Redis 事务的核心机制与设计定位1.1 说清楚 Redis 事务到底是个什么东西先用一句话概括Redis 事务是一组命令的打包执行机制通过MULTI、EXEC、DISCARD、WATCH四个命令配合完成。MULTI开启一个事务后续命令不会立即执行而是进入一个先进先出的队列直到EXEC才会一次性按顺序执行。如果中途想放弃就用DISCARD清空队列。这个机制第一眼看起来很简单但有一个关键点必须理解到位Redis 事务强调的是“打包顺序执行”而不是“原子性回滚”。这一点和 MySQL 事务有本质区别也是面试里最容易踩的坑。举个最简单的例子127.0.0.1:6379 MULTI OK 127.0.0.1:6379 SET user:1:balance 100 QUEUED 127.0.0.1:6379 DECRBY user:1:balance 50 QUEUED 127.0.0.1:6379 EXEC 1) OK 2) (integer) 50当输入MULTI后Redis 返回OK之后每条命令返回QUEUED表示命令已经进入队列。执行EXEC时队列里的命令按先进先出的顺序逐个执行并且把每条命令的结果打包成一个数组返回。1.2 为什么 Redis 的事务和 MySQL 事务不是一回事这句话我几乎每次分享都要强调Redis 事务不是你印象里的那种数据库事务。MySQL 事务靠 ACID 四重保障尤其是原子性——要么全部成功要么全部回滚。Redis 事务做不到“全部回滚”它连回滚的概念都没有。先看一个容易混淆的点。在 Redis 2.6.5 之前如果你在MULTI里写入了一条语法错误的命令这个错误命令会延迟到EXEC时才暴露而且其它命令依然执行。2.6.5 之后行为变了如果命令在入队阶段就发现语法错误整个事务会被直接拒绝EXEC返回EXECABORT相当于在发车之前就检查出问题直接取消发车。127.0.0.1:6379 MULTI OK 127.0.0.1:6379 SET key value QUEUED 127.0.0.1:6379 INCR key # 这里命令本身没语法错误但运行时才发现类型不对 QUEUED 127.0.0.1:6379 EXEC 1) OK 2) (error) WRONGTYPE Operation against a key holding the wrong kind of value你看SET key value执行成功了但INCR key因为类型错误运行失败。前面成功的命令不会回滚。这就是 Redis 事务最核心的特点之一不保证原子性只保证队列里的命令“不会在中途被别的客户端的命令插入”。那它到底保证了什么隔离性。因为 Redis 是单线程模型事务在EXEC执行期间其它客户端的命令不会被穿插进去。相当于拿了一把大锁把这一批命令锁住连续执行完。所以 Redis 事务给你的是“隔离”不是“原子”。这里要特别提醒网上很多人把“Redis 事务是原子性的”这句话挂在嘴边这是错的。严格来说Redis 官方文档用词也很谨慎强调的是事务中的命令会“作为一个整体被执行”但由于运行错误不会回滚所以“原子性”这个说法在严格语义下是不成立的。2. 四个核心命令的实操与原理剖析2.1 MULTI、EXEC、DISCARD 的基础用法与执行状态流转事务的状态流转其实很好理解一条线穿起来正常状态 → MULTI → 事务状态命令入队 → EXEC → 执行队列命令 → 正常状态 └→ DISCARD → 丢弃队列 → 正常状态我建议你在本地 Redis 里亲手把这条线走一遍体会一下事务状态下命令的返回值和执行后的实际结果。很多人在面试时说“MULTI 之后命令都返回 QUEUED”但其实WATCH、MULTI、EXEC、DISCARD这四个命令本身在事务状态下并不会被排队它们会立即生效。在事务状态下有几个细节值得注意事务状态下不能再嵌套MULTI会报MULTI calls can not be nested。EXEC执行完后事务状态自动结束不需要手动重置。如果MULTI之后连接断开Redis 会自动丢弃排队中的命令不需要DISCARD也不存在“残留事务”的隐患。我自己最初上手时犯过一个低级错误在事务里写了一个WATCH以为它会被排队执行。实际上WATCH必须在MULTI之前调用它的作用是监控 key 在事务开始前是否被修改过一旦在MULTI之后再WATCH就已经失去了乐观锁的语义。2.2 WATCH 乐观锁Redis 事务的灵魂如果说MULTI/EXEC是 Redis 事务的骨架那WATCH就是灵魂。它解决的是“多个客户端同时操作同一个 key 时如何避免并发覆盖”的问题。先看一个典型场景用 Redis 做库存扣减。假设stock:1001当前值是 10两个客户端同时读到库存 10各自扣减 1期望结果是 8但如果没有锁或WATCH最终可能变成 9因为两个客户端都基于旧值 10 写入 9产生丢失更新。WATCH的原理是乐观锁在MULTI之前先WATCH一个或多个 key然后执行事务。如果在EXEC之前被WATCH的 key 被其它客户端修改了那么本次事务会被拒绝执行EXEC返回nil。# 客户端 A 127.0.0.1:6379 WATCH stock:1001 OK 127.0.0.1:6379 MULTI OK 127.0.0.1:6379 DECRBY stock:1001 1 QUEUED 127.0.0.1:6379 EXEC (nil) # 说明 WATCH 的 stock:1001 在 EXEC 前被别的客户端改过了如果EXEC返回nil业务层需要做重试通常是重新WATCH、重新MULTI、重新执行命令直到成功。这里有一个非常隐蔽的坑WATCH监控的 key 不限于当前事务里操作的 key。你可以WATCHkey A事务里操作 key B只要 key A 在EXEC前被修改事务同样会被拒绝。很多人没意识到这一点导致线上莫名出现大量事务执行失败。还有一个细节WATCH的监听是一次性的。事务执行完不管成功还是失败监听自动取消。如果事务因为DISCARD取消监听也一起取消。如果想手动取消可以用UNWATCH。2.3 为什么不支持回滚官方设计哲学与工程考量这个问题的标准答案是Redis 设计者认为事务里命令失败通常是因为编程错误这类错误在开发阶段就应该被发现而不是在运行时依赖回滚机制来处理。如果引入回滚就需要在底层维护 undo 日志这会让 Redis 核心变得复杂违背了它“简单高效”的定位。但我在实际工程里对这个答案做一点补充。Redis 命令本身的失败概率其实很低常见失败只有两种语法错误入队时就能发现和数据类型错误运行时发现。前者可以在入队阶段拦截后者通常意味着业务代码写错了——比如往 string 类型上执行LPUSH这属于 bug不是正常的业务异常。相比之下MySQL 事务的回滚能力处理的是更复杂的业务逻辑异常比如“转账第一条 SQL 成功第二条 SQL 违反唯一约束”这种错误在 Redis 这种 key-value 操作模式下比较少见Redis 命令粒度小、逻辑简单很难出现“多个命令之间有业务依赖导致中途失败”的情况。所以我的观点是不是说 Redis 做不出回滚而是它在设计层面刻意选择了不做。如果你需要真正的“要么全部成功、要么全部失败”应该用 Lua 脚本而不是 Redis 事务。3. Redis 事务与常见技术方案的横向对比3.1 Redis 事务 vs Lua 脚本为什么 Lua 更接近“原子”这是面试高频追问。“Redis 事务能保证原子性吗”这个问题你答“不能”之后面试官大概率会追问那我想让一组命令原子执行怎么办答案是 Lua 脚本。Redis 从 2.6 版本开始支持 Lua 脚本EVAL命令可以执行一段 Lua 代码。Lua 脚本在执行期间Redis 会阻塞其它命令整个脚本是真正意义上的原子操作。脚本中间如果出错已经执行的写命令也不会自动回滚但因为在单线程模型下整个脚本不会被中断所以“隔离性”比事务更强不存在其它客户端命令穿插的问题。-- 用 Lua 实现一个“检查和设置”的原子操作 local current redis.call(GET, KEYS[1]) if current ARGV[1] then return redis.call(SET, KEYS[1], ARGV[2]) end return nil127.0.0.1:6379 EVAL local current redis.call(GET, KEYS[1]) if current ARGV[1] then return redis.call(SET, KEYS[1], ARGV[2]) end return nil 1 mykey oldValue newValue实际项目中事务能做的事 Lua 都能做而且 Lua 在传输效率和原子性表现上更好。我的习惯是能用 Lua 解决就不用 Redis 事务。Redis 事务更多是面试题里的常客真正的生产代码里出现频率反而不高。3.2 Redis 事务 vs 管道Pipeline别把两个东西混为一谈管道是很多新手容易和事务搞混的概念。区分一句话就行管道是网络传输层的优化事务是服务端执行层的封装。管道可以把多条命令一次性发给 Redis减少网络 RTT但服务端还是逐条执行中间可以被其它客户端的命令穿插。事务也能减少一部分网络交互但它更关键的价值是“服务端把命令打包执行”中间不穿插其它命令。两者可以结合用通过管道发送MULTI、多条命令、EXEC既享受网络优化又获得服务端打包执行的效果。但要注意管道里的命令返回结果需要自己对上号用起来稍微麻烦一点。3.3 和分布式事务的边界别拿 Redis 事务硬扛分布式的一致性热词里出现了不少分布式事务相关的内容比如 seata、最大努力通知、订单与库存分布式事务等。我在这里想直接泼一盆冷水Redis 事务是单机多命令的执行机制和分布式事务是两个维度的问题。分布式事务解决的是“多个独立资源比如多个数据库、多个服务之间如何保持数据一致”的问题典型的方案有 2PC、TCC、Saga、可靠消息最终一致性等。Redis 事务只能保证单实例内多个命令的隔离执行它管不了 MySQL 和 Redis 之间、或者两个 Redis 实例之间的数据一致性。我见过一些项目试图用 Redis 的WATCH机制实现分布式锁或者分布式事务结果在并发稍高时就出现各种问题。原因很简单Redis 事务和WATCH都只作用于单个 Redis 实例跨实例的协调需要锁服务或者消息队列来做指望WATCH去跨节点解决并发问题方向就错了。如果非要在分布式系统里用 Redis 事务最常见的场景是“单实例 Redis 内部需要保证多个 key 的操作一致性”比如一个购物车 Redis 里同时更新多个字段这种情况可以用一旦涉及 MySQL 和 Redis 的双写就必须另想办法比如本地消息表、事务消息或者 TCC 方案。3.4 Redis 事务和 ACID 的对照关系面试里经常会问“Redis 事务满足 ACID 吗”这个问题建议从四个维度分别回答原子性Atomicity不满足。运行错误不回滚只能保证命令“打包执行”。一致性Consistency基本满足。Redis 单线程执行保证不会出现多个客户端插入导致的中间状态但需要注意事务本身不能保证所有命令都成功所以“一致性”只能说是执行过程层面的一致不是业务语义层面的最终一致。隔离性Isolation满足。EXEC执行期间不会被其它命令穿插这是单线程模型天然带来的隔离。持久性Durability取决于持久化配置。如果 AOF 配置为appendfsync always事务执行结果能实时落盘如果是everysec则最多丢 1 秒数据如果只开 RDB则可能丢更多数据。所以 Redis 持久性和事务没有直接绑定关系而是和持久化策略绑定。这个表格建议面试前背熟ACID 维度Redis 事务是否满足原因原子性不满足运行错误不回滚只保证打包执行一致性部分满足隔离执行但业务语义一致性需自行保障隔离性满足单线程模型EXEC 期间无其它命令穿插持久性视配置而定取决于 RDB / AOF 的持久化策略4. 实战中的完整案例用 WATCH 实现不超卖的库存扣减4.1 业务背景与方案选择我现在假设一个场景商品秒杀活动库存只有 10 件需要支持多个用户同时抢购要求不能超卖。很多人的第一反应是让 Redis 的DECR命令直接扣库存但这里有个前置条件如果扣完发现小于 0就要拒绝这次扣减不能把库存扣成负数。DECR本身没有“扣减后检查并回滚”的能力所以需要事务或 Lua。方案有两个用WATCH配合MULTI/EXEC实现乐观锁重试。直接用 Lua 脚本一个脚本完成检查和扣减更简洁。我先把 Lua 方案放一边重点演示 WATCH 版本因为这是理解 Redis 事务机制最好的练习。4.2 基于 WATCH 重试的完整流程第一步初始化库存127.0.0.1:6379 SET stock:1001 10 OK第二步业务代码模拟扣减。这里用 Python 的 redis-py 来写方便你直接跑import redis client redis.Redis(host127.0.0.1, port6379, db0) def deduct_stock(key, quantity, max_retry5): for attempt in range(max_retry): try: # 开启乐观锁监听 pipe client.pipeline() pipe.watch(key) current int(pipe.get(key)) if current quantity: print(f库存不足当前库存 {current}) return False # 执行事务 pipe.multi() pipe.decrby(key, quantity) pipe.execute() print(f扣减成功剩余库存 {current - quantity}) return True except redis.WatchError: # 事务被中断说明 key 在 WATCH 之后被其他客户端修改过 print(f并发冲突第 {attempt 1} 次重试) continue print(重试次数耗尽) return False if __name__ __main__: # 模拟并发扣减 10 次 import threading threads [threading.Thread(targetdeduct_stock, args(stock:1001, 1)) for _ in range(10)] for t in threads: t.start() for t in threads: t.join() print(最终库存:, client.get(stock:1001))执行逻辑里值得关注的是pipe.watch(key)、pipe.multi()、pipe.execute()这个顺序。WatchError异常会在execute()时抛出如果抛出来了说明被监控的 key 在WATCH和EXEC之间被别的客户端改动了。这时候需要整个事务重来一遍因为WATCH已经失效MULTI也已经过期管道对象不能再继续使用建议直接重新创建 pipeline。4.3 这个方案有哪些坑我实际跑过这个代码几个点很值得注意第一WATCH 必须在 MULTI 之前顺序错了就完全失效。很多初级工程师把WATCH写在管道中间结果并发冲突检测形同虚设。第二重试要有限制。高并发下乐观锁冲突会很频繁如果没有最大重试次数可能陷入无限循环。上面代码设了 5 次上限实际项目中建议结合业务容忍度设置 3~5 次。第三检查再扣减的整个逻辑必须放在 WATCH 之后。有人会在WATCH之前先GET一次库存然后WATCH再扣减这会导致“判断库存”和“扣减库存”基于的是不同时刻的值依然可能出现超卖。正确做法是WATCH之后立刻GET然后MULTI扣减。第四相比 Lua 脚本WATCH 方案在网络交互上更啰嗦Redis 需要多次往返而且并发冲突时需要重试。这套方案的价值更多是“帮助你理解 Redis 事务”真正生产环境做库存扣减我个人更推荐 Lua 脚本一个EVAL搞定检查和扣减既省事又可靠。5. 常见问题排查与面试追问实录5.1 高频面试题整理下面这些题是我在面试中被问过、也听同事面试别人时问过的整理出来给需要的人参考Q1Redis 事务是什么怎么用最简单也最基础的题把 MULTI、EXEC、DISCARD、WATCH 四个命令讲清楚再举一个例子说明即可。重点是突出“打包执行”和“不回滚”两个特点。Q2Redis 事务能回滚吗为什么不能。分两层回答入队阶段发现语法错误整个事务拒绝执行运行阶段发现类型错误等出错命令失败但其它命令继续执行。原因是 Redis 设计哲学追求简单高效命令失败多由编程错误导致回滚机制不值得引入。Q3WATCH 是乐观锁还是悲观锁乐观锁。它不阻塞其它客户端操作而是在执行事务前检查“被监控的 key 是否被改过”如果被改过就放弃执行需要应用层重试。Q4Redis 事务和 Lua 脚本有什么区别Redis 事务保证隔离性但不保证原子性不回滚Lua 脚本整体作为一个原子操作执行中途不会被其它命令插入。复杂逻辑推荐 Lua 脚本。Q5Redis 事务满足 ACID 吗四维分析原子性不满足、隔离性满足、一致性要看语义理解、持久性取决于持久化策略。Q6Redis 事务在集群模式下有什么问题如果事务里操作的 key 不在同一个 slotRedis Cluster 会报错因为事务要求所有 key 必须在同一个节点上。所以集群环境下要慎用事务这也是很多人转向 Lua 的原因——Lua 脚本在集群模式下同样要求 key 在同一个 slot但可以配合 hash tag 来实现。5.2 我实际遇到的问题排查实录再分享一个真实踩坑经历。之前有个项目用 Redis 做积分累计代码里把“读积分、加积分、写回”包在事务里结果线上偶尔出现积分丢失。排查后发现原因代码在MULTI之前没有WATCH导致两个并发请求都读到旧积分然后各自加完写回后写的覆盖先写的积分就丢了。加了WATCH之后冲突时事务返回nil代码没有处理nil分支直接把结果当成“执行成功”返回给前端了用户看到的积分和实际 Redis 里的值不一致。这个案例说明两件事第一事务要配 WATCH 才能防并发第二EXEC返回nil时要明确告诉上层“操作失败需要重试”。不要把nil当成成功这是很多人的盲区。另外还有一个坑是关于DISCARD的。有个同事在MULTI后写错了命令想用DISCARD取消事务但他在DISCARD之后继续用同一个连接执行命令结果因为连接状态没有完全清理出现了命令被丢弃的情况。实际上DISCARD之后连接就回到正常状态了这个问题大概率是客户端连接池复用了旧事务状态导致的重启连接恢复正常。遇到类似问题第一反应应该是检查连接池里有没有残留的旧连接。5.3 踩坑速查表问题现象可能原因解决思路EXEC 返回 nilWATCH 的 key 在 EXEC 前被修改捕获无结果分支重试整个事务事务里命令全部没执行入队阶段语法错误EXECABORT检查命令语法避免拼写错误部分命令成功部分失败运行阶段类型错误不会回滚业务层校验数据类型别依赖回滚集群模式报跨 slot 错误事务涉及多个 key 在不同节点使用 hash tag 或改用 LuaMULTI 嵌套报错事务状态内再次调用 MULTI检查代码逻辑避免嵌套事务连接异常后事务状态残留连接池复用未清理的连接重启连接或检查客户端连接池配置6. 最后分享一点实战心得说了这么多其实我最想表达的是Redis 事务是个“看起来有用、实际使用场景有限”的机制。它在面试里的价值大于在工程里的价值。真正做项目时我强烈建议你把精力花在 Lua 脚本上它才是 Redis 里“原子操作”的实用工具。如果你只是学习我建议你在本地把 MULTI、EXEC、DISCARD、WATCH 这套流程完整跑一遍尤其是用两个 redis-cli 窗口模拟并发冲突亲眼看看EXEC返回nil是什么样子。这种直观的体验比背十篇八股文都管用。如果你准备面试重点把“为什么不支持回滚”和“WATCH 的乐观锁原理”这两个点讲透面试官通常会顺着这两个点往下追问把 Lua 脚本、ACID、Pipeline 这些对比内容准备好基本上这一块就不会丢分了。最后再分享一个小技巧如果你在项目里确实要在一批 key 上做事务建议把事务的 key 设计成集中在一个 hash 或几个固定前缀里这样在 Redis Cluster 下可以用 hash tag比如{target}把相关 key 固定到同一 slot给未来扩展留条后路。这个细节在规划阶段很容易被忽略等上了集群再改就麻烦多了。