news 2026/3/21 20:49:44

Redis 事务的“原子性”迷思:为什么我们最终选择了 Lua 脚本

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Redis 事务的“原子性”迷思:为什么我们最终选择了 Lua 脚本

写在前面的话

作为一个长期和关系型数据库(RDBMS)打交道的开发者,初次查阅 Redis 文档时,看到MULTIEXECDISCARD这些指令,心中难免涌起一股由于熟悉而带来的安全感。

我们的大脑会自动建立映射:MULTI就是BEGINEXEC就是COMMITDISCARD就是ROLLBACK。这套组合拳打下来,所有的业务逻辑似乎都应该具备了“不成功便成仁”的原子性保障。

但这恰恰是 Redis 给我上的第一课:相似的命名背后,往往藏着截然不同的灵魂。当你把 MySQL 的事务观生搬硬套到 Redis 身上时,错付就已经开始了。

这篇文章将带你剥开Redis 事务的外衣,从“原子性”的定义偏差说起,聊聊为什么在现代开发中,我们越来越倾向于用 Lua 脚本来替代它。


一、先把误会解开:Redis 事务不是 ACID

在关系型数据库的世界里,“事务”二字重若千钧,它几乎等同于ACID(原子性、一致性、隔离性、持久性)。我们习惯了“要么全有,要么全无”的安全感。

而在 Redis 的世界里,MULTIEXEC更像是一个批处理信号

把一堆命令先放进队列里排队,等到EXEC时,一次性、按顺序地执行它们。

这里有一个巨大的认知偏差。当我们谈论 Redis 的“原子性”时,Redis 指的其实是隔离性(Isolation),而不是回滚(Rollback)

  • 它保证的是我执行这段命令的时候,别人不能插队(独占执行)。
  • 它不保证的是如果我执行到一半报错了,我会帮你把前面的操作撤销(失败回滚)。

为了更直观地理解,我们可以对比一下 Redis 事务和标准 ACID 事务的区别:

特性关系型数据库 (MySQL)Redis 事务差异解读
原子性 (Atomicity)All or Nothing
失败即回滚,如同未发生过
All or Partial
没得商量,错了就错了,剩下的接着干
Redis 不支持 Rollback,部分成功是常态
一致性 (Consistency)强一致性
约束必须满足
弱一致性
依赖业务代码保障
Redis 不会校验业务约束(如外键、非空等)
隔离性 (Isolation)有多种隔离级别 (RC/RR/Serializable)串行化执行
执行期间不可被打断
得益于单线程模型,EXEC期间天然隔离
持久性 (Durability)WAL 日志保障
掉电不丢失
取决于 AOF/RDB 配置默认配置下通常有数据丢失风险

一句话总结:
Redis 事务是“命令队列 + 独占执行”,绝不是“失败回滚 + 强一致”。


二、残酷的真相:它真的不包回滚

为了把这个概念刻进 DNA,我们看两种真实的错误场景。

1. 入队时的“低级错误”(全员连坐)

如果你在命令入队阶段就犯了语法错误(比如参数写少了),Redis 还是讲道理的,它会直接拒绝整个事务。

/* by yours.tools - online tools website : yours.tools/zh/jsonudview.html */ 127.0.0.1:6379> MULTI OK 127.0.0.1:6379> SET key1 value1 QUEUED 127.0.0.1:6379> SET key2 # <--- 语法错误:少了参数 (error) ERR wrong number of arguments for 'set' command 127.0.0.1:6379> EXEC (error) EXECABORT Transaction discarded because of previous errors.

这时候,所有命令都不会执行。这符合我们对“事务”的预期。

2. 执行时的“运行时错误”(虽死犹进)

这才是真正的坑。假设语法没问题,但在执行期间,某条命令因为数据类型不匹配报错了:

/* by yours.tools - online tools website : yours.tools/zh/jsonudview.html */ 127.0.0.1:6379> MULTI OK 127.0.0.1:6379> SET user:A:points 100 QUEUED 127.0.0.1:6379> LPUSH user:A:points "error_data" # <--- 对 String 类型做 List 操作,注定运行报错 QUEUED 127.0.0.1:6379> INCR user:A:points # <--- 后续命令 QUEUED 127.0.0.1:6379> EXEC 1) OK 2) (error) WRONGTYPE Operation against a key holding the wrong kind of value <--- 报错! 3) (integer) 101 <--- 依然成功了!

目瞪口呆了吗?
第二条命令报错了,但第三条命令依然欢快地执行了。数据出现了中间态:即所谓的“不一致”。

Redis 官方对此的解释非常“直男”:

“只有语法错误才会被拦截,运行时错误属于程序员的逻辑 Bug(比如把 String 当 List 用)。数据库不应该为了程序员的 Bug 买单,去搞复杂的回滚机制。


三、进阶之路:从原生批量到 Lua 脚本

💡 预备知识:RTT 是性能杀手

一个 Redis 命令的执行可以简化为 4 步:发送命令 → 命令排队 → 命令执行 → 返回结果

其中,第 1 步和第 4 步的时间之和称为RTT (往返时间)。如果我有 100 个命令,一个个发就需要 100 次 RTT,大部分时间都浪费在网络传输上。
批量操作的核心意义,就是把 100 次 RTT 压缩成 1 次。

既然MULTI/EXEC这么“头铁”,那我们在实际开发中到底该怎么选?我们可以把 Redis 的批量操作能力分为几个段位。

Lv1. 原生批量命令 (MSET / MGET)

这是最简单、最快的方式。

  • 特点:原生的原子性。MSET key1 val1 key2 val2是一个原子操作,要么都成功,要么都失败(在 Redis 层面)。
  • 示例
    MSET key1 "Hello" key2 "World"
  • 局限:只能处理同一种命令,逻辑死板。

Lv2. 管道 (Pipeline)

当你需要批量执行几十个不同的命令,且不需要它们之间有逻辑依赖时,Pipeline 是首选。

  • 特点唯快不破。它把几十个命令打包,一次网络请求(RTT)发给服务器,服务器执行完再一次性返回。
  • 形象理解下 100 个单 -> 一次性收 100 个快递 (1 次 RTT)
  • 与事务的区别
    • 非原子性:Pipeline 只是打包发送,Redis 可能会在处理 Pipeline 中间穿插执行其他客户端的命令(交错执行)。
    • 效率更高:不需要像事务那样每个命令都发一次,只需要发送一次。

Lv3. 事务 (MULTI / EXEC)

比 Pipeline 多了一层保障:独占执行

  • 特点原子操作(隔离性)
    • 两个不同的事务不会同时运行。在EXEC执行期间,Redis 会“以此为尊”,保证没有其他客户端能插队。
  • 缺点
    • RTT 开销大:事务中每个命令都需要单独发送到服务端入队,请求次数并没有减少。
    • 不支持回滚,不支持在事务中间做逻辑判断。

Lv3.1 事务 + WATCH (乐观锁)

单纯的MULTI/EXEC往往比较鸡肋,因为它无法感知中间状态。但这套机制唯一的“王牌”组合是配合WATCH命令,实现乐观锁 (CAS)。

  • 场景:秒杀扣减库存。

    • MULTI之前WATCH stock
    • 如果在EXEC执行前stock被别人改了,整个事务原地取消(返回 nil)。
  • 代码示例

    WATCH stock:001 # 1. 监视库存 GET stock:001 # 2. 读库存,发现是 10 MULTI # 3. 开启事务 (开始排队) DECR stock:001 # 4. 减库存 EXEC # 5. 执行 # 如果在步骤 1-5 之间,别人改了 stock:001,这里会返回 (nil),事务回滚。
  • 致命弱点高并发下性能极差

    • 就像一群人抢一个麦克风,一个人抢到了,其他人的CAS全部失败,只能客户端重试(自旋)。
    • 竞争越激烈,重试越频繁,CPU 空转越严重。

Lv4. 最终兵器 —— Lua 脚本

从 Redis 2.6 开始,Lua 脚本成为了解决复杂原子性问题的核心方案,它完美替代了WATCH事务。

为什么它比事务强?

  1. 逻辑原子性:一段 Lua 脚本被视作一条命令。Redis 保证脚本执行期间,不会有任何其他脚本或命令插入
  2. 效率更高:不需要像WATCH那样反复重试。脚本在服务器端执行,只有一次 RTT。

示例:安全的“先查后改”

-- 判断 key 是否等于预期值,如果是则删除 if redis.call("GET", KEYS[1]) == ARGV[1] then return redis.call("DEL", KEYS[1]) else return 0 end

⚠️ 必须警惕的缺陷:Lua 也不回滚!
虽然 Lua 脚本被称为“原子操作”,但请注意:它的原子性依然指的是不被打扰,而不是失败回滚

如果 Lua 脚本运行到中途出错(比如调用了不存在的命令,或显式报错退出),脚本会停止执行,但之前已经执行过的写操作,是不会被撤销的!

这意味着,即使是 Lua,也不能给你带来 RDBMS 那种“回滚一切”的安全感。你依然需要在代码层面保证逻辑的严密性。


四、总结:选型决策表

为了让你在实际业务中不再纠结,我整理了一份简单的决策表:

需求场景推荐方案核心理由
简单批量读写 (KV)MSET / MGET原生命令,最快,最省心。
大量离散命令 (无关联)Pipeline网络开销最低,吞吐量最高。
需要 CAS (低并发)WATCH + MULTI事务唯一的用武之地。适合低频竞争,实现简单。
复杂逻辑 / 高并发Lua 脚本行业标准。避免了 CAS 自旋的性能开销,原子性强。
即使报错也要回滚MySQL / RDBMS别为难 Redis。它没有 Undo Log,做不到真正的回滚。

写在最后

回头看,Redis 事务这套机制,就像是一个“如果不仔细读说明书一定会用错”的半成品。

但正是这个“半成品”,折射出了 Redis 最底层的价值观:为了性能,可以牺牲一切“看起来很美”的抽象。它拒绝了沉重的 Undo Log,拒绝了复杂的隔离级别,只留下了一个最简单的“排队执行”逻辑。

所以,当我们下次再写下MULTI的时候,心里要清楚:

  • 如果只是为了快,Pipeline才是那个不讲武德的“加速器”。
  • 如果只是为了防插队,Transaction够用了,但在高并发下,它脆弱得像个易碎品。
  • 如果要处理真正的复杂逻辑,请毫不犹豫地拥抱Lua—— 虽然它也不会回滚,但至少在“执行原子性”上,它是我们手里最稳的那张牌。

真正的技术成熟,不是背诵八股文里的 ACID 定义,而是懂得在由于物理限制而满是遗憾的真实世界里,做出那个最不坏的选择。


文章的最后,想和你多聊两句。

技术之路,常常是热闹与孤独并存。那些深夜的调试、灵光一闪的方案、还有踩坑爬起后的顿悟,如果能有人一起聊聊,该多好。

为此,我建了一个小花园——我的微信公众号「[努力的小郑]」。

这里没有高深莫测的理论堆砌,只有我对后端开发、系统设计和工程实践的持续思考与沉淀。它更像我的数字笔记本,记录着那些值得被记住的解决方案和思维火花。

如果你觉得今天的文章还有一点启发,或者单纯想找一个同行者偶尔聊聊技术、谈谈思考,那么,欢迎你来坐坐。

愿你前行路上,总有代码可写,有梦可追,也有灯火可亲。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/3/14 18:24:25

Qt图像处理利器:QPixmap类完全解析与实战指南

一、QPixmap类概述 QPixmap是Qt框架中用于处理图像的核心类之一,专门为在屏幕上显示图像而优化。与QImage不同,QPixmap针对显示性能进行了特殊优化,更适合在GUI线程中直接渲染。 1. 主要特点 显示优化:底层使用平台相关的图形系统 线程安全:可在GUI线程外创建,但只能在…

作者头像 李华
网站建设 2026/3/20 6:02:24

MFC CEditView类完全指南:从基础到高级应用

一、CEditView类概述 1. CEditView简介 CEditView是MFC(Microsoft Foundation Classes)中的一个视图类,它封装了Windows编辑控件(Edit Control)的功能,提供了基本的文本编辑能力。作为CView的派生类,CEditView集成了文档/视图架构,可以轻松实现文本文件的打开、编辑和…

作者头像 李华