资讯动态

Redis事务机制详解:从MULTI/EXEC到WATCH与Lua脚本实践

发布时间:2026/10/9 18:02:06 来源:尧图企业网站定制
Redis里的事务是被误解最多的功能之一。很多人一听到“事务”两个字脑子里立刻浮现出MySQL的回滚、隔离级别、脏读幻读然后拿着这套预期去用MULTI和EXEC结果线上第一个报错就让他们懵了。我这些年做后端见过太多在事务上翻车的案例也见过不少面试候选人把“Redis事务支持原子性”当成万能答案却连运行期错误不回滚这个最基本的特性都说不清楚。这篇文章就把Redis事务的完整机制拆开讲一遍MULTI、EXEC、DISCARD、WATCH四条命令怎么配合为什么它和MySQL事务是两码事库存扣减和秒杀场景到底该用事务还是Lua脚本以及那些文档里不写、但实战必踩的坑。想搞清楚Redis事务边界的开发者、准备相关面试题的人都可以参考。1. Redis事务的整体定位排队执行不是ACID全家桶1.1 MULTI到EXEC之间发生了什么Redis事务的入口是MULTI命令。执行MULTI之后当前连接进入事务状态之后发送的命令不会被立即执行而是被塞进一个命令队列Redis对每一条命令都返回QUEUED。直到你发送EXEC队列里的命令才会按顺序一次性执行。先看一个最简单的实验redis-cli里直接敲127.0.0.1:6379 MULTI OK 127.0.0.1:6379 SET user:10086:balance 500 QUEUED 127.0.0.1:6379 DECRBY user:10086:balance 100 QUEUED 127.0.0.1:6379 EXEC 1) OK 2) (integer) 400这个过程中有个容易被忽略的本质从MULTI到EXEC之间其他客户端发来的命令并不会“插队”。Redis是单线程执行模型的EXEC一旦开始队列里的命令会一条接一条执行完中间绝对不会穿插其他客户端的命令。所以Redis事务提供的核心保证其实是串行执行或者说一种非常朴素的隔离性。它不保证的事情更多没有回滚、没有隔离级别、不做锁等待。官方对事务的定位非常克制就是要一个“批量命令打包执行”的机制。理解这一点后面很多坑就能想通了。1.2 和MySQL事务的差距一张表说清楚把Redis事务和MySQL事务放在一起对比差异非常明显对比项MySQL事务Redis事务回滚能力支持出错自动回滚不支持运行期错误会继续执行后续命令隔离级别RC、RR、可串行化等没有隔离级别靠单线程天然串行锁机制行锁、表锁、MVCC无锁靠WATCH实现乐观锁持久性依赖redo/undo日志和fsync策略依赖RDB/AOF策略与事务本身无关交互方式BEGIN/COMMIT/ROLLBACKMULTI/EXEC/DISCARD/WATCH这张表建议直接记住因为面试里被问到“Redis事务和数据库事务的区别”基本就是这些点。很多人喜欢说“Redis事务保证原子性”严格讲这并不准确。原子性一般指“要么全成功、要么全失败”而Redis事务在运行期命令报错时是部分成功的。它只保证一批命令被连续执行不保证失败后的状态还原。2. DISCARD、WATCH的正确用法以及两类错误的区别2.1 DISCARD是后悔药但不是回滚如果进入MULTI之后反悔了在EXEC之前可以执行DISCARD命令队列会被清空连接退出事务状态127.0.0.1:6379 MULTI OK 127.0.0.1:6379 SET user:1:name foo QUEUED 127.0.0.1:6379 DISCARD OK这个命令在业务代码里经常被用来做“条件放弃”。比如在事务里先入队了几条命令然后在执行前发现前置条件不满足就可以DISCARD掉不让它们执行。有一个比较冷门的点命令队列不是无上限的。如果某个客户端执行了MULTI之后一直不EXEC也不DISCARD命令会一直堆在内存里。正常情况下问题不大但压测或者有异常连接时如果Redis内存异常上涨可以查一下是不是有连接卡在事务状态里没退出来。可以用CLIENT LIST查看连接状态发现了就及时处理。2.2 WATCHCAS思想在Redis里的落地MULTI、EXEC、DISCARD这三条命令只能保证“连续执行”不能保证“执行条件仍然成立”。比如经典的超卖问题你先读库存发现还有1件然后进入事务扣减但读和扣之间另一个请求已经把库存改成0了你这边照样会扣成功这就不对了。WATCH就是来解决这个问题的。它的作用是对一个或多个key加监视然后在EXEC执行前Redis检查这些key自WATCH以来有没有被其他客户端修改过。只要有任何修改EXEC直接返回nil事务里所有命令都不执行。看个例子。两个客户端同时操作stock这个key客户端A: 127.0.0.1:6379 WATCH stock OK 127.0.0.1:6379 GET stock 1 此时客户端B执行: SET stock 0 客户端A: 127.0.0.1:6379 MULTI OK 127.0.0.1:6379 DECR stock QUEUED 127.0.0.1:6379 EXEC (nil)客户端A的EXEC返回nilDECR根本没有执行。这就是典型的CASCompare And Swap思想先记住版本状态执行前对比发现变了就放弃。返回nil之后整个事务等于没发生需要自己重试。重试逻辑一般是一个循环WATCH、读值、判断、MULTI、入队写命令、EXEC如果返回nil就再来一轮直到成功或者超过最大重试次数。2.3 排队期错误和运行期错误处理逻辑完全相反刚接触事务的人最容易把两类错误混在一起。第一类是排队期错误。命令在进入队列时就能发现的问题比如命令拼写错误、参数数量不对。这时候Redis不会返回QUEUED而是直接报错。更关键的是一旦事务队列里出现这种错误整个事务就被标记为异常状态之后EXEC会直接返回EXECABORT所有已排队的命令一个都不执行。第二类是运行期错误。命令本身合法但在执行时才暴露问题比如对字符串类型的key执行LPUSH。这种错误只影响当前一条命令EXEC返回结果里对应位置是error但后续命令照常执行前面已经执行的命令也不会回滚。注意排队期错误属于“编程错误”事务整体作废运行期错误属于“数据/逻辑错误”Redis不做回滚。写代码时一定要分清楚自己写的命令属于哪一类别把希望寄托在事务回滚上Redis没有这个能力。3. 实战库存扣减、秒杀以及为什么老手偏爱Lua脚本3.1 用WATCHMULTI实现不超卖用前面几节的原理可以写一个安全扣库存的代码。以Python的redis-py客户端为例import redis def decr_stock(rc: redis.Redis, key: str, num: int) - bool: for _ in range(3): # 最多重试3次 try: with rc.pipeline() as pipe: pipe.watch(key) # 1. 监视key stock int(pipe.get(key) or 0) # 2. 读当前库存 if stock num: return False # 3. 库存不足直接失败 pipe.multi() # 4. 开启事务 pipe.decrby(key, num) # 5. 扣减 result pipe.execute() # 6. 执行 if result is not None: # 7. 检查是否被WATCH拦下 return True except redis.WatchError: # 8. 监视的key被改动 continue return False这段逻辑的核心是WATCH和读值要在同一个连接里完成MULTI、EXEC也要在同一个连接里完成中间不能断。因为WATCH状态是绑定在连接上的连接一断监视就失效了。这种写法在低并发场景下完全够用但有一个先天问题一旦冲突频繁每次失败都要重新WATCH、重读、重试客户端代码变复杂Redis也会被打很多额外请求。在秒杀这种高竞争场景下WATCH的重试风暴会让情况雪上加霜。3.2 Lua脚本更省事的原子操作方案正因为WATCHMULTI的重试逻辑繁琐业界在真实的高并发扣减场景里更多是直接用Lua脚本。Redis从2.6开始支持执行Lua脚本脚本本身是原子执行的脚本运行期间不会有其他客户端命令插入。这个性质比事务更强因为整个判断和执行都在一气呵成里完成不存在“读和写之间被别人改掉”的窗口期。同样扣库存Lua脚本可以写成这样local stock tonumber(redis.call(GET, KEYS[1]) or 0) if stock tonumber(ARGV[1]) then return 0 end redis.call(DECRBY, KEYS[1], ARGV[1]) return 1调用方只需要把key和扣减数量传进去返回值0表示库存不足1表示扣成功。不需要WATCH不需要循环重试不用担心连接里的WATCH状态一次调用搞定。要注意的是Lua脚本如果在执行过程中报错前面已经执行的写操作同样不会自动回滚这一点和事务是一致的。所以脚本内部要做充分的前置判断别指望出错了还能还原状态。另外脚本里用到的key数量多时要注意集群模式下所有key必须落在同一个槽位这跟事务的限制一样。3.3 事务和Pipeline千万别混为一谈Pipeline管道和事务也是经常被搞混的一对。Pipeline的核心作用是减少网络往返把一批命令一次性发给Redis再一次性收回结果它本身没有任何事务语义命令之间完全可以插入其他客户端的命令。MULTI/EXEC的核心作用是保证一串命令的连续执行它也有减少网络往返的副作用因为命令是在EXEC时一次性发给Redis的。两者可以结合使用很多客户端库在开启事务后也确实会自动走pipeline的方式发送。但反过来单纯用Pipeline不等于事务这一点在面试里也经常被问到。实战中的建议是“不需要原子性、只需要省网络开销”的场景用Pipeline“需要批量命令不被插队”的场景用MULTI/EXEC“需要先判断再执行、避免条件竞争”的场景优先考虑Lua脚本。把这三者边界搞清楚代码会简洁很多。4. 常见故障排查与易错点整理4.1 线上最常见EXEC返回nil到底谁改了我的keyWATCHMULTI最常见的问题就是EXEC返回nil。这时候第一反应不是去看业务逻辑而是确认是不是有别的线程或服务在并发修改你WATCH的key。常见的情况包括后台任务在同一个key上做定期更新撞上了业务高峰期。多个实例共用同一个key做计数彼此没有协调。缓存刷新逻辑和扣减逻辑操作了同一个key但没走同一套事务。排查手法很简单在返回nil的日志里把WATCH的key记录下来同时开启Redis的monitor命令观察一段时间看看到底是谁在这些时间点改了key。多数情况下调整业务逻辑让互不干扰的key分开或者改用Lua脚本问题就消失了。4.2 连接复用导致WATCH失效这是最容易踩的坑前面提过WATCH状态是绑定在连接上的。业务系统用了连接池之后如果不注意连接绑定很容易出现这种局面某次请求从连接池借了一个连接执行WATCH然后代码把连接还回池子里另一个请求借到同一个连接却发现还有残留的事务状态或者更糟——WATCH压根就不生效了。解决方式有两条。一是确保WATCH、MULTI、EXEC整个流程都在同一个连接里完成像3.1里的示例代码就是通过pipeline把整个流程圈在一个连接中。二是连接释放时主动清理事务状态redis-py等在连接归还时一般会做reset但要确认使用的客户端版本确实有这个行为。除了连接问题还要记得EXEC之后WATCH会失效整个事务结束所有监视关系都被清除。如果还想继续用WATCH必须重新WATCH。这是设计上就定好的不算Bug但新手经常在这里犯迷糊。4.3 集群模式下的CROSSSLOT限制Redis Cluster下使用事务有个硬性限制事务队列里的所有key必须落在同一个哈希槽上。跨槽的MULTI/EXEC会直接报CROSSSLOT错误。原因不难理解集群环境下不同槽位的数据可能在不同主节点上单节点的事务没法跨节点生效。解决办法是使用哈希标签hash tag。让多个相关key的键名里包含相同的大括号内容比如{order:10086}:items和{order:10086}:count这样两个key会落到同一个槽位事务就能执行了。但要注意hash tag用多了会导致热点key集中在单个节点上这个trade-off要根据业务量来权衡。4.4 常见错误速查表现象/错误原因处理办法EXECABORT排队期有命令错误修命令语法重提事务WRONGTYPE运行期类型不匹配检查key是否被错误的数据结构占用CROSSSLOT集群跨槽位用hash tag让key落到同一槽EXEC返回nilWATCH的key被修改重试或改用Lua脚本连接断掉后事务状态消失WATCH/MULTI绑定连接确保整个流程用同一连接这张表基本覆盖了我在项目里遇到过的所有Redis事务相关报错。遇到问题先对号入座能省下很多排查时间。5. 最后说点我的个人体会做了几年后端我的感受是Redis事务的真实价值比很多人想象的要小但也比很多人想象的要大。说它小是因为它确实不提供回滚、隔离级别这些“正统事务”能力你没法把它当成MySQL的替代品来用。说它大是因为它提供了一种简单可靠的“批量连续执行”机制配合WATCH就能解决很多单key维度下的条件竞争问题。我现在的选型习惯是这样的如果只是把多个写操作打包执行不需要前置判断用MULTI/EXEC就够了如果需要先判断再写比如扣库存、限流计数直接用Lua脚本不折腾WATCH那套重试逻辑Redis事务更多用在面试题考察和框架代码的兼容性处理上。至于跨Redis、跨数据库、跨系统的分布式事务那已经不是Redis事务能解决的范围了需要本地消息表、事务消息或者补偿机制来兜底别指望MULTI能包打天下。最后分享一个小技巧排查事务问题永远先开monitor看命令流比反复看日志高效得多。先把命令执行的真实顺序看清楚再谈优化方案。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑