Redis 事务:MULTI/EXEC 为什么不支持回滚?Lua 原子扣减又是怎么做到的?
作者:鱼宵 | 实战驱动系列 · 第 3 篇
完整课程与可运行源码已开源在 Gitee:gitee.com/j67mk2/redi… (10 课实战教程,本课源码在 lesson-06/)
一、真实场景:账户余额怎么扣成了负数?
想象一个最简单的扣款逻辑,在 Java 里"正常"地写:
// 第一步:读余额
int balance = Integer.parseInt(jedis.get("account:1001"));
// 第二步:判断够不够
if (balance < 60) { return; } // 余额不足,放弃
// 第三步:扣减
jedis.decrBy("account:1001", 60);
看着没毛病?跑起来你就知道了——两个线程同时要扣 60,账户里只有 100,最后余额变成了 -20。
这不是段子。上面这个"读 → 判断 → 扣"的三步,在并发下就是超扣/超卖事故的完整剧本。怎么治?很多人第一反应是"用 Redis 事务啊(MULTI/EXEC)"——结果发现事务也救不了。真正靠谱的是 Lua 脚本。
这篇文章就把"Redis 事务为什么不是真事务、Lua 为什么是原子利器"一次讲透,末尾有真实运行输出对照。
二、MULTI/EXEC:Redis 的"事务",长什么样?
Redis 的事务就三步:
MULTI # 开启事务,之后的命令先不执行,进队列
命令1
命令2
命令3
EXEC # 一口气把队列里的命令全部执行
类比:去奶茶店点单。你对着店员一口气报完"珍珠奶茶去冰、加份芋圆"(MULTI 后命令入队),店员不是做一杯记一杯,而是等你说"就这些,做吧"(EXEC),才一次性全部做出来。
一句话:MULTI/EXEC = 先排队,后统一执行。
三、核心考点:为什么说它不是"真事务"?
数据库事务讲 ACID,Redis 这个"事务"和它差得远,面试常考下面三点:
1. 不支持回滚(和数据库最大的区别)
数据库里一条命令失败,前面做的全部撤销;Redis 不会。如果队列里某条命令在运行时出错(比如对一个字符串 key 执行 INCR,类型不对),它就那样失败着——前面已经执行的命令照样留下结果,绝不回滚。
2. 只有"语法错误"才会整体流产
命令名拼错了,Redis 在入队阶段就发现了,整个事务不执行——这是唯一能"全都不执行"的情况。但"命令写对了、跑起来才发现类型不对"这种运行期错误,不回滚。
3. 没有隔离性
事务里的命令在 EXEC 之前不执行;EXEC 执行的那一小段时间里,如果没有 WATCH,别的客户端的命令可能插在中间执行。讲人话:你点单的时候,隔壁客人的单可能插在你那几杯之间做。
| 对比 | 数据库事务 | Redis MULTI/EXEC |
|---|---|---|
| 出错回滚 | ✅ 支持 | ❌ 不回滚 |
| 隔离性 | ✅ 有 | ❌ 无(除非 WATCH) |
| 保证什么 | ACID | 只保证"队列命令一口气执行,不被插队" |
想要"出错就撤销"?回数据库去。Redis 事务只保证连续执行,不保证出错兜底。
四、WATCH:给事务上"验货再付款"
WATCH key 给某个 key 加个"盯着":在我 EXEC 之前,如果这个 key 被别人改过,我的整个事务就放弃执行(EXEC 返回 nil)。
类比"验货再付款":你看中一台二手手机,跟卖家说"我先去取钱,你别卖给别人"。等你取钱回来要付款时,发现手机已经被卖掉了(key 被改了)——你取消交易(EXEC 失败),而不是硬付钱。
这就是乐观锁:先不锁,等动手前再检查有没有被人动过,和"版本号"一个思路。
五、真正的原子利器:Lua 脚本
为什么说"查余额 → 判断够不够 → 扣减"这种多命令原子操作要用 Lua?
关键在 Redis 的单线程模型:Redis 处理命令本来就是单线程的,当一段 Lua 脚本在执行时,其他所有命令都得排队等它跑完。所以你把"查 → 判 → 改"这一整套写进一段 Lua,Redis 就是一口气、不被打断地执行完——天然原子,不需要事务、不需要锁。
两个命令先认识一下:
EVAL 脚本 key个数 key... arg...:直接把脚本内容发过去执行EVALSHA:脚本太长时,先把脚本缓存进 Redis,以后只发它的"指纹(SHA1)",省流量(本课用 EVAL 就够)
看完整的扣减脚本(这是全文最值钱的一段,背下来):
-- KEYS[1] = 账户 key(如 account:1001)
-- ARGV[1] = 要扣的金额(如 60)
local balance = redis.call('GET', KEYS[1]) -- 1. 查余额
if balance == false then return -1 end -- 账户不存在
balance = tonumber(balance) -- Redis 返回的是字符串,转数字
if balance < tonumber(ARGV[1]) then return 0 end -- 2. 判断够不够
redis.call('DECRBY', KEYS[1], tonumber(ARGV[1])) -- 3. 扣减
return 1 -- 成功
Java 里调用就一行:
Object result = jedis.eval(DEDUCT_LUA, 1, "account:1001", "60");
整段脚本在 Redis 里一气呵成、不被插队。第一个线程扣成 40 后,第二个线程的脚本再读余额就是 40,判断 40 < 60 成立,直接返回"余额不足",一分钱都不多扣。
小贴士:Lua 里数字记得
tonumber()转一下——Redis 返回的是字符串,直接和数字比较会出 bug。
六、动手验证:亲眼看看 -20 和 40
完整工程在仓库
lesson-06/,mvn exec:java一键跑三个实验。下面是本机真实运行输出:
========== 实验二:非原子扣余额(GET 判断 + DECRBY 分两步)==========
[15:51:55.245] 线程2:读到当前余额 = 100
[15:51:55.245] 线程1:读到当前余额 = 100
[15:51:55.305] 线程2:扣减 60 完成
[15:51:55.305] 线程1:扣减 60 完成
[15:51:55.306] 非原子方案跑完,最终余额 = -20(超扣了!)
========== 实验三:Lua 原子扣余额(整段脚本一气呵成)==========
[15:51:55.315] 线程2:扣减失败,余额不足
[15:51:55.315] 线程1:扣减 60 成功
[15:51:55.316] Lua 方案跑完,最终余额 = 40(只成功扣一次,另一次被拦下)
注意实验二的细节:两个线程都读到 100——因为"读"和"扣"之间有 50ms 时间窗,两个线程同时读完、都觉得够扣、各扣 60。这就是超扣。
| 两线程读到的余额 | 最终余额 | 结论 | |
|---|---|---|---|
| 非原子(读→判→扣 分三步) | 都读到 100 | -20 | 超扣了,钱被扣成负数 |
| Lua(查→判→改 打包原子) | 第一个 100、第二个看到 40 | 40 | 只成功扣一次,另一次被拦下 |
这就是原子性的价值:该成功一次的操作,绝不能因为并发被执行两次。
七、挑战题(答案都在仓库里,跑起来才知道)
- ⭐ 把实验二里
Thread.sleep(50)那行删掉再跑几次:最终余额是稳定 -20、稳定 40、还是看运气?为什么去掉延迟结果就不确定了?(提示:并发交错本来就不确定,sleep 只是把窗口放大到必现) - ⭐⭐ 写代码用
WATCH account:1001,然后开另一条连接在MULTI/EXEC之间偷偷改了这个 key——观察exec()返回了什么?(提示:返回 null = 事务被放弃) - ⭐⭐⭐ 为什么 lesson-05 的分布式锁"释放"用 Lua,而"加锁"不用?加锁是不是也能用 Lua 包一下?(提示:加锁
SET NX EX本身就是单条原子命令,Lua 只用来打包"多条命令")
八、面试回答模板(背下来)
面试官:Redis 的事务和数据库事务有什么区别?
三个区别:① 不支持回滚,运行期出错就失败着,前面不撤销;② 无隔离性,EXEC 期间别的命令可能插队(除非 WATCH);③ 只保证"队列命令一口气连续执行,不被插队"。语法错误会整体流产,运行期错误不会。
那多命令原子操作怎么做?
用 Lua 脚本。Redis 单线程,脚本执行期间其他命令排队,所以"查余额→判断→扣减"写进一段 Lua 就天然原子,不需要锁也不需要事务。典型场景:原子扣减余额、分布式锁解锁、库存扣减。
九、总结
| 工具 | 保证什么 | 不保证什么 | 什么时候用 |
|---|---|---|---|
| MULTI/EXEC | 命令连续执行不被插队 | 回滚、隔离 | 只要"打包执行"不要兜底 |
| WATCH | EXEC 前 key 没被改过才执行 | 并发性能 | 乐观锁场景 |
| Lua | 整段脚本原子执行 | — | 查+判+改类操作的首选 |
记住这条:Redis 事务是"排队一起做",Lua 是"一口气做完";要原子,选 Lua;要回滚,回数据库。
十、关于这个系列
本文是「Java 后端实战精通营」系列第 3 篇,原则:实战驱动、由浅到深、面试向,每篇文章的结论都可以亲手验证。
👉 Redis 实战精通营(10 课):gitee.com/j67mk2/redi…
- 本文对应源码位置:
lesson-06/(实验一 MULTI/EXEC、实验二超扣反面教材、实验三 Lua 原子扣减,挑战题 1 的答案就在实验二的代码里) - 第 1 篇《Redis 分布式锁:为什么必须用 SET NX EX?Lua 解锁又是干嘛的?》、第 2 篇《缓存一致性:为什么必须"先更新数据库,再删缓存"?》已发布——第 1 篇的 Lua 解锁和第 3 篇的 Lua 扣减是同一个原子性思路,值得连起来看
- 后续系列(陆续发布):Dubbo / JVM / JUC / RocketMQ / MySQL / Elasticsearch
下一篇预告:《Redis 高可用:主从复制、哨兵和 Cluster 集群,一台机器挂了怎么不停服务?》——从单节点走向高可用,亲手起一主两从看数据自动同步。
跑完上面任何一步遇到问题,把终端输出发评论区,一起排查。