我之前在帮团队做技术面试复盘时注意到一个现象:一说起 Redis 事务,几乎没有候选人不认识MULTI和EXEC,但问到“Redis 明明不支持回滚,为什么面试题里还要叫它事务”“事务和 Lua 脚本到底什么时候该选谁”,大部分人就卡住了。这篇攻略面经就是要把 Redis 事务这件事从命令层面一直聊到 ACID 边界,把面试官真正想听的底层机制、易错点、选型逻辑都摊开讲清楚。
1. 面试官问“Redis 事务”时,内心在等什么答案
1.1 Redis 事务的官方定位:把多个命令打包成“准原子”执行
Redis 事务并不是我们理解的那种数据库事务。它做的事情很朴素:先把一组命令放进队列,然后一次性、按顺序地执行。核心命令就四个:
MULTI:标记一个事务块的开始,后续收到的命令不会立刻执行,而是进入队列。EXEC:触发事务,按入队顺序依次执行队列里的命令。DISCARD:清空事务队列,退出事务状态。WATCH:监视一个或多个 key,在EXEC之前如果这些 key 被其他客户端改动,则整个事务被中断。
这个机制解决的核心问题其实是:避免多个命令在并发场景下被其他客户端的操作“插队”。我举一个项目里最常见的场景——转账。A 账户要给 B 账户转 100 块,你至少需要两步:DECR A 100,INCR B 100。如果中间来个并发请求,A 的余额又被人扣了一次,账就算不清了。Redis 事务把这两条命令按顺序打包执行,中间不会插入其他命令,这就保证了这组命令逻辑上的整体性。
1.2 面试官真正在意的三个层次
我把面试中的回答质量分成三层,你可以对照一下自己停在哪儿。
第一层:能背出MULTI/EXEC/DISCARD/WATCH的用法,知道“命令入队后统一执行”。这层能过,但只能算是及格线。
第二层:能讲清楚 Redis 事务的“失败模型”——入队时的语法错误和运行时的执行错误是两种完全不同的表现,事务中的命令执行失败不会自动回滚已执行的命令。这层已经能区分出大部分人了。
第三层:能解释“Redis 事务到底在 ACID 上缺了什么、多了什么”,并且能结合项目实际情况说明“为什么我不用事务而是用 Lua 脚本”或“为什么我在这个场景下必须用 WATCH 做乐观锁”。这层是资深工程师的理解方式。
如果你正准备面试,建议目标至少定在第二层,第三层才是拉开差距的地方。下面逐层拆。
2. 命令队列机制与“不回滚”这句话的真正含义
2.1 入队阶段发生的错误:连执行资格都没有
要理解 Redis 事务的失败模型,必须从命令进入队列的瞬间开始。
当你输入MULTI之后,Redis 会给当前客户端标记一个REDIS_MULTI状态。之后收到的命令都会走一条独立路径:检查命令是否存在、参数个数是否正确、命令是否允许在事务中使用。如果检查全部通过,命令对象会被包装进一个multiCmd结构,追加到当前客户端的mstate命令队列里,然后返回QUEUED。
这里有个关键点:入队时的语法错误不会立刻中断整个事务,但它会把事务标记成一个“脏”状态。比如:
127.0.0.1:6379> MULTI OK 127.0.0.1:6379> SET name "redis" QUEUED 127.0.0.1:6379> GETS name (error) ERR unknown command 'GETS' 127.0.0.1:6379> SET age 18 QUEUED 127.0.0.1:6379> EXEC (error) EXECABORT Transaction discarded because of previous errors.EXEC被拒绝,整个事务被丢弃,SET name和SET age都不会执行。这是从 Redis 2.6.5 之后的行为,这么设计是为了避免“入队时已经知道会错,还要稀里糊涂执行其他命令”的尴尬情况。
我在实际排障时看到过不少误用:有人以为入队时报错的命令不会执行,其他命令可以继续,结果被EXECABORT整段丢弃。如果你在事务里有一个命令拼错了参数,整组业务操作都会失效,这个点在生产中很容易被忽视,尤其是在用客户端封装工具时,错误信息层层包装之后,很容易被当成“超时”或“网络异常”处理。
2.2 运行时错误:已执行的命令不会回滚
入队阶段最大的特点是一切命令都“只检查不运行”,但到了EXEC阶段,命令会真正开始执行。如果某个命令在运行时出错了呢?
举个例子:
127.0.0.1:6379> MULTI OK 127.0.0.1:6379> SET key1 "hello" QUEUED 127.0.0.1:6379> LPUSH key1 "world" QUEUED 127.0.0.1:6379> INCR key2 QUEUED 127.0.0.1:6379> EXEC 1) OK 2) (error) WRONGTYPE Operation against a key holding the wrong kind of value 3) (error) ERR value is not an integer or out of range注意看,SET key1 "hello"执行成功了,LPUSH key1 "world"报错(key1 是字符串类型),INCR key2也报错。但整个EXEC返回了三个结果,第一个是OK,后面两个是 error。已经成功的第一个命令不会回滚,后面的命令也不会因为前面出错而停止。
这就是 Redis 官方文档里“Redis 事务不支持回滚”的真正含义:执行阶段的错误是运行时错误,Redis 既不会中断事务,也不会撤销已经完成的操作。
面试官问到这里,往往会追加一句:“那为什么 Redis 不设计回滚?”我比较认可的解答思路是:回滚能力会显著增加 Redis 实现的复杂度,而且运行时错误绝大多数情况下是由 bug 导致的,回滚并不能修复 bug,反而会让数据处于一个更不可预测的状态。更重要的是,Redis 的强项是性能和简单的内存操作模型,为了极低频的编程错误引入回滚机制,性价比太低。
2.3 生产环境里如何处理“伪回滚”
既然 Redis 不会帮我们回滚,那需要业务一致性的时候怎么办?我的经验是分成三个层面去处理。
第一,入队前做尽量多的“预检查”。把可能引发运行时错误的操作提前用只读命令验证掉,例如在执行INCR前先确认 key 的类型,用TYPE key判断,确保不会对字符串执行列表操作。
第二,事务执行前先做数据校验,执行后检查返回结果。在应用代码里,EXEC返回的结果数组里包含了每条命令的执行状态。只要发现某条结果是 error,就说明事务里出现了部分成功,这时候需要在业务层做补偿。
第三,如果补偿逻辑太复杂,那就不建议用原生事务,直接上 Lua 脚本。Lua 脚本可以通过redis.pcall捕获错误并中断后续命令,配合返回值来主动控制执行流程,比纯事务更加可控。这个后面会详细展开。
3. WATCH 乐观锁:Redis 实现 CAS 的关键
3.1 WATCH 的底层机制:版本变化检测
WATCH是我认为 Redis 事务里最值得深挖的命令,也是面试官最喜欢往下追问的点。它的作用是:在MULTI之前监视一个或多个 key,如果这些 key 在事务执行前被其他客户端修改,EXEC直接返回空结果,事务不执行。
从源码角度看,WATCH做的事情是把当前客户端注册到被监视 key 的监听列表里。Redis 内部维护了一个watched_keys字典,key 是“数据库号 + 键名”,value 是一个链表,存放所有监听了这个 key 的客户端。当某个 key 被修改(比如SET、LPUSH、DEL)时,touchWatchedKey函数会把所有监听该 key 的客户端标记为CLIENT_DIRTY_CAS。等到EXEC执行时,客户端只要带着CLIENT_DIRTY_CAS标志,事务就会被直接放弃。
这个过程很像乐观锁里的版本号机制,只是 Redis 替我们把“版本号”维护好了。
3.2 一个扣库存的完整范例
库存扣减是 WATCH 最经典的应用场景。假设商品库存存储在stock:1001这个 key 上,用户要买 1 件。用 WATCH 实现的伪代码逻辑是这样的:
WATCH stock:1001 GET stock:1001 # 读出当前库存,假设是 5 MULTI DECR stock:1001 EXEC如果整个流程执行期间没有其他人修改过stock:1001,EXEC返回 1,扣减成功。如果另一个请求在这期间把库存改成了 4,当前客户端的CLIENT_DIRTY_CAS被置位,EXEC返回空,说明扣减失败,需要重试。
应用代码里推荐的模式是“循环重试”:
while True: with redis.pipeline() as pipe: try: pipe.watch("stock:1001") current = int(pipe.get("stock:1001")) if current < 1: return "库存不足" pipe.multi() pipe.decr("stock:1001") pipe.execute() return "扣减成功" except redis.WatchError: continue注意这里WATCH必须放在MULTI之前,而且WATCH状态在EXEC或DISCARD之后会被清除。失败重试时需要重新WATCH,不能只重新执行EXEC。
3.3 WATCH 的三个坑,我踩过的教训
第一个坑:在同一连接里WATCH之后自己又改了那个 key。很多人以为“我自己改自己的 key,总不会影响乐观锁吧?”但事实上 Redis 并不区分修改方是谁,只要被监视的 key 发生写操作就会触发脏标记。我自己就遇到过排障半天的情况:用同一个连接先WATCH了一个 key,随后在事务外SET了它,回头EXEC永远返回空,最后才发现是“自己脏了自己”。
第二个坑:WATCH能监视的 key 数量是有限的,但实际场景里不用太担心。Redis 对单个客户端监视 key 的数量上限由watched_keys数据结构本身决定,如果你WATCH了太多 key,内存占用会上升,而且每次写操作都要去遍历监听链表,写性能会被拖慢。我的建议是:只监视真正参与事务逻辑的 key,不要图省事把所有相关 key 全部WATCH。
第三个坑:WATCH和连接池搭配时要小心。如果在连接池里取出了一个连接,执行了WATCH,然后忘了在业务结束时调用UNWATCH或DISCARD就归还连接,下一个拿到这条连接的人就会带着上一轮的监视状态,轻则性能损耗,重则整个事务执行结果不符合预期。生产环境里用完WATCH之后,要么EXEC要么UNWATCH,这是必须养成的习惯。
4. 用 ACID 对照:Redis 事务与传统数据库事务的边界
4.1 原子性:原子执行不等于可回滚
很多面试资料里说“Redis 事务具备原子性”,这个说法很容易误导人。原子性在数据库理论里强调的是“要么全成功,要么全失败”,但 Redis 事务在执行阶段的错误不会回滚已执行的命令。所以更准确的说法是:Redis 事务保证的是“命令不会被并发插入、完整地按顺序执行”,但不保证“执行失败时整体回滚”。
用一个表格来区分:
| 维度 | 传统数据库事务 | Redis 事务 |
|---|---|---|
| 执行方式 | SQL 逐条解析并执行,支持回滚 | 命令入队后一次性执行,不回滚 |
| 失败中断 | 任一语句失败,可回滚所有变更 | 运行时错误只影响出错命令,其他继续 |
| 原子性 | 事务整体提交或回滚 | 只保证“有序整体执行”,不保证失败回滚 |
| 隔离性 | 通过锁/MVCC 实现多种隔离级别 | 单线程天然串行执行 |
| 持久性 | 通过 WAL/redo log 等保证 | 依赖 AOF/RDB,与事务无关 |
理解了这一点,你就能接住面试官的经典追问:“Redis 事务能保证原子性吗?”正确答案不是“能”或“不能”,而是先区分“原子执行”和“可回滚”这两个概念,再根据语境给出结论。
4.2 一致性:数据结构一致,业务一致性靠应用层
一致性在数据库里有很严格的数学定义,但在工程实践里通常可以理解为“数据始终满足某种约束”。Redis 事务能保证的“一致性”非常有限:它不会让某种结构性的数据损坏,比如字符串 key 不会莫名变成列表类型。但如果你在事务里把 A 账户扣了 100,B 账户因为某种原因(比如 key 不存在、类型错误)没有加上 100,Redis 不会管这个业务约束是否被破坏。
所以在实际项目中,我用 Redis 事务时一定会明确一点:Redis 事务只负责命令序列的原子性,业务上的一致性必须由调用方自己保证。比如在转账场景里,执行完EXEC之后,必须检查B 账户 INCR的返回结果,如果它报错了,A 账户的扣减结果需要单独做补偿。
有些同学会问:那为什么不把“判断 INCR 是否成功”也放进事务里?很遗憾,Redis 事务命令队列里的命令在EXEC之前不会计算结果,所以无法实现“前一条命令的结果决定后一条命令是否执行”这种动态逻辑。这恰恰是 Lua 脚本的用武之地。
4.3 隔离性:单线程模型下的天然串行
隔离性是 Redis 事务最省心的一环。Redis 采用单线程模型(忽略 6.0 之后多线程 IO 对命令执行的影响),所有命令在服务端都是一个接一个执行的,不存在并发执行命令的问题。这意味着你不需要像传统数据库那样去配置隔离级别,也不需要担心脏读、不可重复读。
但注意,Redis 的“天然串行”是针对命令执行而言,并不是说事务之间不存在竞争。举个例子:两个客户端同时以WATCH + MULTI + EXEC的方式扣减同一个库存 key,它们是可能发生竞争冲突的,只不过这种冲突通过 WATCH 在事务执行前就被发现了,而不是在事务执行中才出现锁等待。
所以面试时谈到隔离性,最好的说法是:Redis 事务的隔离性是“执行期隔离”,不是“事务期隔离”。事务在执行的那一刻独占命令执行权,但事务从开始到提交之间,其他客户端可以正常读写其他 key,不会像数据库那样锁住表或行。
4.4 持久性:事务和持久化是两个独立的话题
Redis 的事务本身不提供持久性保证,这一点和数据库事务很不一样。Redis 是否丢数据,完全取决于你的持久化配置。
- 如果只开了 RDB,那么从上次快照到故障发生之间的所有数据都可能丢失,事务也不例外。
- 如果开了 AOF,
appendfsync配置为always时,每个写命令都会同步刷盘,持久性最好;everysec时最多丢 1 秒数据;no时由操作系统决定刷盘时机。 - 混合持久化(RDB + AOF)是 Redis 4.0 之后的推荐配置,但依然不能做到数据库那样的事级崩溃恢复保证。
有一次线上环境出现过 Redis 实例异常重启,因为 AOF 配置是everysec,导致最近 1 秒内的几笔事务数据丢失。这个案例告诉我们:如果业务对数据安全要求高,不要依赖 Redis 事务来保证不丢数据,要么用appendfsync always(代价是吞吐下降),要么在应用层做数据补偿。
5. 事务与 Lua 脚本:选型时要想清楚的事
5.1 Lua 脚本能解决事务解决不了的动态逻辑
聊完 ACID,进入选型环节。很多人在项目里纠结“到底用事务还是 Lua 脚本”,我的结论很直接:如果只是把几个互不依赖的命令打包执行,用MULTI/EXEC就够了;如果命令之间存在逻辑判断,或者后一个命令要根据前一个命令的结果来决定是否执行,必须用 Lua。
举个例子,还是库存扣减。普通的事务写法是这样的,它把所有命令一股脑丢给 Redis:
MULTI DECR stock:1001 INCR sold:1001 EXEC问题在于,如果stock:1001已经是 0,DECR之后它变成了 -1,相当于卖超了。你需要先读库存、判断、再扣减,这种“读 + 条件判断 + 写”的模式,原生事务做不到,但 Lua 脚本可以:
local stock = tonumber(redis.call('GET', KEYS[1])) if not stock or stock < tonumber(ARGV[1]) then return -1 end redis.call('DECRBY', KEYS[1], ARGV[1]) redis.call('INCRBY', KEYS[2], ARGV[1]) return 0调用方式:
EVAL "脚本内容" 2 stock:1001 sold:1001 1脚本执行期间,Redis 不会执行任何其他命令,因此不需要 WATCH,也不会出现并发扣减超卖的问题。而且脚本返回值可以清楚地告诉调用方是“库存不足”还是“扣减成功”,这是原生事务无法提供的能力。
5.2 一份选型对比与我的取舍建议
| 维度 | Redis 事务 | Lua 脚本 |
|---|---|---|
| 原子性 | 命令串行执行,运行期不回滚 | 脚本整体执行,不会被插入命令,但也不回滚 |
| 动态条件 | 不支持 | 支持,可读可写可判断 |
| 复杂度 | 低,四个命令即可 | 需要维护脚本字符串,较复杂 |
| 错误处理 | EXEC 返回结果数组,需逐个检查 | pcall 可捕获错误并中断流程 |
| 适用场景 | 简单批量写、转账、计数器 | 复杂业务逻辑、条件更新、库存扣减 |
| 性能 | 一条条命令依次执行 | 脚本缓存后开销较低 |
我个人的实践标准是这样:如果一组命令超过三条,而且中间有“读一个值,根据这个值决定下一步”的逻辑,直接写 Lua,别再用事务硬撑。反过来,如果只是绑定执行两个INCR,事务足够简单干净。
5.3 面试里关于 Lua 脚本的套路追问
面试官在聊完事务之后,大概率会顺带问 Lua 脚本,常见套路有这么几个:
“Lua 脚本和 Redis 事务有什么区别?”最核心的区别是 Lua 脚本能在执行过程中读取中间结果并做条件判断,而事务的命令队列是一个没有反馈的机械执行过程。此外 Lua 脚本天然具备原子性,不需要 WATCH。
“Lua 脚本执行期间会不会阻塞 Redis?”会。如果一个脚本里有死循环,整个 Redis 实例都会卡住。线上要特别注意脚本的运行时长,官方建议脚本执行时间要短,最好控制在毫秒级。如果脚本运行超过lua-time-limit(默认 5000ms),Redis 会向其他客户端发送“脚本繁忙”的提示,但脚本不会主动终止。所以发布前一定要压测脚本耗时。
“能不能在 Lua 脚本里执行MULTI?”不能。Lua 脚本本身已经处于一个类似“原子事务”的执行环境下,再嵌套事务会破坏语义。
“Lua 脚本和事务能一起用吗?”可以,但要清楚脚本内部不需要MULTI,脚本外部的MULTI也没法包裹EVAL来获得额外收益。现实中我用 Lua 脚本时会直接在脚本内部完成所有读写,外部不加事务。
我把 Redis 事务相关问题的回答要点列成了一个速记清单,面试前看一眼很有帮助:
- 事务是什么:MULTI 入队、EXEC 执行、DISCARD 清空、WATCH 乐观锁。
- 事务的边界:入队错误会导致 EXECABORT;运行时错误不回滚,已执行命令保留。
- ACID 怎么答:原子性仅限“有序执行”,一致性靠应用层,隔离性靠单线程,持久性靠 AOF/RDB。
- 和 Lua 对比:动态逻辑用 Lua,简单打包用事务,复杂补偿方案优先 Lua。
- 项目落地:WATCH 使用完毕要 UNWATCH,连接池场景尤其注意,避免脏状态被复用。
最后说一个我在线上真实遇到过的教训。有一次业务方反馈“库存偶尔会多扣”,排查下来发现代码里确实用了WATCH + MULTI + EXEC,看起来没有问题。但查看日志后发现,这个客户端在并发量升高时频繁抛出WatchError,重试逻辑写成了“重新 GET 库存,但没重新 WATCH”。结果旧事务失败后,紧接着的新事务在没有任何监视的情况下直接执行了DECR,库存就被多扣了。所以 WATCH 相关代码的重试逻辑不是简单循环,而是整段“WATCH → GET → MULTI → 组装命令 → EXEC”都要重新完整执行,漏掉任何一步都会破坏乐观锁的语义。这种细节,面试官未必会写在题面上,但如果你能主动讲出来,印象分会明显不一样。