Redis事务原子性迷思,Lua脚本是明智之选?

摘要:作为一个长期和关系型数据库(RDBMS)打交道的开发者,初次查阅 Redis 文档时,看到 MULTI、EXEC、DISCARD 这些指令,心中难免涌起一股由于熟悉而带来的安全感。
我们的大脑会自动建立映射:MULTI 就是
写在前面的话 作为一个长期和关系型数据库(RDBMS)打交道的开发者,初次查阅 Redis 文档时,看到 MULTI、EXEC、DISCARD 这些指令,心中难免涌起一股由于熟悉而带来的安全感。 我们的大脑会自动建立映射:MULTI 就是 BEGIN,EXEC 就是 COMMIT,DISCARD 就是 ROLLBACK。这套组合拳打下来,所有的业务逻辑似乎都应该具备了“不成功便成仁”的原子性保障。 但这恰恰是 Redis 给我上的第一课:相似的命名背后,往往藏着截然不同的灵魂。 当你把 MySQL 的事务观生搬硬套到 Redis 身上时,错付就已经开始了。 这篇文章将带你剥开 Redis 事务的外衣,从“原子性”的定义偏差说起,聊聊为什么在现代开发中,我们越来越倾向于用 Lua 脚本来替代它。 一、先把误会解开:Redis 事务不是 ACID 在关系型数据库的世界里,“事务”二字重若千钧,它几乎等同于 ACID(原子性、一致性、隔离性、持久性)。我们习惯了“要么全有,要么全无”的安全感。 而在 Redis 的世界里,MULTI 和 EXEC 更像是一个批处理信号: 把一堆命令先放进队列里排队,等到 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 还是讲道理的,它会直接拒绝整个事务。 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. 执行时的“运行时错误”(虽死犹进) 这才是真正的坑。假设语法没问题,但在执行期间,某条命令因为数据类型不匹配报错了: 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 事务。 为什么它比事务强? 逻辑原子性:一段 Lua 脚本被视作一条命令。Redis 保证脚本执行期间,不会有任何其他脚本或命令插入。 效率更高:不需要像 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 定义,而是懂得在由于物理限制而满是遗憾的真实世界里,做出那个最不坏的选择。 文章的最后,想和你多聊两句。 技术之路,常常是热闹与孤独并存。那些深夜的调试、灵光一闪的方案、还有踩坑爬起后的顿悟,如果能有人一起聊聊,该多好。 为此,我建了一个小花园——我的微信公众号「[努力的小郑]」。 这里没有高深莫测的理论堆砌,只有我对后端开发、系统设计和工程实践的持续思考与沉淀。它更像我的数字笔记本,记录着那些值得被记住的解决方案和思维火花。 如果你觉得今天的文章还有一点启发,或者单纯想找一个同行者偶尔聊聊技术、谈谈思考,那么,欢迎你来坐坐。 愿你前行路上,总有代码可写,有梦可追,也有灯火可亲。