写在前面的话
作为一个长期和关系型数据库(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 还是讲道理的,它会直接拒绝整个事务。
bash
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. 执行时的"运行时错误"(虽死犹进)
这才是真正的坑。假设语法没问题,但在执行期间,某条命令因为数据类型不匹配报错了:
bash
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 层面)。 -
示例 :
bashMSET 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)。
- 在
-
代码示例:
bashWATCH 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。
示例:安全的"先查后改"
lua
-- 判断 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 定义,而是懂得在由于物理限制而满是遗憾的真实世界里,做出那个最不坏的选择。
文章的最后,想和你多聊两句。
技术之路,常常是热闹与孤独并存。那些深夜的调试、灵光一闪的方案、还有踩坑爬起后的顿悟,如果能有人一起聊聊,该多好。
为此,我建了一个小花园------我的微信公众号「[努力的小郑]」。
这里没有高深莫测的理论堆砌,只有我对后端开发、系统设计和工程实践的持续思考与沉淀。它更像我的数字笔记本,记录着那些值得被记住的解决方案和思维火花。
如果你觉得今天的文章还有一点启发,或者单纯想找一个同行者偶尔聊聊技术、谈谈思考,那么,欢迎你来坐坐。
愿你前行路上,总有代码可写,有梦可追,也有灯火可亲。