上一篇【第55篇】Redis事务——MULTI/EXEC/DISCARD/WATCH详解下一篇【第57篇】Lua脚本——Redis里跑JavaScript的表亲如果你跟一个数据库工程师说Redis有事务他可能会用一种奇怪的眼神看着你——因为在他眼里事务就应该是ACID的而Redis的事务…呃怎么说呢有点另类。上篇文章我们学会了MULTI/EXEC/DISCARD/WATCH的用法今天就来严格地逐项分析Redis事务的ACID特性看看它到底满足了几条。先说结论Redis事务不是传统意义上的ACID事务但它有自己的设计哲学。ACID的基本定义先快速回顾一下ACID四个字母各自代表什么。这玩意儿是数据库领域的四项基本原则每一个正经的关系型数据库都必须遵守属性全称含义一句话总结AAtomicity原子性要么全部成功要么全部回滚CConsistency一致性事务前后数据库满足一致性约束IIsolation隔离性并发事务之间互不干扰DDurability持久性事务一旦提交数据就不会丢失打个比方ACID就像银行转账的规矩——A原子性保证扣款和加款要么同时发生要么都不发生C一致性保证总金额不变I隔离性保证你转账的时候别人不能同时动你的账户D持久性保证转账成功后即使银行系统崩溃也不会丢数据。现在让我们一个一个来检验Redis事务。A原子性Atomicity分析传统定义在MySQL中原子性意味着BEGIN;UPDATEaccountSETbalancebalance-100WHEREid1;UPDATEaccountSETbalancebalance100WHEREid2;-- 如果第二条SQL报错第一条也会被回滚COMMIT;要么两条都执行要么都不执行绝对不会出现扣了钱但没加上的尴尬局面。Redis的情况Redis事务的原子性是弱原子性需要分情况讨论Redis 事务原子性分析 ┌───────────────────────────────────────────────────────┐ │ 情况1: 语法错误入队时发现 │ │ │ │ MULTI │ │ SET a 1 → QUEUED │ │ INCR a b c → ERROR (语法错误) │ │ SET b 2 → QUEUED │ │ EXEC → EXECABORT (全部不执行) │ │ │ │ 结果: ✓ 原子性满足全部回滚 │ └───────────────────────────────────────────────────────┘ ┌───────────────────────────────────────────────────────┐ │ 情况2: 运行时错误EXEC时发现 │ │ │ │ SET a hello (先设a为字符串) │ │ MULTI │ │ SET a world → QUEUED │ │ INCR a → QUEUED (格式正确入队成功) │ │ SET c 3 → QUEUED │ │ EXEC │ │ → OK, ERROR, OK (INCR失败但不影响其他命令) │ │ │ │ 结果: ✗ 原子性不满足部分执行部分跳过 │ └───────────────────────────────────────────────────────┘关键区别在于语法错误在入队阶段就能发现Redis会直接拒绝整个事务EXECABORT但运行时错误只有在EXEC时才会暴露此时Redis的选择是——跳过出错的命令继续执行其余命令不会回滚。这就像一个老师收作业如果交上来的作业格式不对语法错误直接全部打回重写如果格式对了但答案算错了运行时错误老师就给这道题打个叉其他题照样批改。各种场景下的原子性场景原子性说明语法错误满足EXECABORT全部不执行运行时错误不满足出错命令跳过其余正常执行EXEC之前客户端断连满足所有命令都不执行Redis服务崩溃满足部分执行的结果不会写入RDB/AOF⚠️ 注意Redis事务运行时错误不回滚是最常被吐槽的设计。如果你对某个key做了INCR但类型不对其他命令照样会执行不会因为INCR失败就回滚整个事务。这在MySQL用户看来简直是离经叛道。结论Redis事务的原子性是有条件的只有在语法错误时才会全部回滚运行时错误不会。所以严格来说Redis事务不满足完整意义上的原子性我们称之为弱原子性。C一致性Consistency分析一致性的含义一致性是指事务执行前后数据库从一个合法状态转换到另一个合法状态。不会出现钱凭空消失或库存变成负数这种不一致的情况。注意一致性跟原子性不同。原子性说的是要么全做要么全不做一致性说的是不管做了多少结果必须是合法的。Redis的情况Redis的一致性分析要从三个层面来看1. 入队错误一致性保持MULTI SET key1 value1 QUEUED INCR key1 key2 key3# 语法错误(error)ERR wrong number of arguments SET key2 value2 QUEUED EXEC(error)EXECABORT Transaction discarded because of previous errors.# → 所有命令都不执行数据库保持事务前的状态EXECABORT直接丢弃所有命令数据库纹丝不动一致性完美。2. 运行时错误一致性保持但有副作用MULTI SET ahelloQUEUED SET b100QUEUED INCR a# 运行时错误hello 不能加1QUEUED SET cworldQUEUED EXEC1)OK2)OK3)(error)ERR value is not an integer or out of range4)OK# → ahello, b100, cworld# → b和c正常设置a保持了SET的结果# → 没有出现半完成的不一致状态虽然INCR失败了但其他命令的执行结果是完整的——不会出现b被设成了500但事务没提交这种中间状态。每个成功的命令都是独立完整地执行了。3. 服务器故障一致性保持如果在EXEC执行过程中Redis崩溃如果开启了AOF且appendfsync为always已执行的命令都已持久化重启后恢复如果开启了RDB正在生成的快照可能包含部分结果但Redis的RDB是forkcopy-on-write不会损坏已有数据如果未开启持久化重启后数据为空但这是持久性的问题不是一致性问题结论Redis事务的一致性是可以保证的。不管事务是成功、部分失败、还是被取消数据库始终处于一个一致的状态。这得益于Redis单线程执行模型——命令是按顺序执行的不会出现并发导致的脏读、幻读等一致性问题。I隔离性Isolation分析传统数据库的隔离级别MySQL有四个隔离级别每个级别解决不同的问题隔离级别脏读不可重复读幻读READ UNCOMMITTED可能可能可能READ COMMITTED不可能可能可能REPEATABLE READ不可能不可能可能SERIALIZABLE不可能不可能不可能MySQL默认REPEATABLE READ级别通过MVCC和Next-Key Lock实现了很高程度的隔离。但要实现完全的SERIALIZABLE性能代价很大。Redis的情况Redis使用单线程模型执行命令。这意味着在EXEC执行期间Redis不会处理任何其他客户端的命令Redis 单线程执行模型 Client-A: MULTI → SET a → SET b → EXEC │ Client-B: ──────────── GET a ──────────────→│ 等待... │ Client-C: ──────────── GET b ──────────────→│ 等待... EXEC 执行过程中: ├── SET a value1 (执行中其他客户端全部等待) ├── SET b value2 (执行中其他客户端全部等待) └── EXEC 返回 [OK, OK] Client-B: GET a → 返回 value1 (事务已提交) Client-C: GET b → 返回 value2 (事务已提交)EXEC期间Redis就像一个独占舞台的演员——其他所有客户端都在台下等着谁也别想插队。这相当于MySQL中最高的隔离级别——SERIALIZABLE串行化。但要注意一个细节在MULTI和EXEC之间其他客户端的命令是可以正常执行的MULTI到EXEC之间的非隔离行为 Client-A: MULTI → SET a 1 → (等待) → EXEC │ │ Client-B: ───────┼── SET a 999 ───────────┼──→ 成功 │ │ │ a 在 MULTI 之后被 │ │ 其他客户端修改了 │ ▼ ▼ EXEC执行: SET a 1 → 覆盖了B的修改 → OK但这种行为是符合预期的——MULTI只是把命令入队并没有加锁。如果你需要防止这种插队行为就要使用WATCH。WATCH带来的例外WATCH使用了乐观锁机制在EXEC之前是可以被其他客户端修改的WATCH 的非隔离行为 时刻T1: Client-A WATCH counter → GET counter → 10 时刻T2: Client-B SET counter 20 ← 修改了counter 时刻T3: Client-A MULTI → INCR counter → EXEC → nil (被取消) → Client-A读到counter10但在EXEC时发现counter已被修改 → 事务被取消Client-A需要重试但这不是隔离性问题——这是乐观锁的正常行为。Client-A的事务实际上没有被执行返回nil所以不存在读到了未提交的数据的情况。结论Redis事务的隔离性是完美的等效于SERIALIZABLE隔离级别。这完全归功于Redis的单线程模型——不需要锁、不需要MVCC、不需要任何并发控制机制天然隔离。这也是Redis事务最值得称道的地方。D持久性Durability分析持久性的含义持久性是指事务一旦COMMIT数据就被永久保存即使服务器崩溃也不会丢失。MySQL通过redo log来保证这一点——只要COMMIT成功数据就一定在磁盘上。Redis的情况Redis的持久性取决于持久化配置这是最看人下菜碟的一环三种持久化配置下的持久性 ┌─────────────────────────────────────────────┐ │ 1. 完全关闭持久化 │ │ (appendonly no, save ) │ │ │ │ EXEC 执行成功 → 数据只在内存中 │ │ 服务器崩溃 → 数据全部丢失 │ │ │ │ 持久性: ✗ 完全不保证 │ │ 适用: 纯缓存场景 │ └─────────────────────────────────────────────┘ ┌─────────────────────────────────────────────┐ │ 2. RDB 持久化 │ │ (save 900 1 等) │ │ │ │ EXEC 执行成功 → 数据在内存中 │ │ 服务器崩溃 → 恢复到最近一次RDB快照 │ │ 可能丢失数分钟的数据 │ │ │ │ 持久性: △ 部分保证取决于save配置 │ │ 适用: 可以容忍少量数据丢失 │ └─────────────────────────────────────────────┘ ┌─────────────────────────────────────────────┐ │ 3. AOF 持久化 (appendfsync always) │ │ │ │ EXEC 执行成功 → 数据立即写入AOF文件 │ │ 服务器崩溃 → AOF重放恢复不丢数据 │ │ │ │ 持久性: ✓ 完全保证 │ │ 代价: 性能大幅下降每次写都fsync │ └─────────────────────────────────────────────┘AOF三种同步策略对比appendfsync持久性性能说明always最强最差每次写操作都fsync最多丢失0条everysec较强较好每秒fsync一次最多丢失1秒数据no弱最好由操作系统决定何时fsync⚠️ 注意生产环境中appendfsync always基本没人用——它会让Redis的写性能降到和传统数据库一个量级失去了使用Redis的意义。大多数场景选择everysec就足够了最多丢失1秒数据。结论Redis事务的持久性取决于配置。只有在appendonly yesappendfsync always的配置下才能保证完整的持久性。但这个配置会严重影响性能通常不推荐。Redis vs MySQL事务完整对比综合以上分析我们来一个世纪对决ACID属性MySQL InnoDBRedis 事务评价原子性完全满足undo log回滚弱原子性运行时错误不回滚Redis不满足完整原子性一致性完全满足约束检查满足单线程保证Redis满足隔离性4个级别可选等效SERIALIZABLERedis满足且是最强级别持久性完全满足redo log取决于配置Redis条件满足其他维度MySQL InnoDBRedis回滚支持完整支持不支持条件逻辑SQL IF/CASE不支持需Lua性能开销大锁日志小无锁无日志实现复杂度高低适用场景强一致性业务高性能缓存/计数最终评分ACID四个字母中Redis完全满足CI条件满足D弱满足A。打分的话大约是2.5/4听起来不及格但别忘了Redis本来就不是为ACID事务设计的。Redis为什么不支持回滚这是很多MySQL用户最不理解的设计决策。Redis的作者AntirezSalvatore Sanfilippo在官方文档中给出了明确的解释“Redis commands can fail only because of syntax errors (commands are never tested for the number of arguments, the type, and so on during the command queueing), or because of programming errors. This means that in practice, a failing command is the result of a programming error that you should fix ASAP.”—— Redis官方文档翻译成人话就是Redis 的设计哲学 MySQL 的态度: Redis 的态度: 我帮你兜底出错了自动回滚 你自己写对代码别出错了 你尽管操作我保证要么全做 你把代码写对了我不会出错 要么全不做 你写错了那是你的问题 → 优点: 安全不会因为一个bug → 优点: 简单、高性能 导致数据不一致 不需要维护undo log → 缺点: 需要undo log性能开销 → 缺点: 运行时错误不回滚 事务越大回滚越慢 出了错只能人工修复Antirez认为Redis的命令错误只有两种语法错误编程时就该发现和类型错误也是编程时就该避免的回滚需要undo log这会让Redis变得复杂且低效生产环境不应该有运行时错误——如果你的代码对字符串做INCR那应该在开发阶段就发现并修复这个逻辑对不对从Redis的定位高性能缓存/数据结构服务器来看确实有道理。但如果你把Redis当数据库用那这个设计就有点任性了。如果你需要回滚能力可以使用Lua脚本—— Lua脚本在执行出错时会自动回滚脚本中的所有Redis命令都不会生效。WATCH乐观锁 vs MySQL悲观锁对比维度WATCHRedisSELECT FOR UPDATEMySQL锁类型乐观锁悲观锁实现方式标记 EXEC时检查行锁 / 表锁阻塞行为不阻塞其他客户端阻塞其他事务的读写死锁风险无有需要超时/重试性能高无锁等待低锁等待重试机制需要客户端自己实现自动等待或超时适用场景读多写少冲突少写多冲突多# Redis WATCH 重试模式乐观锁whileTrue:WATCH key valGET key# 业务逻辑...MULTI SET key newval resultEXECifresultisnotNone:break# 成功# 失败则重试其他客户端修改了key-- MySQL 悲观锁BEGIN;SELECTbalanceFROMaccountWHEREid1FORUPDATE;-- 此时其他事务如果也SELECT FOR UPDATE同一行会被阻塞UPDATEaccountSETbalancebalance-100WHEREid1;COMMIT;-- 释放锁⚠️ 注意WATCH乐观锁在高并发写场景下可能会导致大量重试称为乐观锁冲突风暴。如果冲突率超过30%建议改用Lua脚本或考虑使用分布式锁。两种锁各有千秋乐观锁适合和平年代冲突少悲观锁适合战争时期冲突多。选择哪种取决于你的业务场景。事务 vs Lua脚本如果你需要更强的原子性保证Lua脚本可能比事务更适合对比维度MULTI/EXECLua脚本原子性弱运行时错误不回滚强出错自动回滚条件判断不支持支持if/else循环不支持支持for/while返回值所有命令的结果数组可以自定义返回值网络往返多次MULTI 命令 EXEC一次EVAL可读性高命令直观中需要懂Lua语法性能好更好一次网络往返调试难度低高# MULTI/EXEC: 不能做条件判断MULTI GET counter# 这里没法判断counter的值然后决定做什么...SET counter100EXEC# Lua: 可以做条件判断EVAL local counter redis.call(GET, counter) if tonumber(counter) 0 then redis.call(DECR, counter) return true else return false end 0简单来说MULTI/EXEC是批量执行器Lua脚本是可编程执行器。如果你的逻辑很简单只是批量执行命令用事务就够了如果你需要条件判断、循环、或者强原子性那就该请Lua出场了。本章小结Redis 事务 ACID 分析总结 ┌───────────────┬────────────┬──────────────────────────┐ │ ACID 属性 │ 是否满足 │ 说明 │ ├───────────────┼────────────┼──────────────────────────┤ │ A 原子性 │ △ 弱满足 │ 运行时错误不回滚 │ │ C 一致性 │ ✓ 满足 │ 单线程天然保证 │ │ I 隔离性 │ ✓ 满足 │ 等效SERIALIZABLE │ │ D 持久性 │ △ 条件满足 │ 取决于持久化配置 │ └───────────────┴────────────┴──────────────────────────┘ 总评: Redis事务不是传统ACID事务 它是 批量命令执行器 乐观锁 设计哲学: 简单、高性能、信任开发者理解了Redis事务的ACID特性后你就能在合适的场景使用它批量操作减少RTT→ MULTI/EXEC 完美胜任CAS操作乐观锁→ WATCH MULTI EXEC条件逻辑 强原子性→ Lua脚本下一篇登场Redis事务就像一把瑞士军刀里的螺丝刀——不是最专业的工具但在合适的人手里它就是最好用的。上一篇【第55篇】Redis事务——MULTI/EXEC/DISCARD/WATCH详解下一篇【第57篇】Lua脚本——Redis里跑JavaScript的表亲