Redis 的事务(Transaction)在官方文档里通常就被四个命令概括:MULTI、EXEC、DISCARD、WATCH。可一旦真在业务里跑起来,很多人会发现它和我们习惯的关系型数据库事务差得不是一点半点。这篇文章就把这四个命令拆开揉碎了聊一遍:Redis 事务到底是怎么设计的、命令怎么配合、什么时候会静默失效、以及遇到“需要回滚”的场景应该如何换思路。适合已经掌握 Redis 基础命令、准备在关键业务里引入事务,或者面试前想把底层行为彻底搞清楚的同行参考。
1. Redis 事务是什么:先看核心设计和定位
1.1 事务到底要解决什么问题
Redis 事务的初衷并不复杂:让多个命令作为一个整体,在 Redis 服务端按顺序执行,并且执行过程中不会被其他客户端的命令插入。举一个最常见的例子:一次库存扣减,往往要同时做“扣减库存数量”和“写入扣减记录”。这两条命令分开发送,中间如果插进来一个查询或者别的写入,虽然不一定出错,但逻辑边界就模糊了;更麻烦的是如果客户端在第一条命令成功后、第二条命令前崩溃,数据就只剩了一半。
用 MULTI/EXEC 包住这两条命令,就能避免这种“中间插入”和“执行一半”的尴尬。Redis 是单线程处理命令的,事务一旦开始 EXEC,队列里的命令会被逐一执行,在事务执行完毕之前,其他客户端的请求只能排队等着。所以 Redis 事务提供的核心能力是:把多个动作打包成一个不可被插队的整体。
需要明确的是,这个“整体”不等于数据库里的 ACID 事务。它更像是一个“串行执行队列”,而不是“要么全做、要么全不做”的原子操作。
1.2 它和关系型数据库事务的三处关键差异
很多人在 MySQL 里习惯了事务,下意识认为 Redis 事务也应该有回滚能力,这是最容易踩坑的地方。三处关键差异必须提前说清楚:
| 方面 | 关系型数据库事务 | Redis 事务 |
|---|---|---|
| 原子性 | 支持回滚,出错后可以恢复到事务开始前 | 不支持回滚,运行时错误不会撤销之前已执行的命令 |
| 隔离性 | 需要通过隔离级别控制可见性 | 单线程事件循环天然隔离,事务内命令不会被插队 |
| 持久性 | 通常在事务提交时有完整持久化保障 | 取决于 RDB / AOF 配置,Redis 自身不保证每笔事务都立即落盘 |
所以,如果你需要的功能是“先检查库存,再扣库存,如果中间失败就把前面的扣减还原”,直接用 Redis 原生的 MULTI/EXEC 做不到。这不是 Redis 不完整,而是它的设计思路本来就不一样。Redis 更推荐用 WATCH 做乐观锁,或者在需要复杂控制和回滚时改用 Lua 脚本。
2. 四个核心命令的语法和细节
2.1 MULTI:事务从这里开始
MULTI 是整个事务的入口。客户端发送 MULTI 后,连接会进入事务状态,Redis 会返回 OK。从这一刻起,后续所有命令都不会被立即执行,而是被塞进一个命令队列,Redis 对每条入队命令返回 QUEUED。
> MULTI OK > SET product:1001:stock 5 QUEUED > INCR product:1001:sold QUEUED这里有一个容易忽略的点:MULTI 期间返回 QUEUED 只表示“命令已经进入队列”,并不代表命令一定能成功执行。命令真正被执行的时机是在 EXEC 发出之后。也就是说,队列里即使存在命令,你也无法提前拿到执行结果,所有结果都要等 EXEC 一次性返回。
另外,MULTI 不能嵌套。如果连接已经处于事务状态,再发送 MULTI,Redis 会直接报错。开发时如果用了连接池,一定要留意连接是否被复用,避免把上一个事务的状态带到下一条命令。
2.2 EXEC:把队列真正跑起来
EXEC 是触发事务执行的命令。它会把当前连接队列里排好的命令按顺序执行,然后把每条命令的结果打包成一个数组返回。
> EXEC 1) OK 2) (integer) 6如果队列里没有任何命令就调用 EXEC,Redis 会返回空数组。另一个关键点是,一旦 EXEC 执行完毕,这个连接上的事务状态就自动结束了,不需要额外清理。
需要特别注意的是,EXEC 的原子性体现在“执行过程中不会被其他请求插队”,但不是说队列里所有命令都成功才叫成功。后面讲运行时错误时我会再展开,这里先记住一个结论:Redis 事务不会因为其中一条命令运行时报错,就把其他命令回滚掉。
2.3 DISCARD:随时反悔
如果 MULTI 之后、EXEC 之前,你发现命令队列有问题,或者不想执行了,可以直接发送 DISCARD。它会清空当前事务队列,并让连接恢复正常状态。
> MULTI OK > SET product:1001:stock 5 QUEUED > DISCARD OKDISCARD 之后,之前排队的命令全部作废,并且如果之前使用了 WATCH,也会在 DISCARD 后解除监控。注意,DISCARD 只是在 EXEC 之前放弃,一旦 EXEC 已经发出,就没办法通过 DISCARD 反悔了。
从使用习惯上来说,我建议在事务队列构造失败、参数校验不过时优先考虑 DISCARD,而不是直接 EXEC 一个明知有问题的队列,这样可以避免无意义的执行。
2.4 WATCH/UNWATCH:乐观锁的正确用法
WATCH 是 Redis 事务里最容易被忽略、也最有用的命令。它解决的是“先读后写”场景下的条件竞争问题。典型例子是:先读取某个 key 的值,判断满足某个条件后,再在事务里修改它。如果读取之后、EXEC 之前,这个 key 被其他客户端改了,那么整个事务就应该作废。
WATCH 可以监控一个或多个 key。在事务提交前,如果被监控的 key 被其他客户端修改过,EXEC 会直接返回 nil,并拒绝执行整个事务队列。这个机制相当于给指定 key 加了一个乐观锁。
> WATCH product:1001:stock OK > GET product:1001:stock "5" # 这里假设另一个客户端突然执行了 INCR product:1001:stock > MULTI OK > DECR product:1001:stock QUEUED > EXEC (nil)UNWATCH 则用于主动解除所有 WATCH 监控。它一般在你已经通过 GET 做判断、但最终决定不执行事务时使用,避免这个连接带着残留的监控进入下一次操作。
需要特别提醒:WATCH 必须在 MULTI 之前调用,这样才能在事务开始之前建立监控。如果在 MULTI 之后调用,它会被当成普通事务命令排队,等到 EXEC 时再执行,这时已经失去了“事务执行前检查修改”的意义。
3. 手把手跑通一个 Redis 事务
3.1 场景设计:库存扣减
这里我用一个非常常见的“下单减库存”场景来做演示。假设有一个 Redis key 叫product:1001:stock,当前库存是 10。用户下单需要做两件事:把库存减 1,然后写一条订单状态。
如果只是用 MULTI/EXEC 直接把 DECR 和 HSET 包起来,能做到不插队,但做不到“库存不足时不下单”。因为在 MULTI 里你没法根据判断结果临时决定要不要执行。所以,正确的姿势是用 WATCH 先监控库存 key,然后在事务开始前读取库存,由客户端判断是否够卖;够卖再拼装命令队列,不够卖就 UNWATCH 直接返回。
3.2 使用 WATCH + MULTI 的完整操作
初始数据:
> SET product:1001:stock 10 OK > SET product:1001:order 0 OK客户端逻辑:
> WATCH product:1001:stock OK > GET product:1001:stock "10"此时客户端在应用里做判断:10 > 0,可以下单。于是继续:
> MULTI OK > DECR product:1001:stock QUEUED > HSET order:1001 status "paid" QUEUED > EXEC 1) (integer) 9 2) (integer) 1如果 WATCH 之后,另一个客户端抢先把product:1001:stock改成 9,那么同样的 EXEC 会返回:
> EXEC (nil)这时候,DECR 和 HSET 都不会执行。客户端收到 nil 后,需要通过再次读取库存、再次判断、再次重试,来完成一次安全的下单流程。
3.3 核心细节:失败重试逻辑怎么写
WATCH 的事务在并发冲突时会静默返回 nil,所以应用层必须设计重试。重试的核心是循环一定次数,每次循环重新 WATCH、重新读取、重新判断,直到 EXEC 成功或超过最大重试次数。
拿 Python 里的 redis-py 来写大致伪代码:
MAX_RETRY = 3 def deduct_stock(r, product_key, order_key): for _ in range(MAX_RETRY): r.watch(product_key) stock = int(r.get(product_key) or 0) if stock <= 0: r.unwatch() return False pipe = r.pipeline(transaction=True) pipe.decr(product_key) pipe.hset(order_key, "status", "paid") try: pipe.execute() return True except WatchError: continue return False这里有个细节很多人容易写错:重试时不能复用第一次创建的 pipeline,因为 WATCH 状态是绑定在连接上的,第二次重试必须重新 WATCH 并新建 pipeline,否则监控状态可能是旧的。我实际开发中踩过这个坑,看起来逻辑没问题,但并发一高就偶尔出现数据错误。
4. 常见问题与避坑指南
4.1 语法错误和运行时错误是两码事
Redis 事务里的错误分两个阶段,行为完全不同。阶段一是在命令入队时,如果 Redis 发现命令格式不对、参数个数不合法,会直接返回错误信息。此时队列里已经有一个非法命令,到 EXEC 时 Redis 会直接拒绝执行整个事务队列,返回错误。这类错误通常可以在 MULTI 阶段提前发现。
阶段二是命令在 EXEC 执行时发生错误,比如对 string 类型的 key 执行 LPUSH,Redis 会返回 WRONGTYPE 错误。和很多人想的不一样,这一条命令出错不会让后面的命令停止,也不会让前面已经执行的命令回滚。Redis 会把每条命令的执行结果依次放入返回数组,错误命令在数组里占一个 error 条目,后续命令照常执行。
> SET mykey "hello" OK > MULTI OK > LPUSH mykey item QUEUED > INCR cnt QUEUED > EXEC 1) (error) WRONGTYPE Operation against a key holding the wrong kind of value 2) (integer) 1所以,如果事务里有多条命令,其中一条运行时出错,其他命令依然生效,数据可能处于“改了一半”的状态。设计业务时一定要对 key 类型和值类型做前置校验,或者考虑用 Lua 脚本在服务端做类型检查和条件判断。
4.2 事务和 Pipeline 别搞混
Pipeline 是把多个命令一次发给服务端,减少网络往返成本;事务则是把多个命令按原子方式串行执行。Pipeline 本身不提供任何隔离性,多个 Pipeline 里的命令依然可能被其他客户端的请求穿插执行。事务也不自带网络优化效果,MULTI 阶段每条命令仍然会收到一次 QUEUED 响应,网络往返不会减少。
开发中常见的组合方式是“Pipeline + 事务”:把 MULTI、多条命令、EXEC 一起打包发送,既能减少包数量,又能获得事务能力。但用的时候要小心,事务期间的所有中间响应(包括 QUEUED)也会一起回来,解析顺序一旦对不上,客户端就乱了。主流客户端库都有现成的 transaction 接口封装,建议优先用封装好的能力,而不是自己手工拼 MULTI/EXEC。
4.3 在 Cluster 模式下使用事务的限制
Redis Cluster 模式下,事务内操作的 key 必须落在同一个哈希槽上,否则 EXEC 会直接报错。要同时操作多个 key,可以利用哈希标签,比如把用户中心相关的 key 写成{user:123}:balance和{user:123}:lock,花括号内的内容参与哈希计算,这样可以强制让它们落到同一个槽上。
WATCH 同样受这个限制,被监控的 key 和事务内操作的 key 都应该在同一个槽内。如果你在 Cluster 环境里遇到“CROSSSLOT Keys in request don't hash to the same slot”错误,先检查 key 命名,别怀疑是事务命令写错了。
4.4 该用 MULTI/EXEC 还是 Lua 脚本
| 场景 | 推荐方案 | 原因 |
|---|---|---|
| 只需要无脑批量执行命令 | MULTI/EXEC | 简单直接,保留原子执行 |
| 需要在执行前判断 key 值做条件写 | WATCH + MULTI/EXEC | 乐观锁 + 客户端重试 |
| 需要复杂条件分支、循环、类型校验 | Lua 脚本(EVAL) | 逻辑在服务端执行,避免多次网络往返 |
| 需要出错后主动回滚前面操作 | Lua 脚本 + 手动补偿 | Redis 原生事务本身不回滚 |
一个很常见的认知是:Lua 脚本就是用来替代事务的。其实不完全对。Lua 脚本确实能解决“读改写”条件竞争问题,因为整个脚本在服务端原子运行,中途不会被其他命令插入。但如果脚本执行到一半发生错误,Redis 也不会自动撤销之前写入的数据。和原生事务一样,回滚要靠脚本内自己的补偿逻辑。
5. 几个常见应用场景和我的取舍心得
5.1 适合直接用 MULTI/EXEC 的场景
如果业务里只是需要把几个独立的写操作聚合在一起,不依赖读过的数据做判断,那 MULTI/EXEC 是我最喜欢的方案。比如批量更新多个计数器、同步写入缓存和标记位、一次性设置多个配置项,这类场景用事务能避免请求穿插,代码也很容易读。
这里有一个实测下来的习惯:尽量让事务内的命令数量保持在可控范围内。Redis 事务是单线程执行的,队列里命令太多,后面的所有客户端请求都会被阻塞。虽然没有硬性数量限制,但超过几十条命令时,你要认真想想是否真的应该放在同一个事务里。
5.2 真正需要 WATCH 的场景
需要“先读后写”且数据并发竞争明显的场景,WATCH 几乎是唯一的选择。举例来说:防止超卖、优惠券领取总数控制、秒杀库存预扣,这些场景都是先读当前值,判断可执行后再更新。WATCH 等于给这个判断过程加了一个版本校验,竞态发生时事务会失败,客户端重新判断即可。
不过要提醒一点:WATCH 冲突发生的概率高了,重试也就会频繁。如果某个热点 key 每秒被更新成千上万次,WATCH + 重试的代价可能会很大,或者出现活锁。这时候需要考虑把请求队列化、用分布式锁、或者让业务上做“最终一致性”而不是每次强一致。
5.3 我个人更推荐什么样的替代方案
在实际项目里,我见过很多人在不需要事务的地方强行加事务,最后既增加复杂度,又没换来回滚能力。Redis 很多原子命令本身就能解决一部分问题:INCR/DECR 自增自减是原子的,SET if NX 可以处理去重,HSETNX、LPUSH 等单命令操作也都有原子性。能用原子命令解决的,就不要套事务。
需要多命令强一致时,我更推荐优先考虑 Lua 脚本。原因很简单:Lua 脚本可以在服务端执行条件判断,不需要先 GET 回客户端判断再走一轮,逻辑上更紧凑;而且脚本整体原子运行,不会在判断和执行之间被其他请求钻空子。唯一要留意的是脚本要写得收敛,不能在里面做耗时循环,否则会阻塞整个 Redis 实例。
最后分享一个我踩过几次的坑:事务里尽量不要混用“读取某个 key 后决定写另一个 key”的操作。因为 MULTI 阶段不能动态决定下一步命令,只能把命令全部提前排好,一旦前面读到的数据影响后续命令逻辑,就必须借助 WATCH 和重试,而重试代码写不好很容易留下脏数据。看清 Redis 事务的边界,才能把它用在合适的位置。