资讯详情

Redis事务与WATCH乐观锁:深入理解并发控制、原子性与实战避坑

📅 2026/10/15 7:27:53 | 华诺云谱 👁 阅读
Redis事务与WATCH乐观锁:深入理解并发控制、原子性与实战避坑
聊到Redis事务很多人第一反应就是MULTI和EXEC张嘴就是“把命令打包执行”但真到线上扣库存、防超卖的时候光打包命令远远不够。Redis事务最容易被误解的地方不是它能不能原子执行一批命令而是它根本不像关系型数据库那样支持回滚也不提供传统意义上的锁。真正能帮你在并发场景下保住数据一致性的是WATCH这条容易被忽略的命令——也就是常说的乐观锁。所以这篇文章不止讲MULTI/EXEC怎么用还会把WATCH乐观锁的实现原理、和分布式锁的对比、常见陷阱以及我在真实项目里踩过的一些坑完整拆一遍。无论你是刚接触Redis的前后端开发还是正在排查线上并发问题的运维同学甚至只是在准备面试都能从里面找到对自己有用的部分。1. Redis事务到底解决了什么问题1.1 没有事务并发下会发生什么先看一个最简单的例子。假设商品库存存在Redis的stockkey里值初始为1。业务逻辑很直白先读当前库存如果大于0就执行扣减。听起来没有毛病但并发请求一到就暴露问题客户端A先GET stock读到1客户端B也GET stock同样读到1接着A执行DECR stockstock变成0B再执行DECR stockstock变成-1。整个过程没有任何一个命令出错可库存就是被扣超了。问题出在“检查”和“扣减”是两步独立操作中间可以被其它客户端插入。Redis虽然是单线程但单线程只保证单个命令不被并行并不保证多个命令组成的业务流程是原子的。数据库领域管这类问题叫竞态条件也正是事务要解决的核心问题之一。还有一个容易忽视的点即使把检查和扣减放进同一个MULTI/EXEC事务里也未必能解决这个问题。因为MULTI中的命令只是进入队列EXEC之前业务根本拿不到中间结果你没办法在事务内部判断“剩余库存是否大于0”。真正能让你安全完成读改写流程的是后面要重点讲的WATCH。1.2 Redis事务与传统事务的本质差异很多人把Redis事务当成MySQL事务的降级版这个印象需要纠正。Redis的事务由MULTI开头之后的命令不是立即执行而是进入一个队列直到EXEC被调用Redis才会单线程地、一条接一条地执行队列里的全部命令。这个过程中不会有其它客户端命令插队所以单从“命令执行不被打断”来看它确实有原子执行的效果。但Redis事务和传统事务的区别非常明显我用一张表来对比对比项Redis事务传统关系型事务原子性命令打包执行但运行错误时部分命令仍会执行要么全成功要么全回滚回滚不支持没有undo机制支持回滚隔离性EXEC期间命令不被打断接近串行化有隔离级别如读已提交、可重复读锁等待不阻塞其它客户端提交时靠WATCH检测冲突通常要加锁等待持久性取决于RDB/AOF配置依赖数据库日志与配置这张表不是要否定Redis事务的价值而是说明它的设计目标很纯粹用最小的开销保证一批命令在执行时不被打断并借助WATCH做冲突检测。如果你期望的是“像MySQL一样出错自动回滚”Redis事务做不到也从来没想做到这一点。为什么Redis不设计回滚核心原因是性能。事务执行阶段本来就已经是单线程串行执行如果再引入undo日志和回滚机制每条命令都要先记录旧值内存消耗和指令开销都会明显上升而Redis的大多数命令都很简单入队阶段又能拦截大部分格式错误真正等到执行期才暴露的错误绝大多数属于业务代码写错类型回滚的价值并不高。与其增加复杂度不如让开发者用Lua脚本或者重试逻辑来兜底。1.3 三个阶段入队、排队、执行理解Redis事务最好按阶段拆。第一阶段是MULTI它告诉Redis当前连接进入事务状态第二阶段是命令入队从MULTI之后到EXEC之前客户端发来的读、写命令都会被Redis返回QUEUED然后放进当前连接的事务队列第三阶段是EXECRedis开始真正执行队列里的命令并把每个命令的返回值按顺序整理成一个数组返回给客户端。除了EXECDISCARD也能让事务终止。DISCARD会清空已经入队的命令并退出事务状态不会执行任何一条。如果此时事务还没有启动只是单独WATCH了一些key也可以用UNWATCH手动放弃监视。三个阶段讲清楚了后面很多行为就很好理解。有一个细节值得注意MULTI到EXEC之间的事务队列是只在当前客户端连接上存在的其它客户端完全感知不到。它不像关系型数据库那样在服务器端创建了一个全局可见的事务上下文而是“谁发起MULTI命令就排在谁的连接上”。如果客户端在MULTI之后、EXEC之前断开连接Redis会自动丢弃这个事务队列不会留下任何脏数据。这也是连接级状态管理的典型表现。2. 手把手操作Redis事务MULTI、EXEC、DISCARD用明白2.1 从最简单的入队执行开始你可以用redis-cli直接体验。先看一个最简单的批量写入127.0.0.1:6379 MULTI OK 127.0.0.1:6379 SET site blog QUEUED 127.0.0.1:6379 INCR pageview QUEUED 127.0.0.1:6379 EXEC 1) OK 2) (integer) 1注意返回顺序EXEC返回的数组第i项对应第i条入队命令的结果。如果中间某条命令入队失败EXEC可能会直接中止这个细节不搞明白排查问题时容易一头雾水。这里的INCR第一次执行返回1是因为pageview之前不存在Redis把它当成0再加1。如果你在EXEC之后再次查看pageview会得到1说明事务里的命令确实真正生效了。整个过程从外部看SET和INCR之间没有任何其它命令插入这就是事务最基本的价值。2.2 入队报错和运行报错是两回事入队阶段Redis会检查命令的基本格式。比如GET原本只接受一个key你偏要GET a bRedis在MULTI状态下会直接返回语法错误不会进入QUEUED。这时候继续输入EXECRedis会返回EXECABORT并放弃整个事务之前的命令一条都不会执行。但更隐蔽的是运行时错误。语法没问题真正执行时却失败比如对一个字符串key执行LPOP这在入队阶段看不出来。看下面的例子127.0.0.1:6379 MULTI OK 127.0.0.1:6379 SET str hello QUEUED 127.0.0.1:6379 LPOP str QUEUED 127.0.0.1:6379 INCR count QUEUED 127.0.0.1:6379 EXEC 1) OK 2) (error) WRONGTYPE Operation against a key holding the wrong kind of value 3) (integer) 1SET成功、INCR也成功只有LPOP报错。这就是Redis事务让人又爱又恨的地方它不会因为一条运行期错误而回滚已经执行的命令。所以业务代码里凡是进了事务队列的命令都要在入队前确认类型和参数不能指望事务帮你兜底。这里可以给一个很不严谨但好记的类比MULTI/EXEC像是一个“快速执行器”它能保证这一串命令一口气跑完但跑完时发现中间有人摔了一跤它不会把已经跑完的人拉回起点只会让受伤的那一个停下来报错。所以你在使用前就要确保队列里所有人都有能力跑完全程。2.3 DISCARD和UNWATCH怎么用DISCARD的用途很直白。前面入队的命令如果有问题或者业务突然不想执行了就执行DISCARD。它会清空当前连接的事务队列让连接回到普通状态。和EXEC不同DISCARD不会执行任何命令相当于把草稿箱清空。UNWATCH和DISCARD容易混。UNWATCH只取消WATCH监视不会清空事务队列如果已经执行了MULTI再用UNWATCH就没意义了通常UNWATCH发生在WATCH之后、MULTI之前。如果你发现监视的key变了决定不执行这段逻辑可以先UNWATCH再重新WATCH这样比带着脏标记继续执行干净。还要提醒一点如果先执行WATCH再执行MULTI最后用DISCARD退出事务那么WATCH监视也会一并取消连接回到普通状态。如果只执行了WATCH还没有执行MULTI这时候不想继续监视才需要显式UNWATCH。这两个命令使用的时机不同搞清楚之后写客户端代码会顺手很多。3. WATCH乐观锁Redis事务的完整拼图3.1 为什么有MULTI/EXEC还不够仍然用库存例子。假设业务代码写成两步先GET stock得到1然后MULTIDECR stockEXEC。两个客户端同时执行这两个步骤A和B都先读到1然后都去执行DECR。MULTI/EXEC能保证A的DECR执行时不会被B插入但B的DECR依然会执行最后stock变成-1。问题在于MULTI/EXEC只保证“队列里的命令不被穿插”却没有约束“这条DECR该不该执行”。要约束执行条件就需要在提交时检查某个前提是否仍然成立。这个检查不是靠MULTI而是靠WATCH。用一句话总结MULTI/EXEC解决的是“怎么连续执行”WATCH解决的是“满不满足条件才执行”。两者配合才是一个完整的读改写控制方案。3.2 WATCH原理它到底在盯什么WATCH可以理解为给客户端连接挂了一个“监控探头”。执行完WATCH key之后如果另一个客户端修改了这个key当前连接会在内部被打上标记。等到当前客户端执行EXEC时Redis检查标记如果发现被监视的key动过就拒绝执行整个事务直接返回nil。还有一个很多人不知道的细节key因为过期被自动删除也会算作“被修改”。所以如果你监视了一个带过期时间的key即使没有任何客户端动它只要它在EXEC之前过期事务同样会失败。这在设计缓存型业务时尤其要注意。WATCH是非阻塞的。它不会阻止其它客户端写数据也不会锁住key只是把判断推迟到提交那一刻。这正是乐观锁的核心思想我假定冲突很少发生先正常做自己的事提交时发现版本变了就放弃重来。借助WATCHRedis实现的是一个典型的CASCompare-And-Swap流程。如果从服务端实现角度看可以简单理解为每个key维护了一个“版本号”概念WATCH时记录版本EXEC前比对版本。实际Redis内部用的是全局dirty计数和key监测结构但对外表现就是“变了就失败”。这也就解释了为什么WATCH只在你当前连接上生效换一个连接状态就没了。3.3 一个案例彻底搞懂WATCH与重试用两个终端模拟冲突。终端A执行127.0.0.1:6379 WATCH stock OK 127.0.0.1:6379 GET stock 1 127.0.0.1:6379 MULTI OK 127.0.0.1:6379 DECR stock QUEUED在终端A的EXEC之前让终端B执行127.0.0.1:6379 DECR stock (integer) 0此时回终端A执行EXEC返回的是nil而不是数组。说明WATCH发现stock被B改过事务被拒绝。如果没有B的插入A的EXEC会正常扣成0并返回数组。换成真实业务的Python代码可以这样写import redis def decr_stock(key): r redis.Redis() with r.pipeline() as pipe: while True: try: pipe.watch(key) stock int(pipe.get(key) or 0) if stock 0: pipe.unwatch() return False pipe.multi() pipe.decr(key) pipe.execute() return True except redis.WatchError: continue这段代码里最关键的是while循环。WATCH失败后不能直接返回错误因为库存可能已经被其它请求扣掉了必须重新读一次最新值再判断是否还有货。没有重试机制乐观锁就等于废了一半。重试的次数和间隔要在真实业务里控制好不能无限重试否则高并发下会形成请求风暴。3.4 WATCH与分布式锁该选谁很多人在选型时会把WATCH和SETNX锁混在一起。其实两者解决的问题不同。WATCH是并发控制里的乐观锁冲突由提交时检测SETNX命令可以做出互斥锁冲突由锁本身拦截。下面这个表能帮你快速做选择场景WATCH推荐度SETNX分布式锁推荐度单个key的简单读改写推荐也可以用但偏重需要同时保护多个不相关key单机可以集群下要小心跨slot更合适锁粒度可自定义高并发热点key冲突重试多压力大可以用锁队列但热点本身要治理长时间临界区不推荐不适合推荐需要回滚/补偿无法回滚锁只能互斥也不解决回滚实际项目中我见过不少用WATCH解决并发覆盖的案例也见过用SETNX锁保护一个跨多步操作的案例。选型关键看冲突频率和临界区长度冲突高、临界区长锁更合适冲突低、只是防一个key的覆盖WATCH更轻。还要补充一点WATCH检测到冲突后只是返回失败业务必须做好补偿。它不会帮你排队也不会让某个请求等待。如果业务无法接受失败和重试那么更合适的方案可能是分布式锁、消息队列串行化或者Lua脚本原子执行而不是单纯靠WATCH。4. 实战中的坑与排查经验4.1 WATCH必须在MULTI前面顺序反了直接报错新手最容易犯的错误是把WATCH写在MULTI之后以为事务一开始再监听也来得及。实际运行会得到错误127.0.0.1:6379 MULTI OK 127.0.0.1:6379 WATCH stock (error) ERR WATCH inside MULTI is not allowed原因不复杂WATCH的作用是在事务开始之前确认基础版本。如果已经进入MULTI命令都开始排队了这时再监视一个keyRedis无法保证“监视的起点”是事务之前的状态于是索性拒绝。记住这个顺序WATCH在前MULTI在后命令入队最后EXEC。UNWATCH也类似尽量不要放在MULTI里调用。虽然某些版本可能不会报错但事务中调用UNWATCH语义混乱很容易让代码阅读者误判。保持代码顺序清晰是避免事务类问题的最好习惯。4.2 事务里读不到事务前的操作结果MULTI里的GET不会立即返回值而是返回QUEUED。你可能会觉得那我在MULTI里先GET再DECR不是能判断库存吗可惜不能。GET的结果要等EXEC之后才会返回事务队列在执行前已经定型你完全没有机会根据读取结果修改队列里的命令。想实现“读后写”可以有两种办法一种是把读取放在WATCH之后、MULTI之前这时Redis还没有进入事务状态GET能立刻返回另一种是干脆用Lua脚本在脚本内部先读、再判断、最后写结果一次返回。相比之下Lua脚本更符合大多数人的直觉。这个坑在客户端代码里尤其隐蔽。很多新人写完MULTI后加了一个GET发现客户端返回的不是整数而是None或者异常第一反应是命令写错了。其实只是Redis事务对“返回时机”有严格限制除了每个命令入队时返回QUEUED其它结果都必须等EXEC统一给。4.3 EXEC返回nil不一定是网断了先查WATCH是否失效很多线上问题排查时看到EXEC返回nil第一反应是网络超时或主从切换。其实对加了WATCH的事务来说nil最常见的原因是你都还没EXEC被监视的key已经被别的客户端改了。排查方法很直接在客户端把WATCH到EXEC之间的时间打出来看看有没有慢操作同时确认是否有其它任务在并发写同一个key。如果确认有就看看业务能不能接受重试。另外一个不常见但存在的坑是key本身设置了过期时间到了EXEC那一刻恰好过期也会被判为修改导致事务失败。不同语言客户端对这种情况的暴露方式不一样。redis-py会把EXEC失败包装成WatchError异常而有些客户端可能只返回null需要你在业务代码里显式判断。最好在项目里统一封装一个事务执行函数对所有返回值做归一处理避免每个调用方都各自踩坑。4.4 集群环境下的WATCH限制Redis Cluster不是所有命令都能随便用。MULTI/EXEC、WATCH、Lua脚本都要求操作的所有key落在同一个哈希槽。比如WATCH了a和b两个key它们分属不同槽后面EXEC很可能直接报CROSSSLOT错误因为跨槽的原子执行在集群里无法保证。解决办法之一是用hash tag把多个相关key设计成{user:100}:cart和{user:100}:orders这种形式。Redis在计算哈希槽时只取花括号里的内容这样两个key就会落到同一个槽。但hash tag用多了也会引起数据热点要结合业务权衡。如果业务本来就在单机Redis上不需要考虑这个问题但一旦决定迁移到Cluster像WATCHMULTI这种涉及多key的方案就要重新审查。优先把相关key收敛到同一个业务边界里或者直接用Lua脚本配合单节点执行是更稳妥的方向。4.5 冲突重试别写成死循环乐观锁最怕重试风暴。在高并发秒杀场景下热点key只有那么一两个所有人都WATCH它只要一方改成功其它人都失败然后大家一起重试结果又有一方成功其余再失败如此反复。轻则请求耗时飙升重则把Redis连接池打满。给重试加上限和退避是必须的。你可以设置最多重试3次每次随机sleep几十毫秒或者用指数退避。如果冲突率依然很高就该考虑换Lua脚本脚本把检查和扣减放在同一个原子操作里不再需要客户端反复提交一次调用就完成判断。后面会专门对比。还有个容易被忽略的问题重试时不能无脑重新执行整段业务逻辑。如果重试前已经发送了外部通知、记录日志、扣减其他系统余额一旦WATCH失败又从头开始很可能产生重复副作用。正确做法是把“写Redis”和“外部副作用”解耦或者把整个读改写流程统一放到同一个补偿机制里。5. 常见问题速查与处理建议5.1 高频面试题Redis事务是原子的吗如果面试问“Redis事务是不是原子的”正确答案不是简单的“是”或“不是”。你应该分两层说第一MULTI之后、EXEC执行时所有入队命令不会被打断因此从命令执行粒度看是原子的第二如果事务中出现运行期错误已经执行的命令不会回滚未执行的命令也不会继续补偿所以不具备传统意义上的all-or-nothing原子性。再加上一句WATCH的作用WATCH不是让事务变原子而是提供乐观锁让事务在key被修改时能主动放弃。这样回答既有深度又不会把概念揉在一起面试官通常比较认可。如果进一步追问“能不能用事务替代Lua脚本”可以顺着说事务适合无条件的批量执行Lua脚本适合需要中间结果参与决策的场景。Redis执行Lua脚本时也是原子的而且脚本本身可以写复杂逻辑所以现在很多团队更愿意用Lua来做强一致操作。5.2 事务和Lua脚本怎么选实战对照如果你要做的不是简单写一批命令而是带有条件判断的读改写我建议优先考虑Lua脚本。看一个扣库存的脚本local stock tonumber(redis.call(GET, KEYS[1])) if not stock or stock 0 then return -1 end redis.call(DECR, KEYS[1]) return stock - 1调用方式类似EVAL script 1 stock。Redis执行Lua脚本时同样保证原子性而且脚本内部可以拿GET的结果去决定后续操作这是MULTI/EXEC队列做不到的。代价是脚本必须短小不能做耗时的循环或网络调用否则会阻塞整个Redis实例。我做项目时的经验是事务适合“我已经想清楚要执行哪几条写命令只是不希望被打断”的场景只要涉及“读了再判断再写”就直接上Lua。WATCH方案也能实现读改写但冲突高时重试压力大代码也容易写复杂。需要提醒的是Lua脚本在Redis Cluster里同样要求所有key在同一个slot。如果你跨slot调用脚本会得到CROSSSLOT错误。所以脚本设计时最好把相关key通过hash tag放到一起或者根据业务维度拆分更小的脚本避免一个长脚本管太多不相关的数据。5.3 一次线上库存扣减事故还原之前有位开发者跟我聊过一个抽奖活动库存key用Redis保存。最初实现是先GET剩余数量大于0就DECR没有用任何事务。结果活动开始没多久后台显示库存变成负数。原因很简单两个请求几乎同时GET到同样的剩余数1都认为可以扣然后先后DECR库存先变0再变-1。第一次修复加了MULTI/EXEC把DECR放进事务里但GET判断仍然在事务外竞态窗口照样存在负数依旧出现。后来换成WATCH重试问题才暂时解决再后来活动流量大了WATCH重试把Redis打得很热最终改成Lua脚本一次调用完成“检查库存、扣减、返回剩余量”压测数据才稳定下来。这个故事里每一步都是典型错误先忽略并发然后误以为MULTI能解决条件判断最后才发现还有重试压力和脚本选型问题。产品上线前不压测这些问题很难暴露一旦压测Redis命令耗时和错误率会很快告诉你答案。5.4 快速参考表常见问题的判断与解决把文中提到的坑整理成一张表方便大家排查时直接对照现象可能原因处理建议EXEC返回nilWATCH的key被其它客户端修改或key过期检查并发写增加重试或改用LuaEXEC返回EXECABORT入队阶段有语法或参数错误修复命令后再提交避免带错命令进队事务中某命令返回WRONGTYPE运行时类型错误其它命令已执行入队前校验key类型不能依赖回滚CROSSSLOT错误集群中多key不在同一slot使用hash tag或收敛业务key命令返回QUEUED后想立即读值事务未执行结果只能EXEC后拿把读操作放在MULTI前或改用Lua高并发下大量重试WATCH冲突频繁增加退避或直接用Lua脚本WATCH后连接断开连接级状态丢失重新建立连接后重新WATCH这张表不一定要背下来但建议收藏。线上出问题时先对现象再看原因能省掉很多不必要的排查时间。最后说一点我在真实项目里的体会。WATCH重试确实能解决并发覆盖但它并不适合所有场景冲突率低时代码简单、够用冲突率高时客户端重试本身就是一种额外压力。现在我遇到扣库存这类强一致操作一般直接写Lua脚本遇到跨多个key、需要业务判断的复杂流程才会考虑分布式锁或者队列化。如果你刚接触Redis事务建议先别急着背命令把MULTI/EXEC队列模型和WATCH的乐观锁思路亲手验证一遍。拿两个终端同时操作同一个key看一次EXEC返回nil比看十篇博客都管用。踩过这个坑你对Redis并发模型的理解会踏实很多。
📝

华诺云谱内容团队

资深建站顾问 · 行业研究员

10年+企业数字化服务经验,专注智能建站、SEO优化与品牌营销,持续输出建站技巧、行业洞察与营销干货,已帮助5000+企业实现数字化增长。

你可能需要的服务

订阅华诺云谱资讯周报

每周一封,精选建站技巧、SEO与营销干货,直达邮箱。已有 8,000+ 企业主订阅,助你少走弯路。

↑