我不会起名字322· 后端 / 算法 / 数据库
📘 技术栈 | 🧩 力扣 Hot100 | 🐹 Go 项目 | 🟥 Redis | 🐬 MySQL
文章目录
- Redis 事务为什么没回滚:MULTI 的 3 个反直觉行为
- 一、先搞清根本区别:Redis 事务**不支持回滚**
- 二、行为 1:语法错误 → 整个事务不执行
- 三、行为 2:运行时错误 → 只跳过那一条
- 四、行为 3:WATCH 乐观锁的失效时机
- 五、三种原子性方案对照表(可带走物)
- 六、什么时候不该用 MULTI
- 七、总结
Redis 事务为什么没回滚:MULTI 的 3 个反直觉行为
“我用了 MULTI,里面一条命令失败了,前面几条怎么还生效了?”
这是一位做订单系统的同事问的。他的思路很正常——数据库事务里一条失败就整体回滚,Redis 的 MULTI 应该也一样。但真不一样。
这篇讲清 Redis 事务和数据库事务的本质区别,拆 3 个反直觉行为,并给出"到底该用什么"的选型表。
一、先搞清根本区别:Redis 事务不支持回滚
这是最核心的一点,也是所有困惑的源头:
| 特性 | 数据库事务 | Redis 事务 |
|---|---|---|
| 原子性(Atomicity) | 全部成功或全部失败 | 部分成功(不保证) |
| 隔离性(Isolation) | 支持不同隔离级别 | 命令间不会被其他客户端插入(EXEC 期间) |
| 一致性(Consistency) | 支持 | 弱 |
| 持久性(Durability) | 取决于配置 | 取决于 AOF 配置 |
| 回滚 | 支持 | ❌ 不支持 |
为什么 Redis 不做回滚?官方理由:回滚需要额外的日志和状态管理,会显著增加复杂度,而 Redis 认为语法错误可以在入队时就发现(见下文),运行时错误是"程序 bug"而非"应该回滚的业务场景"。
二、行为 1:语法错误 → 整个事务不执行
如果某条命令在入队时就被发现有语法错误(比如命令名拼错、参数个数不对),Redis 会:
- 该命令入队失败;
EXEC时整个事务被拒绝执行(返回错误);- 一条都不会执行。
127.0.0.1:6379>MULTI OK127.0.0.1:6379(TX)>SET k1 v1 QUEUED127.0.0.1:6379(TX)>SET k2# ❌ 参数个数不对(缺 value)(error)ERR wrong number of argumentsfor'set'command127.0.0.1:6379(TX)>INCR k1 QUEUED127.0.0.1:6379(TX)>EXEC(error)EXECABORT Transaction discarded because of previous errors.注意:EXECABORT—— 整个事务被丢弃,k1也没被设置。
这是唯一"看起来像回滚"的情况,但本质是"入队阶段就发现错误,所以根本没执行",不是执行后回滚。
判据:入队时返回QUEUED才是成功;返回(error)就是入队失败。
三、行为 2:运行时错误 → 只跳过那一条
真正的"反直觉"在这里。如果命令入队成功了,但执行时才出错(比如对 string 做LPUSH、INCR一个非数字值),那么:
- 只有出错的那条失败;
- 其他命令全部生效;
- 没有回滚。
127.0.0.1:6379>SET str"hello"OK127.0.0.1:6379>MULTI OK127.0.0.1:6379(TX)>SET k1 v1 QUEUED127.0.0.1:6379(TX)>INCR str# str 是 "hello",不是数字QUEUED# 入队成功!(运行时才会错)127.0.0.1:6379(TX)>SET k2 v2 QUEUED127.0.0.1:6379(TX)>EXEC1)OK# k1 设置成功2)(error)ERR value is not an integer or out of range# INCR 失败3)OK# k2 设置成功结果:k1和k2都生效了,只有INCR str失败。这就是"没回滚"的现场。
关键判据:入队时不检查类型,所以类型错误只能在执行时暴露。这类错误无法在事务开始前发现。
四、行为 3:WATCH 乐观锁的失效时机
WATCH是 Redis 提供的"乐观锁",但它的语义常被误解:
# 客户端 AWATCH balance GET balance# 读到 100MULTI DECRBY balance30# 想扣 30# 此时客户端 B 修改了 balanceEXEC# 返回 nil(事务被放弃),因为 balance 被改过规则:WATCH监视的 key,如果在EXEC之前被任何客户端修改,则EXEC返回nil,整个事务不执行。
反直觉点:
| 误解 | 实际 |
|---|---|
| WATCH 能锁住 key 不让别人改 | ❌ 不锁,只是"如果被改过就放弃" |
| WATCH 后的读一定是"当前值" | ❌ 读和 EXEC 之间仍可能被改 |
| 事务被放弃会自动重试 | ❌ 需要应用层自己重试(循环) |
WATCH 的正确用法(应用层重试):
functransfer(ctx context.Context,rdb*redis.Client,from,tostring,amountint64)error{forattempt:=0;attempt<3;attempt++{// 必须自己重试!err:=rdb.Watch(ctx,func(tx*redis.Tx)error{bal,err:=tx.Get(ctx,from).Int64()iferr!=nil{returnerr}ifbal<amount{returnfmt.Errorf("余额不足")}_,err=tx.TxPipelined(ctx,func(pipe redis.Pipeliner)error{pipe.DecrBy(ctx,from,amount)pipe.IncrBy(ctx,to,amount)returnnil})returnerr},from)iferr==redis.TxFailedErr{// 被其他客户端改动,重试continue}returnerr}returnfmt.Errorf("重试 3 次仍失败")}关键:redis.TxFailedErr是"WATCH 失效"的信号,必须重试,否则就是静默失败。
五、三种原子性方案对照表(可带走物)
| 方案 | 原子性 | 能否读后写 | 复杂度 | 适用场景 |
|---|---|---|---|---|
单命令(INCR/SETNX) | ✅ 强 | ❌ 只能单步 | 最低 | 计数器、简单锁 |
| MULTI/EXEC | ⚠️ 部分(无回滚) | ❌ 不能依赖前一条结果 | 低 | 批量独立写 |
| WATCH + MULTI | ⚠️ 乐观锁(需重试) | ✅ 可以 | 中 | 需要读后写的简单场景 |
| Lua 脚本 | ✅ 强(单线程执行) | ✅ 可以 | 中 | 推荐的复杂原子操作 |
Lua 是最终答案——它在服务端单线程执行,整段脚本要么跑完要么不跑,且能读后写:
-- deduct_stock.lua:库存扣减,原子localstock=tonumber(redis.call('GET',KEYS[1])or'0')localqty=tonumber(ARGV[1])ifstock<qtythenreturn-1-- 库存不足endredis.call('DECRBY',KEYS[1],qty)returnstock-qty-- 返回扣减后的库存vardeductStock=redis.NewScript(` local stock = tonumber(redis.call('GET', KEYS[1]) or '0') local qty = tonumber(ARGV[1]) if stock < qty then return -1 end redis.call('DECRBY', KEYS[1], qty) return stock - qty `)remaining,err:=deductStock.Run(ctx,rdb,[]string{"stock:1001"},2).Int64()ifremaining==-1{// 库存不足,业务上拒绝}为什么 Lua 优于 WATCH:WATCH 需要应用层重试(高并发下重试率高),Lua 在服务端一次执行,没有重试开销。
六、什么时候不该用 MULTI
| 场景 | 别用 MULTI 的原因 |
|---|---|
| 命令之间有依赖 | MULTI 里不能读前一条的结果 |
| 需要失败回滚 | Redis 事务不支持回滚 |
| 高并发扣减/转账 | 用 WATCH 会大量重试,用 Lua 更好 |
| 只是想批量提速 | 用pipeline(不开 MULTI,见另一篇) |
七、总结
- Redis 事务不支持回滚—— 这是和数据库事务的根本区别;
- 3 个反直觉行为:语法错误整体不执行(EXECABORT)、运行时错误只跳过那一条(其他照常生效)、WATCH 失效返回 nil 且必须应用层重试;
- 入队不检查类型,所以类型错误只能在执行时暴露,无法预防;
- 需要原子 + 读后写:首选 Lua 脚本;需要批量提速:用 pipeline(不要 MULTI);
- 用 WATCH 就必须写重试循环,否则等于静默失败。
留个问题给你:你们扣库存是用WATCH还是 Lua 脚本?有没有踩过"以为 MULTI 会回滚"的坑?