Redis 事务:MULTI/EXEC 为什么不支持回滚?Lua 原子扣减又是怎么做到的?

0 阅读8分钟

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、第二个看到 4040只成功扣一次,另一次被拦下

这就是原子性的价值:该成功一次的操作,绝不能因为并发被执行两次。


七、挑战题(答案都在仓库里,跑起来才知道)

  1. ⭐ 把实验二里 Thread.sleep(50) 那行删掉再跑几次:最终余额是稳定 -20、稳定 40、还是看运气?为什么去掉延迟结果就不确定了?(提示:并发交错本来就不确定,sleep 只是把窗口放大到必现)
  2. ⭐⭐ 写代码用 WATCH account:1001,然后开另一条连接在 MULTI/EXEC 之间偷偷改了这个 key——观察 exec() 返回了什么?(提示:返回 null = 事务被放弃)
  3. ⭐⭐⭐ 为什么 lesson-05 的分布式锁"释放"用 Lua,而"加锁"不用?加锁是不是也能用 Lua 包一下?(提示:加锁 SET NX EX 本身就是单条原子命令,Lua 只用来打包"多条命令")

八、面试回答模板(背下来)

面试官:Redis 的事务和数据库事务有什么区别?

三个区别:① 不支持回滚,运行期出错就失败着,前面不撤销;② 无隔离性,EXEC 期间别的命令可能插队(除非 WATCH);③ 只保证"队列命令一口气连续执行,不被插队"。语法错误会整体流产,运行期错误不会。

那多命令原子操作怎么做?

用 Lua 脚本。Redis 单线程,脚本执行期间其他命令排队,所以"查余额→判断→扣减"写进一段 Lua 就天然原子,不需要锁也不需要事务。典型场景:原子扣减余额、分布式锁解锁、库存扣减。


九、总结

工具保证什么不保证什么什么时候用
MULTI/EXEC命令连续执行不被插队回滚、隔离只要"打包执行"不要兜底
WATCHEXEC 前 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 集群,一台机器挂了怎么不停服务?》——从单节点走向高可用,亲手起一主两从看数据自动同步。

跑完上面任何一步遇到问题,把终端输出发评论区,一起排查。