
Redis 事务MULTI/EXEC 为什么不支持回滚Lua 原子扣减又是怎么做到的作者鱼宵 实战驱动系列 · 第 3 篇完整课程与可运行源码已开源在 Giteehttps://gitee.com/j67mk2/redis-journey 10 课实战教程本课源码在 lesson-06/一、真实场景账户余额怎么扣成了负数想象一个最简单的扣款逻辑在 Java 里正常地写// 第一步读余额intbalanceInteger.parseInt(jedis.get(account:1001));// 第二步判断够不够if(balance60){return;}// 余额不足放弃// 第三步扣减jedis.decrBy(account:1001,60);看着没毛病跑起来你就知道了——两个线程同时要扣 60账户里只有 100最后余额变成了 -20。这不是段子。上面这个读 → 判断 → 扣的三步在并发下就是超扣/超卖事故的完整剧本。怎么治很多人第一反应是用 Redis 事务啊MULTI/EXEC——结果发现事务也救不了。真正靠谱的是Lua 脚本。这篇文章就把Redis 事务为什么不是真事务、Lua 为什么是原子利器一次讲透末尾有真实运行输出对照。二、MULTI/EXECRedis 的事务长什么样Redis 的事务就三步MULTI # 开启事务之后的命令先不执行进队列 命令1 命令2 命令3 EXEC # 一口气把队列里的命令全部执行类比去奶茶店点单。你对着店员一口气报完珍珠奶茶去冰、加份芋圆MULTI 后命令入队店员不是做一杯记一杯而是等你说就这些做吧EXEC才一次性全部做出来。一句话MULTI/EXEC 先排队后统一执行。三、核心考点为什么说它不是真事务数据库事务讲 ACIDRedis 这个事务和它差得远面试常考下面三点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 脚本在执行时其他所有命令都得排队等它跑完。所以你把查 → 判 → 改这一整套写进一段 LuaRedis 就是一口气、不被打断地执行完——天然原子不需要事务、不需要锁。两个命令先认识一下EVAL 脚本 key个数 key... arg...直接把脚本内容发过去执行EVALSHA脚本太长时先把脚本缓存进 Redis以后只发它的指纹SHA1省流量本课用 EVAL 就够看完整的扣减脚本这是全文最值钱的一段背下来-- KEYS[1] 账户 key如 account:1001-- ARGV[1] 要扣的金额如 60localbalanceredis.call(GET,KEYS[1])-- 1. 查余额ifbalancefalsethenreturn-1end-- 账户不存在balancetonumber(balance)-- Redis 返回的是字符串转数字ifbalancetonumber(ARGV[1])thenreturn0end-- 2. 判断够不够redis.call(DECRBY,KEYS[1],tonumber(ARGV[1]))-- 3. 扣减return1-- 成功Java 里调用就一行Objectresultjedis.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只成功扣一次另一次被拦下这就是原子性的价值该成功一次的操作绝不能因为并发被执行两次。七、挑战题答案都在仓库里跑起来才知道⭐ 把实验二里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命令连续执行不被插队回滚、隔离只要打包执行不要兜底WATCHEXEC 前 key 没被改过才执行并发性能乐观锁场景Lua整段脚本原子执行—查判改类操作的首选记住这条Redis 事务是排队一起做Lua 是一口气做完要原子选 Lua要回滚回数据库。十、关于这个系列本文是「Java 后端实战精通营」系列第 3 篇原则实战驱动、由浅到深、面试向每篇文章的结论都可以亲手验证。Redis 实战精通营10 课https://gitee.com/j67mk2/redis-journey本文对应源码位置lesson-06/实验一 MULTI/EXEC、实验二超扣反面教材、实验三 Lua 原子扣减挑战题 1 的答案就在实验二的代码里第 1 篇《Redis 分布式锁为什么必须用 SET NX EXLua 解锁又是干嘛的》、第 2 篇《缓存一致性为什么必须先更新数据库再删缓存》已发布——第 1 篇的 Lua 解锁和第 3 篇的 Lua 扣减是同一个原子性思路值得连起来看后续系列陆续发布Dubbo / JVM / JUC / RocketMQ / MySQL / Elasticsearch下一篇预告《Redis 高可用主从复制、哨兵和 Cluster 集群一台机器挂了怎么不停服务》——从单节点走向高可用亲手起一主两从看数据自动同步。跑完上面任何一步遇到问题把终端输出发评论区一起排查。