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(host='127.0.0.1', port=6379, db=0) def deduct_stock(key, quantity, max_retry=5): 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(target=deduct_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 高频面试题整理
下面这些题是我在面试中被问过、也听同事面试别人时问过的,整理出来给需要的人参考:
Q1:Redis 事务是什么?怎么用?
最简单也最基础的题,把 MULTI、EXEC、DISCARD、WATCH 四个命令讲清楚,再举一个例子说明即可。重点是突出“打包执行”和“不回滚”两个特点。
Q2:Redis 事务能回滚吗?为什么?
不能。分两层回答:入队阶段发现语法错误,整个事务拒绝执行;运行阶段发现类型错误等,出错命令失败但其它命令继续执行。原因是 Redis 设计哲学追求简单高效,命令失败多由编程错误导致,回滚机制不值得引入。
Q3:WATCH 是乐观锁还是悲观锁?
乐观锁。它不阻塞其它客户端操作,而是在执行事务前检查“被监控的 key 是否被改过”,如果被改过就放弃执行,需要应用层重试。
Q4:Redis 事务和 Lua 脚本有什么区别?
Redis 事务保证隔离性但不保证原子性(不回滚),Lua 脚本整体作为一个原子操作执行,中途不会被其它命令插入。复杂逻辑推荐 Lua 脚本。
Q5:Redis 事务满足 ACID 吗?
四维分析:原子性不满足、隔离性满足、一致性要看语义理解、持久性取决于持久化策略。
Q6:Redis 事务在集群模式下有什么问题?
如果事务里操作的 key 不在同一个 slot,Redis 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 返回 nil | WATCH 的 key 在 EXEC 前被修改 | 捕获无结果分支,重试整个事务 |
| 事务里命令全部没执行 | 入队阶段语法错误,EXECABORT | 检查命令语法,避免拼写错误 |
| 部分命令成功部分失败 | 运行阶段类型错误,不会回滚 | 业务层校验数据类型,别依赖回滚 |
| 集群模式报跨 slot 错误 | 事务涉及多个 key 在不同节点 | 使用 hash tag 或改用 Lua |
| MULTI 嵌套报错 | 事务状态内再次调用 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,给未来扩展留条后路。这个细节在规划阶段很容易被忽略,等上了集群再改就麻烦多了。