仓库 · 421 字 · 2 分钟阅读

ACID,一个字母一个字母看

数据库到底是怎么兑现 Atomicity、Consistency、Isolation、Durability 的——预写日志、MVCC、锁调度,以及那个让大多数存储栈至少丢过一次脸的 fsync 问题。

#TL;DR

ACID 是那个四字母缩写——Atomicity(原子性)、Consistency(一致性)、Isolation(隔离性)、Durability(持久性)——区分一个数据库和一个文件的东西。它是一份承诺:事务要么完整完成,要么根本没有发生;数据保持内部合法;并发事务看不到彼此的半成品;已提交的数据能挺过崩溃。每一个字母背后的实现都是一个独立的深水话题。原子性和持久性依赖预写日志;隔离性靠锁或多版本并发控制实现;一致性一半是约束执行的故事,一半是”其他东西还在正常工作”。任何一个现代关系数据库的工程预算,多数都花在让这四条承诺在负载下保持成立。

#ACID 从何而来

ACID 这个词由 Theo HärderAndreas Reuter 在他们 1983 年的综述论文 Principles of Transaction-Oriented Database Recovery 里创造。其下的想法更老——Jim Gray 关于事务的论文可追溯至 1970 年代中,System R 从 1974 年开始就在实现这些想法——但 Härder 和 Reuter 给出了一个好记的名字和一套干净的分类。

这篇论文是综述,不是规范。但这个缩写留了下来,“ACID-compliant”成了”我们认真对待事务”的简略表达。

#A(原子性):全做或全不做

一个事务要么整体执行,要么完全不执行。 任何部分状态都不能被其他事务看到,也不能被持久化到磁盘。

经典例子:

BEGIN;
  UPDATE accounts SET balance = balance - 500 WHERE id = 1;
  UPDATE accounts SET balance = balance + 500 WHERE id = 2;
COMMIT;

如果系统在两条 UPDATE 之间崩溃,这笔事务必须要么 (a) 完成——两条更新都落地——要么 (b) 被回滚,就像它从未发生。不可以出现:钱从账户 1 消失了,却没出现在账户 2。

原子性如何实现:预写日志(WAL)。

数据库在把任何改动应用到真实的数据文件之前,先把一条该改动的记录写到一个只追加的日志文件里:

LSN 1001: BEGIN tx 7
LSN 1002: UPDATE accounts SET balance=balance-500 WHERE id=1  (旧: 1000, 新: 500)
LSN 1003: UPDATE accounts SET balance=balance+500 WHERE id=2  (旧:  200, 新: 700)
LSN 1004: COMMIT tx 7

真正的数据页此时在磁盘上是否已经更新并不重要。重要的是日志。崩溃恢复时,数据库会:

  1. 从上一个检查点开始扫日志。
  2. 对于每一笔有 COMMIT 记录的事务——把它的改动重做一遍(redo)。
  3. 对于每一笔没有 COMMIT 记录的事务——把它的改动回滚(undo)。

这个恢复过程之后,数据库处于这样一个状态:每一笔已提交的事务都是持久的,每一笔没完成的事务都被擦掉了。原子性得以跨越崩溃被保持。

WAL 是数据库工程中最关键的一个机制。每个主流关系数据库都有一份——Postgres 叫 WAL、MySQL/InnoDB 叫 redo log、Oracle 也叫 redo log、SQL Server 叫 transaction log。它们都是同一个模式。

#C(一致性):只能停在合法状态

一个事务把数据库从一个合法状态移动到另一个合法状态。 在已提交状态下,定义好的约束(外键、唯一、CHECK、触发器)永不被违反。

C 是 ACID 里那个比较特别的字母。其他三个都由数据库自身的机制来执行。一致性一部分是数据库的工作,一部分是应用的工作。

数据库负责执行:

  • PRIMARY KEY 的唯一性。
  • FOREIGN KEY 的参照完整性。
  • NOT NULLCHECKUNIQUE 约束。
  • 表上定义好的触发器。

数据库负责执行:

  • 只写在应用逻辑里的业务规则。
  • 跨服务的不变式(例如”总余额应等于所有交易之和”)。
  • 语义上的正确性(如果没 CHECK 约束阻止,模式可能允许账户余额为负)。

如果你的事务让数据库停在一个技术上通过了全部约束、但语义上并不合法的状态——从账户里扣了钱,却没在另一处建立对应的交易记录——数据库会说”一致”,你会说”不一致”。你们都在各自正确的事情上是对的。

C 在 ACID 里实际的含义是:数据库能查的约束,你定义了,它就会查。 其他的都是你自己的事。

#I(隔离性):并发事务不互相污染

并发事务的行为应当如同它们一个接一个串行执行。 没有哪一笔事务看得到另一笔事务的中间状态。

这是实际最难兑现的一个字母,也是数据库提供可调权衡最多的那一个。

#各种异常

经典的并发异常四种,一个比一个难防:

  • 脏读 — 事务 A 读到了事务 B 已写入但尚未提交的数据。如果 B 回滚,A 就看到了一份幻影数据。
  • 不可重复读 — A 读某一行,B 改完并提交,A 再读一次,值不同了。
  • 幻读 — A 跑一条查询返回了一组行,B 插入一条也满足查询条件的新行并提交,A 再跑同一查询,结果里多了一行。
  • 串行化异常 / 写偏斜 — 两笔事务各自读了同一份数据,根据它做判断,然后都写回——合起来的效果不可能由它们两笔以任何串行顺序产生。

#隔离级别

SQL 定义了四种隔离级别,每一级都防更多异常(通常也更贵):

级别脏读不可重复读幻读写偏斜
Read Uncommitted允许允许允许允许
Read Committed禁止允许允许允许
Repeatable Read禁止禁止看实现允许
Serializable禁止禁止禁止禁止
  • Read Uncommitted — 能读到未提交的数据。几乎不是你想要的。多数数据库不实现它,直接”悄悄升级”成 Read Committed。
  • Read Committed — Postgres 和 Oracle 的默认。读只看到已提交数据,但一条事务内跑两次同样的查询可能得到不同结果。
  • Repeatable Read — MySQL/InnoDB 的默认。一个事务内读到的数据是稳定的。幻读是否被防住取决于具体数据库。
  • Serializable — 最严格的一级。事务行为就像严格串行执行的那样。很贵,多数应用不用。

权衡在并发度与正确性之间。隔离级别更高,保证更强,但吞吐更低、延迟更高。多数生产系统跑在 Read Committed,接受一些异常可能发生——并精心设计应用,把影响降到最小。

#两种实现策略

实现隔离性有两条根本路径:

两阶段锁(2PL)。 事务每读一行,拿一把共享锁;每写一行,拿一把排他锁。锁一直持有到事务提交为止。事务间的冲突会让其中一方等待。

优:概念简单、保证强。 劣:读阻塞写、写阻塞读,可能出现死锁,竞争下吞吐受拖累。MySQL/InnoDB 在某些隔离级别、传统 SQL Server,都使用这种方式。

多版本并发控制(MVCC)。 每次写入会生成该行的新版本;只要还可能有事务需要旧版本,旧版本就留着。读永不阻塞写,因为它们始终能看到”我这个事务开始时还生效”的那个版本。写也永不阻塞读。

优:对读多型负载并发度高得多。 劣:有磁盘空间开销(旧版本会堆积,需要周期性清理,在 Postgres 里叫 “vacuum”),实现更复杂,在某些隔离级别下语义更微妙。

Postgres、Oracle 以及现代 SQL Server 使用 MVCC。 MySQL/InnoDB 走的是大部分 MVCC、在 serializable 下混一些锁的混合路线。MVCC 是主流现代方案,原因是读多型负载是常见情形,而阻塞读对吞吐是灾难性的。

#D(持久性):提交后的数据能熬过崩溃

一笔事务一旦提交,它的效果就应挺过之后的任何崩溃。 停电、内核 panic、kill -9 打死数据库进程——数据库重启回来时,已提交的数据还在。

持久性如何实现:fsync()。

当事务提交时,数据库写出它的 WAL 记录,然后调用 fsync()(或 fdatasync())把日志强制写到物理存储。只有 fsync() 返回之后,数据库才会告诉应用”提交成功”。

这才是大多数事务型负载里真正的瓶颈。现代磁盘在吞吐上一秒能扛几十万次写——但 fsync 是完全另一种操作。它是一个屏障:数据库告诉操作系统、操作系统告诉文件系统、文件系统告诉磁盘,立刻把这份数据持久化,并确认回给我,我才继续。这条链路上的每一层都可能说谎。

#fsync() 问题

一个著名而又反复发生的尴尬:fsync 会撒谎

多年来,各种文件系统、磁盘、RAID 控制器和操作系统配置的组合,都曾在数据实际还没落到稳定存储上时,先报告 “fsync 成功”。写入实际上还在磁盘缓存或控制器缓存里,断电就可能丢掉。

  • 2018 年,Postgres 和 MySQL 发现某些 Linux 文件系统在特定故障模式下,会 fsync() 成功,却在后续错误里把数据丢掉。这引出了 Postgres 开发者那篇著名的 “fsync bug” 帖子,最终推动了内核级别的改动。
  • 消费级 SSD 长期以来被知道会在数据还在易失性控制器缓存里时报告 fsync 完成。企业级 SSD 有掉电保护缓存,消费级没有。
  • 带回写缓存的 RAID 控制器本应配电池备份;电池老化时,持久性默默退化成了”尽力而为”。

结论:“持久”是整条栈的性质,不只是数据库的。数据库的持久性,受制于它底下那层存储所允许的上限。多数生产数据库在文档里都写明了它们认为”安全”的存储配置,Postgres 有一个显式的 fsync = off 选项,用持久性换性能(并且明确宣告:持久性保证在这种配置下作废)。

#同步提交 vs. 异步提交

在存储的物理极限之内,数据库提供可调的持久性等级:

  • 同步提交(默认)。 等 WAL fsync 完成再报告提交。持久性最强,吞吐最低。
  • 异步提交。 立刻报告提交,fsync WAL 在后台做。吞吐更高,但”提交到 fsync 之间”崩溃会丢掉刚提交的事务。
  • 组提交。 把多个事务的 WAL 记录打包进一次 fsync,把开销摊到很多次提交上。多数数据库会自动这样做。
  • 基于复制的持久性。 等 WAL 到达一个副本,而不是等本地磁盘落盘。常常比本地 fsync 快(网络快、fsync 慢),并提供另一种形式的持久性保证。

#分布式系统里的 ACID:BASE 与 CAP

ACID 那套承诺在单机上相对好守。分布到多台机器上后,它们就难得多。这是 CAP 定理和 2000 年代那股 BASE(Basically Available, Soft state, Eventually consistent)潮流所探索的东西。

2000 年代的 NoSQL 潮流部分是对”分布式里 ACID 太贵”的反应。早期的 MongoDB、Cassandra、Riak 等,为了横向扩展和分区容忍性,放弃了一部分 ACID 属性。

2010 年代和 2020 年代则部分地把这件事翻了回来:Google SpannerCockroachDBYugabyteDBFoundationDB 这些系统证明,只要你肯为聪明的基础设施(原子钟、共识协议、精细网络工程)买单,分布式 ACID 是可以的。Spanner 用的 TrueTime API——在每个数据中心部署的 GPS 与原子钟——提供全球有序的提交时间戳,从而能在跨洲的规模上提供 serializable 隔离。

ACID 并没有输。它只是证明了自己在分布式里可实现,只是贵。代价不可承受的地方,BASE 路线继续活着。今天多数现代系统对每种负载挑它所需要的 ACID 级别,而不是把 ACID 当成”要么全有、要么全无”。

#ACID 到底是什么

关系模型那篇把 ACID 描述为”数据库所许下的承诺”。说得没错,但低估了下面的东西。每一个字母都是实现冰山的尖顶:

  • A 是预写日志——关于”如何在任意失败点上安全恢复”的 40 年研究。
  • C 是约束执行加模式设计——数据库层上相对薄的一块,应用层上厚得多的一块的上面一层。
  • I 是并发控制——数据库里最难的问题,有两大族解法(锁、MVCC),还有无数变体上的持续研究。
  • D 是存储栈工程——让操作系统、文件系统、硬件可靠地把数据落下来,这件事比任何人第一次遇到时所预期的都难。

在高负载下把这四件事同时做好、还不牺牲吞吐,基本上就是数据库这门学科的全部工程难题。2026 年每一个主要的关系数据库——Postgres、MySQL、Oracle、SQL Server——在核心上都是一台非常精细的机器,用来在每秒服务几万笔事务的同时,把 ACID 保持住。Codd 1970 年发表的数学给你的是什么;Härder 和 Reuter 给出了承诺;之后这四十三年,讨论的都是怎么做