【Redis从入门到精通】第56篇:Redis事务的ACID分析——它到底算不算ACID事务
2026/6/4 14:41:21 网站建设 项目流程

上一篇【第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;UPDATEaccountSETbalance=balance-100WHEREid=1;UPDATEaccountSETbalance=balance+100WHEREid=2;-- 如果第二条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 a"hello"QUEUED SET b100QUEUED INCR a# 运行时错误:"hello" 不能加1QUEUED SET c"world"QUEUED EXEC1)OK2)OK3)(error)ERR value is not an integer or out of range4)OK# → a="hello", b=100, c="world"# → b和c正常设置,a保持了SET的结果# → 没有出现"半完成"的不一致状态

虽然INCR失败了,但其他命令的执行结果是完整的——不会出现"b被设成了500但事务没提交"这种中间状态。每个成功的命令都是独立完整地执行了。

3. 服务器故障:一致性保持

如果在EXEC执行过程中Redis崩溃:

  • 如果开启了AOF且appendfsyncalways:已执行的命令都已持久化,重启后恢复
  • 如果开启了RDB:正在生成的快照可能包含部分结果,但Redis的RDB是fork+copy-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读到counter=10,但在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 yes+appendfsync 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的作者Antirez(Salvatore 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认为:

  1. Redis的命令错误只有两种:语法错误(编程时就该发现)和类型错误(也是编程时就该避免的)
  2. 回滚需要undo log,这会让Redis变得复杂且低效
  3. 生产环境不应该有运行时错误——如果你的代码对字符串做INCR,那应该在开发阶段就发现并修复

这个逻辑对不对?从Redis的定位(高性能缓存/数据结构服务器)来看,确实有道理。但如果你把Redis当数据库用,那这个设计就有点"任性"了。

如果你需要回滚能力,可以使用Lua脚本—— Lua脚本在执行出错时会自动回滚(脚本中的所有Redis命令都不会生效)。


WATCH乐观锁 vs MySQL悲观锁

对比维度WATCH(Redis)SELECT FOR UPDATE(MySQL)
锁类型乐观锁悲观锁
实现方式标记 + EXEC时检查行锁 / 表锁
阻塞行为不阻塞其他客户端阻塞其他事务的读写
死锁风险有(需要超时/重试)
性能高(无锁等待)低(锁等待)
重试机制需要客户端自己实现自动等待或超时
适用场景读多写少,冲突少写多冲突多
# Redis WATCH 重试模式(乐观锁)whileTrue:WATCH key val=GET key# 业务逻辑...MULTI SET key newval result=EXECifresultisnotNone:break# 成功# 失败则重试(其他客户端修改了key)
-- MySQL 悲观锁BEGIN;SELECTbalanceFROMaccountWHEREid=1FORUPDATE;-- 此时其他事务如果也SELECT FOR UPDATE同一行,会被阻塞UPDATEaccountSETbalance=balance-100WHEREid=1;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的表亲


需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询