面试被问到"Redis事务",十个有八个会拿MySQL事务的语义往上套,然后被追问"为什么不支持回滚"时就开始含糊。我在招人时经常把这个话题当试金石——能不能说清MULTI/EXEC/WATCH到底做了什么、事务里命令报错时会发生什么、实际业务里哪些场景该用事务、哪些场景该用Lua脚本,基本能判断一个人是背过八股还是真在项目里处理过并发。这篇不写泛泛的八股,直接把Redis事务的底层机制、边界条件和实战用法拆开讲,顺便把我在生产环境踩过的几个坑也交代清楚。
如果你是刚在Windows或Mac上装好Redis准备练手的新人,或者已经在维护线上Redis的老手,这篇都可以当一份参考笔记看。看完你会明白:Redis事务不是MySQL事务的弱化版,它是一套完全不同思路的"排队执行+条件提交"机制。
1. 先搞清楚一个大前提:Redis事务到底解决什么问题
1.1 大多数人对Redis事务的第一个误解
很多刚接触Redis的人,脑子里默认的"事务"是:要么全部成功,要么全部回滚,像MySQL的BEGIN/COMMIT/ROLLBACK那样。这个模型套在Redis上,第一步就走偏了。
Redis事务的设计目标是另一回事:它要保证一批命令在执行期间不被其他客户端的命令插入。也就是说,当客户端发出MULTI开始入队,再发一堆命令,最后发EXEC提交,Redis服务端会把这批命令当成一个连续的队列,一口气执行完。执行过程中,其他客户端发来的命令只能排队等待,无法插进这个队列里。
可以把它类比成银行柜台的特殊窗口:你带着一沓单据进去,柜员把门一关,一张一张给你办完,期间其他人的业务都在门外等着。这个窗口不会因为其中某张单据有问题就把之前办完的业务全部撤销——它已经办出去的,就办出去了。
这一点如果不先想通,后面看WATCH、看"无回滚"、看"部分执行"都会觉得处处矛盾。
1.2 一句话概括Redis事务的定位
用一句不那么严谨但比较好记的话来说:Redis事务是"命令的打包串行执行",外加一个可选的"提交前条件校验"机制。
- 打包:
MULTI开启,后续命令进队列,EXEC一起执行。 - 串行:整个事务执行期间,不会有其他客户端的命令插入。
- 条件校验:
WATCH可以监控一些key,事务提交时如果这些key被改过,事务直接作废。
它解决的核心痛点是"多步操作之间被打断"的问题,而不是"操作出错后能恢复"的问题。这个定位决定了它的能力边界,也决定了它适合用在哪些场景。后面所有实战讨论,都建立在这个大前提之上。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 底层机制拆解:MULTI/EXEC/DISCARD/WATCH 到底怎么工作
2.1 从MULTI到EXEC的完整链路
用redis-cli看一遍最直观:
bash复制> MULTI
OK
> SET stock 100
QUEUED
> DECRBY stock 1
QUEUED
> GET stock
QUEUED
> EXEC
1) OK
2) (integer) 99
3) "99"
注意几个容易被面试官抓的细节:
MULTI返回OK,表示事务开始。- 之后的命令不直接执行,而是返回
QUEUED,表示命令已经进入事务队列。 EXEC会一次性执行队列里的所有命令,然后返回一个数组,数组里的每个元素对应一条命令的执行结果。- 如果中途反悔,可以用
DISCARD清空命令队列并退出事务。注意DISCARD同时会取消当前连接上的所有WATCH,这一点后面排查问题时很重要。
事务队列保存在当前客户端连接的状态里。如果客户端在MULTI之后、EXEC之前断开连接,服务端会清理这个连接上的事务状态,队列直接丢弃,命令自然也不会执行。所以"连接异常导致事务悄悄变成半截"这个担心不成立。
2.2 WATCH乐观锁的CAS实现
WATCH是Redis事务里最有价值的部分,也是面试中区分"看过文档"和"真用过"的分水岭。
它的工作方式是:在MULTI之前,先WATCH一个或多个key。当客户端执行EXEC时,Redis会检查被WATCH的key从开始监控到现在有没有被其他客户端修改过。只要有一个key被动过,整个事务直接取消,EXEC返回nil,队列里的命令一条都不执行。
这个机制本质上是数据库里的乐观锁(CAS,Compare And Swap):提交时校验版本,版本变了就拒绝提交。
看一个简单的秒杀扣库存思路:
bash复制> WATCH stock:001
OK
> GET stock:001
"100"
> MULTI
OK
> DECRBY stock:001 1
QUEUED
> EXEC
1) (integer) 99
如果在我WATCH和EXEC之间,另一个客户端把stock:001改成了50,那么我这条EXEC会返回(nil),扣减不生效。
有一个很多文章不会提的细节:只要key被写命令动过,即使改完的值和原来一样,也会触发WATCH冲突。比如原来值是100,别的客户端执行SET stock:001 100,值没变,但EXEC依然会被取消。Redis判断的是"key是否被修改过",不是"值是否发生变化"。
另外注意,WATCH解决的不是"不让别人改",而是"别人改了我就不提交"。所以它是乐观并发控制,不是悲观锁。高并发场景下如果冲突频繁,客户端需要自己实现重试逻辑。
2.3 事务队列中的两类错误,结果完全不同
这是Redis事务最容易让新手翻车的地方,也是面试高频考点。
第一类错误发生在命令入队阶段,也就是MULTI之后、EXEC之前。比如命令拼写错误、参数数量不对,Redis能立即识别:
bash复制> MULTI
OK
> SET stock 100
QUEUED
> INCRBY stock abc
(error) ERR value is not an integer or out of range
> EXEC
(error) EXECABORT Transaction discarded because of previous errors.
只要事务队列里存在这种入队错误,EXEC会直接返回EXECABORT,整批命令全部不执行。注意这个行为和"Redis事务不支持回滚"并不矛盾——这是提交前发现的错误,相当于还没开始执行,所以能全盘放弃。
第二类错误发生在命令执行阶段。比如事务里有一条命令对字符串类型执行了LPUSH,这种错误在执行时才会暴露:
bash复制> MULTI
OK
> SET key "hello"
QUEUED
> LPUSH key item
QUEUED
> INCR counter
QUEUED
> EXEC
1) OK
2) (error) WRONGTYPE Operation against a key holding the wrong kind of value
3) (integer) 1
这种情况下,LPUSH这条命令报错,但它前后的SET和INCR都正常执行了。这就是常说的"运行期错误部分执行,没有回滚"。
所以严谨表述应该是:入队阶段发现错误,事务整体不执行;执行阶段发现错误,只有出错的那条命令失败,其他命令不受影响。面试时能把这两类错误分开讲,比笼统说一句"不支持回滚"高一个档次。
3. 面试里绕不开的"无回滚"争论:原子性到底怎么理解
3.1 官方为什么不支持回滚
Redis官方文档对"不支持回滚"的解释很直白,核心理由有三条:
第一,Redis事务中的命令在入队阶段就能发现大部分错误(语法错误、参数错误),这些错误会在EXEC之前暴露并终止整个事务。真正运行期才报的错,通常是类型错误这类"编程错误",程序应该在上线前通过测试就发现,而不是指望数据库回滚来兜底。
第二,回滚机制会大幅增加Redis实现的复杂度。Redis的定位是高性能、高可用的基础组件,为了一个"正常逻辑下基本不会触发"的能力增加大量复杂逻辑,不划算。
第三,很多情况下回滚并不能保证数据正确。比如事务里先删了一个key,后面某条命令失败,如果要回滚这个删除,Redis需要记住被删掉的整个数据结构,可能是一个很大的列表或哈希表,如果用快照方式回滚,内存和性能代价都不小。
这个设计取舍其实和很多人的直觉相反:我们习惯把"支持回滚"当成数据库成熟度的标志,但Redis选择把错误处理的责任交给应用层。理解了它的设计哲学,就不会拿MySQL那套去硬套。
3.2 Redis的原子性从不是MySQL式的原子性
很多人说"Redis事务是原子的",这句话要小心。如果"原子性"指的是"不会被其他命令插入",那Redis事务确实保证。但如果"原子性"指的是"要么全成功、要么全失败",那事务执行阶段的类型错误会让它出现部分执行。
我在面试中倾向这样回答:
Redis事务通过
MULTI/EXEC把命令包装成一个队列,保证这个队列执行期间不会被其他客户端的命令插入,所以它是"串行原子";但它不支持传统意义上的回滚,运行期错误会导致部分命令成功部分失败,所以它不是"全有或全无原子"。
这个区分能让面试官知道你真正理解事务边界在哪里。
另外补充一点:Redis 6.0开始虽然引入了多线程I/O,但命令的执行仍然是单线程事件循环。这意味着事务中的命令在执行阶段天然不会被并发执行,这也是为什么Redis里没有MVCC、没有锁等待那一套东西。
4. 从实战场景看Redis事务的用武之地
4.1 秒杀与库存扣减:WATCH+重试的正确姿势
最简单的库存扣减如果不加控制,会超卖。用WATCH + MULTI + EXEC可以实现一个乐观锁方案。以Python的redis-py为例:
python复制import redis
r = redis.Redis(host='127.0.0.1', port=6379, decode_responses=True)
def decr_stock_with_watch(key):
while True:
try:
pipe = r.pipeline()
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:
# 有其他客户端修改过stock,循环重试
continue
这段代码里WATCH和EXEC必须用同一个客户端连接,所以要用pipeline()包起来。如果冲突频繁,WatchError会不断抛出,重试次数飙升,吞吐量反而会下来。因此这个方案更适合冲突率低、并发量可控的场景。
如果是真正的秒杀场景,我更推荐用Lua脚本(后面单独讲),因为Lua脚本把"读库存、判断、扣减"合并成一次服务端原子操作,既不用多次网络往返,也不用客户端重试。
4.2 缓存一致性场景:事务能做什么、不能做什么
现在很多系统用Redis做缓存,MySQL做存储,于是经常有人问:能不能用Redis事务保证缓存和数据库一致?
答案很明确:不能。Redis事务只能管Redis这边的事情,它管不了MySQL、管不了消息队列、管不了其他任何外部系统。所谓跨系统的强一致,需要分布式事务方案,比如本地消息表、事务消息、TCC、Saga这些,而不是Redis的MULTI/EXEC。
那么缓存场景里Redis事务还有什么用?有几个典型的"缓存侧原子操作"场景:
- 批量更新缓存中的多个小key,希望这批操作不被其他线程插入中间状态。
- 先判断缓存key是否存在,再决定是否写入,希望"判断+写入"是一个连续动作。
- 缓存计数类操作,比如点赞数、访问量,需要"读旧值、改新值"之间不被打断。
比如用WATCH + MULTI实现"只有缓存不存在时才能写入"的语义,防止并发请求同时回源DB把缓存击穿。这个场景里事务确实比单纯的SETNX更灵活——你可以在事务里先GET判断,再执行SET,并且保证这个判断和写入的过程不被别的客户端打断。
但要注意:缓存穿透、击穿、雪崩这些经典问题的根治,靠的是缓存空值、布隆过滤器、热点key永不过期、限流降级这类架构手段。Redis事务在其中只是辅助工具,不是主角。
4.3 事务与分布式锁的边界:别拿锁当事务
另一个容易混的概念是"分布式锁"和"事务"的关系。WATCH经常被误当成锁,但它的语义完全不同:
SETNX lock 1 EX 10这种分布式锁,作用是互斥:保证同一时刻只有一个客户端能拿到锁执行逻辑。WATCH的作用是提交时校验:不阻止别人改,只在提交时发现被改就放弃。
如果业务逻辑是"先抢锁,再操作库存",锁和事务处在不同层面。锁保证进入关键代码段的客户端只有一个,事务保证进入Redis的那几条命令包装执行。两者可以配合,但不能互相替代。
有一个实战坑:如果持有分布式锁的线程因为网络卡顿或GC停顿超过锁的过期时间,锁提前失效,另一个线程也拿到锁进来了,此时即使你用了Redis事务,也无法阻止两个线程同时执行扣减逻辑。因为Redis事务只保证命令的串行执行,不保证业务进程层面的串行。这种问题需要靠"锁续期"(比如Redisson看门狗)或"防重令牌"来兜底,不是靠事务能解决的。
5. 一次真实问题排查:Pipeline里套事务,偶发部分执行
5.1 问题表现与第一轮排查
之前负责过一个库存服务,客户端用redis-py的pipeline()批量操作多个库存key。架构师本意是用Pipeline降低网络往返,然后在Pipeline里套MULTI/EXEC保证原子性。上线后出现一个诡异现象:日志里偶尔出现"扣减失败",失败时其他几个key的扣减却成功了。
第一轮排查先看代码,简化类似这样:
python复制pipe = redis_client.pipeline()
pipe.multi()
for sku_id, qty in req.items():
pipe.decr(f"stock:{sku_id}", qty)
pipe.execute()
这里python客户端的pipeline()默认transaction=True,其实multi()和execute()内部已经会生成MULTI/EXEC命令。理论上命令是一个事务,为什么会出现部分成功?看Redis返回结果,发现execute()返回的数组里,某些元素是错误对象,其他元素是正常数字。说明问题出在运行期错误——某些key上的命令执行时报错了。
那么为什么会对同一个key执行错误操作?继续查业务代码,发现上层有并发线程对同一个stock:{sku_id}先做了LPUSH操作(用来记录扣减流水),导致这个key的类型变成了列表。
5.2 深挖WATCH与连接复用的坑
这个问题表面上是类型错乱导致运行期错误,但背后还藏着一个更隐蔽的坑:事务与连接生命周期绑定。
排查过程中我们发现还有一个低概率问题:WATCH之后,如果pipeline执行的时间过长,或者连接被客户端连接池回收再复用,会导致WATCH失效。具体现象是,某些重试请求明明在前一次WATCH监控的key已经被别人修改过,EXEC还是成功了。
原因很典型:redis-py的pipeline()在transaction=True模式下,WATCH、MULTI、EXEC必须发在同一个连接上。如果代码里手动在两步之间把连接归还给连接池,另一个请求可能复用这个连接,并把WATCH状态带到了新的事务里。更实操的说法是:不要自己在Pipeline外面单独调用WATCH,然后把连接交还给连接池,再去做MULTI/EXEC。你要么全程用同一个pipe/pipeline,要么用redis.Lock这种封装好的原语。
排查时用MONITOR命令在服务端观察命令时序,发现偶发出现的WATCH和EXEC之间,客户端连接断开又重连了,断开时事务队列被清空,WATCH也被废弃,但客户端本地逻辑并不知道,继续发送后续命令,造成语义错乱。
5.3 最终修复:从缓存类问题里抽身,转向Lua
修复分三步做:
第一步,修正类型错乱:把流水写入从LPUSH同名字段改成独立key,避免库存key和列表key混用。
第二步,重新规范事务用法:明确WATCH、MULTI、EXEC必须在同一个连接上,不允许跨连接池归还;所有事务操作统一封装在一个方法里,内部用pipeline()完成。
第三步,也是更根本的调整:凡是"读当前值—做判断—写新值"这一类逻辑,全部改成Lua脚本,不再用WATCH+MULTI+EXEC。因为Lua脚本在服务端原子执行,没有连接生命周期问题,也不存在运行期部分执行导致的脏中间态。从那以后,线上同类问题几乎绝迹。
这个案例的价值在于:你背再多的MULTI/EXEC原理,不如真正遇到一次"Pipeline和事务混用"的故障印象深。事务的正确性高度依赖客户端使用方式,连接池、Pipeline、重试机制都会影响它。
6. MySQL事务和Redis事务的本质差异:面试对照表
6.1 ACID四要素逐项对照
面试里"Redis事务和MySQL事务有什么区别"是保留题目。与其说一堆模糊的"Redis不支持回滚",不如把ACID四要素拆开逐项对照。
| 维度 | MySQL事务 | Redis事务 |
|---|---|---|
| 原子性 | 要么全部执行,要么全部回滚,通过Undo Log实现 | 命令在事务队列中串行执行;入队错误时整体不执行;运行期错误时部分执行,无回滚 |
| 一致性 | 通过约束、外键、回滚机制保证 | 没有内置约束;需要业务代码自己保证数据逻辑;WATCH只是辅助条件校验 |
| 隔离性 | 有隔离级别:读未提交、读已提交、可重复读、串行化 | 单线程事件循环 + 事务队列执行机制,天然达到"串行化"效果,不需要隔离级别概念 |
| 持久性 | 通过Redo Log、双写等机制保证,事务提交后数据基本不丢 | EXEC成功不代表数据已经落盘;取决于RDB快照策略和AOF同步策略,崩溃可能丢数据 |
这个表基本覆盖了面试官想要的答案。重点是最后一行:很多人以为Redis事务提交了就和MySQL事务一样持久,实际上如果AOF配置是everysec或者只用了RDB,服务器宕机时,刚提交的事务数据是有可能丢的。
6.2 为什么Redis没有事务隔离级别
MySQL之所以有一堆隔离级别,是因为多个事务可能在并发读写同一批数据,需要防止脏读、不可重复读、幻读。Redis根本不存在这个问题,因为:
- 命令执行是单线程的,同一时刻只有一个命令在跑。
MULTI/EXEC事务执行期间,其他客户端的命令不会插入到事务队列中间。- 所以事务里的命令看出去的世界,不会被其他并发事务影响。
简单说,Redis事务天然就是"串行化"隔离级别。你不需要SELECT ... FOR UPDATE,不需要MVCC快照,也不需要死锁检测。这也是Redis事务实现如此轻量的原因。
话说回来,单线程执行也意味着一个耗时很长的Lua脚本或一个包含成千上万命令的事务,会阻塞整个Redis服务。串行化带来的隔离性,是要用吞吐量来换的。所以不要把大量命令塞进一个事务里,尤其不要在事务里跑长循环。
7. 进阶取舍:Lua脚本、Pipeline与事务怎么选
7.1 Lua脚本为什么是更强的事务方案
官方语义上,Redis事务和Lua脚本都提供"原子执行"。但实操中我更倾向于用Lua,原因有三个:
第一,Lua脚本允许在原子执行单元内部完成"读、判、写"完整流程。事务虽然也能包多个命令,但事务里的命令之间缺乏流程控制能力——你不能在事务里说"如果库存不够就不扣减"。你只能用WATCH在提交时发现变化,然后客户端重试。Lua可以直接在服务端写条件分支。
第二,Lua脚本没有"运行期错误部分执行"的尴尬。脚本在服务端作为一个整体被Redis调用,虽然已经执行的写命令也不自动回滚,但脚本里可以做充分的前置判断,出错概率远低于靠命令队列堆叠的事务。
第三,Lua脚本少一轮网络往返。MULTI/EXEC模式至少需要客户端发送MULTI、命令们、EXEC三次交互,而Lua脚本只需要一次EVAL或EVALSHA。
7.2 一份可复用的扣减脚本与配套示例
一个经典的库存扣减脚本可以写成这样:
lua复制-- KEYS[1]: 库存key
-- 返回 -1: key不存在; 0: 库存不足; 1: 扣减成功
local stock = tonumber(redis.call('GET', KEYS[1]))
if not stock then
return -1
end
if stock <= 0 then
return 0
end
redis.call('DECRBY', KEYS[1], 1)
return 1
调用方式:
bash复制redis-cli EVAL "local stock = tonumber(redis.call('GET', KEYS[1])); if not stock then return -1 end; if stock <= 0 then return 0 end; redis.call('DECRBY', KEYS[1], 1); return 1" 1 stock:001
脚本执行期间,Redis不会执行其他客户端的任何命令,所以不存在超卖。实际生产环境如果是Java服务,可以用DefaultRedisScript注册脚本,然后用EVALSHA调用,减少脚本内容重复传输。
需要注意:Lua脚本执行期间Redis会被阻塞。如果脚本逻辑复杂、循环次数多,会造成整个Redis实例的命令排队,所以写脚本时要保持逻辑短小。这也是很多公司禁止在Redis里跑复杂聚合脚本的原因。
7.3 Pipeline与事务是两类优化
最后把Pipeline和事务的关系理清楚,这也是面试里容易踩的坑。
Pipeline解决的是网络往返问题。客户端把多个命令一次打包发送给服务端,服务端逐个执行后一次性返回结果。它减少了RTT,但不提供任何"原子性"或"隔离性"保证。如果Pipeline里没有一个事务包装,那么这些命令中间完全可能插进别的客户端的命令。
MULTI/EXEC解决的是服务端执行语义问题。它保证命令队列执行期间不被其他命令插入,但不减少网络往返——该发的命令一条不少,多命令时反而比Pipeline更耗RTT。
两者不冲突,可以组合使用:客户端用Pipeline把MULTI、多个命令、EXEC一次性发出去,既减少网络往返,又获得事务语义。这也是redis-py里pipeline(transaction=True)默认做的事。
不过组合起来有个需要注意的地方:如果Pipeline里塞的事务命令过多,服务端会在一次网络包处理中执行完整个事务,期间Redis被"占住"的时间变长。我个人的经验是,单个事务里的命令控制在几十条以内,超过这个量就要考虑是不是应该换Lua脚本或改批量处理策略。
回到事务本身,我的实操体会是:能不用WATCH+MULTI+EXEC就不用,优先Lua。这个态度不是否定Redis事务,而是认清它的最佳使用场景——低冲突下的条件提交。库存、防重、限流这类"读-判-写"逻辑,Lua的原子性和可读性都碾压事务;而事务更适合那些只需要"打包执行、不允许插入"但不需要复杂判断的场景。把两者的边界划清楚,线上才不会为"部分执行"和"WATCH重试风暴"买单。
