ACID vs BASE

ACID 四个字母各由数据库里的哪个机制兑现:atomicity 靠日志回滚、consistency 靠约束、durability 靠 WAL 刷盘,以及 PostgreSQL synchronous_commit、InnoDB innodb_flush_log_at_trx_commit、Redis appendfsync、MongoDB write concern 分别让你丢多少数据。BASE 系统怎么用幂等和补偿收敛,附丢数据算例和面试答法。

这一章回答三个问题:ACID 每个字母在数据库里由哪个机制兑现、durability 的配置到底能让你丢多少数据、放弃 ACID 的 BASE 系统靠什么最终对齐。 隔离级别和并发异常的细节在 Transactions,跨服务的 Saga、2PC、outbox 在 分布式事务,这里不重复。

ACID 和 BASE 不是两种数据库,而是两种保证的说法。同一个系统里通常两者并存:订单和支付走 ACID,点赞数、推荐列表、搜索索引走 BASE。面试里说「我用 NoSQL 所以是 BASE」会被追问;说清楚「哪类数据丢了或者晚到了代价是什么」才是答案。

有约束的设计问题

一个电商平台,高峰 2,000 笔订单/秒,有三类写入:

  1. 订单与支付:用户看到「支付成功」之后,这笔订单任何情况下都不能消失。
  2. 积分:每笔订单完成后给用户加积分,晚几秒到账可以,但不能加两次,也不能漏加。
  3. 商品浏览计数:每秒几万次自增,服务器崩溃丢掉最近一秒的计数没人在意。

三类数据对「提交后还能不能丢」「多久之后必须一致」的要求完全不同,下文每一节都回到这三类数据。

ACID:每个字母对应一个机制

ACID 代表 Atomicity、Consistency、Isolation、Durability,用于在 transactions 处理中保证 data integrity。

字母保证什么数据库里靠什么兑现应用还要做什么
Atomicity事务里的写要么全部生效,要么全部撤销日志(WAL / redo + undo);崩溃恢复时重做已提交的、撤销未提交的把必须一起成功的写放进同一个事务
Consistency事务结束时数据满足声明的规则PRIMARY KEY、FOREIGN KEY、UNIQUE、CHECK、exclusion 约束数据库管不到的业务规则(如「余额不能透支」没写成约束)由应用负责
Isolation并发事务互不看到对方的中间结果锁、MVCC、SSI选隔离级别,按级别写重试;详见 Transactions
Durability返回提交成功之后,崩溃也不丢提交时把日志刷到持久存储(fsync),再加复制确认刷盘策略和复制方式与数据的价值匹配

原文对四个字母的定义:

Atomic

一个 transaction 的所有操作要么全部成功,要么全部 rollback。MySQL 文档写明,InnoDB 的崩溃恢复不受刷盘设置影响,事务要么完整应用,要么完整抹去。

Consistent

Transaction 完成后,database 处于满足约束的状态。注意这个 C 和 CAP 里的 C 不是一回事:ACID 的 C 说的是约束和不变量,CAP 的 C 说的是多副本读到最新值。数据库只能保证你声明给它的规则;「库存不能为负」如果没写成 CHECK (stock >= 0),就只能靠应用。

Isolated

Transactions 之间互不干扰。Database 协调并发访问,让 transactions 看起来像顺序执行。实际数据库默认都不是完全串行:PostgreSQL 默认 Read Committed,InnoDB 默认 Repeatable Read,各自允许的异常不同。

Durable

一旦 transaction 提交成功,即使系统故障,数据也会保留。关键在「提交成功」是什么时候返回的:日志刷到磁盘之后返回,才是真的 durable;只写进内存或操作系统缓存就返回,崩溃时会丢。下一节就是这个区别。

Durability 是一组配置,不是开关

几个主流系统里控制「提交返回前日志要落到哪里」的参数:

系统参数默认值放松后的行为
PostgreSQLsynchronous_commiton:等本地 WAL 刷盘off:不等刷盘就返回,文档写明最坏延迟是 wal_writer_delay 的三倍;wal_writer_delay 默认 200 ms,即最多约 600 ms 的已确认事务可能丢失
MySQL InnoDBinnodb_flush_log_at_trx_commit1:每次提交写日志并刷盘,文档称这是完整 ACID 所必需0:每秒写并刷一次;2:每次提交写日志、每秒刷一次。两种设置下崩溃都可能丢最多约 1 秒的事务,文档说明每秒刷盘不是 100% 准时
Redis AOFappendfsynceverysecalways 每条命令 fsync;everysec 最多丢约 1 秒写入;no 交给操作系统决定何时刷
MongoDBwrite concern w 和 j多数部署默认 { w: "majority" }w: 1 只等 primary 确认;j: false 不等 journal 落盘

两个容易说错的点:

  • PostgreSQL 的 synchronous_commit = off 和 fsync = off 不是一回事。 文档说前者只会丢最近的事务,数据库状态和这些事务被干净回滚一样,不会损坏;后者是整个服务器级别的设置,可能导致数据损坏。前者还可以按事务设置:一个事务用哪种模式,取决于它开始提交时的 synchronous_commit 值。
  • 单机刷盘不等于不丢。 刷盘保护的是进程或机器崩溃;磁盘整块坏了、机房断电后主库起不来,要靠复制。异步复制时 primary 上已提交的事务在 failover 后可能不在新 primary 上,这部分见 Database Replication 和 PACELC。MySQL 文档也提醒,开了 binlog 的复制环境要同时设 sync_binlog=1。

算一遍:放松刷盘会丢多少单

用开头的 2,000 订单/秒,数据库崩溃一次:

  • InnoDB innodb_flush_log_at_trx_commit=2:最多约 1 秒未刷盘,约 2,000 笔已经告诉用户「支付成功」的订单可能不存在。刷盘偶尔晚于 1 秒时会更多。
  • PostgreSQL synchronous_commit=off:最多 3 × 200 ms = 600 ms,约 1,200 笔。
  • 两者都用默认值:0 笔丢在本机层面;代价是每次提交都要等一次日志刷盘。数据库会把同时提交的多个事务合并成一次刷盘(group commit),所以吞吐不是被磁盘单次延迟直接除出来的,具体数字要在自己的硬件上测。

结论很直接:订单库保持默认;浏览计数这类数据,可以在事务级别放松(PostgreSQL 里给这类写入的事务单独设 SET LOCAL synchronous_commit = off),或者干脆不放进这个库。

BASE:放弃即时一致,换可用和扩展

随着数据量增长和高 availability 需求,很多系统把部分数据交给不做跨记录事务的存储,放宽对即时一致性和数据新鲜度的要求,以换取 scale 与 resilience。

BASE 这个说法最早出现在 Fox、Brewer 等人 1997 年的 SOSP 论文 Cluster-Based Scalable Network Services,原文把它定义为 Basically Available、Soft State、Eventual consistency,并说「不严格满足 ACID 的数据语义都是 BASE」。Dan Pritchett 2008 年在 ACM Queue 的《BASE: An ACID Alternative》把它用到了数据库的功能拆分和异步更新上。BASE 与 ACID 没有严格的一一对应:

Basically Available

系统在部分节点故障时仍然响应,可能返回旧数据或部分结果,而不是整体报错。

Soft state

不同 replicas 在某一时刻不要求相同,状态会在没有新写入时继续变化(复制、修复还在进行)。

Eventual consistency

没有新写入后,所有副本最终收敛到同一个值。期间系统仍可读,只是读到的可能不是最新值。

「最终」不是一个时间承诺。设计 BASE 系统必须回答两件事:正常情况下收敛要多久(要监控),收敛之前读到旧值用户会看到什么。

让 BASE 真正收敛:幂等 + 补偿

以积分为例。订单事务提交后发一条「订单完成」消息,积分服务消费后加分。消息队列通常是至少一次投递,同一条消息可能来两次;积分服务也可能在加完分、确认消息之前崩溃,然后重新消费。

Pritchett 那篇文章用的就是这个思路:用持久化消息队列异步更新汇总数据,再用一张记录「已经应用过哪些消息」的表让重复消息不会被加两次。写成 SQL:

BEGIN;
INSERT INTO applied_events (event_id) VALUES (:event_id);  -- 主键冲突说明处理过,回滚并确认消息
UPDATE user_points SET points = points + :delta WHERE user_id = :user_id;
COMMIT;
-- 提交后才确认(ack)消息

去重记录和积分变更在积分服务自己的库里是同一个 ACID 事务,这一步是局部 ACID。整体链路(订单库 → 队列 → 积分库)是 BASE:两个库在几百毫秒到几秒内不一致,但不会多加也不会漏加。

订单先提交再发消息,中间崩溃会漏发,这一步用 outbox:把「要发的消息」和订单写在同一个事务里,再由单独的进程读出来发送。订单被取消时,积分不能「回滚」,只能发一条反向的补偿事件扣回去。这两个机制的完整拓扑见 分布式事务。

读的一侧也可以选。DynamoDB 的读默认是最终一致的,GetItem、Query 可以传 ConsistentRead: true 拿最新值,文档写明最终一致读的成本是强一致读的一半,而 GSI 只支持最终一致读。所以「用户刚改完资料立刻查看」走强一致读,列表页走最终一致读。

常见翻车

翻车用户看到什么修法
为了压测好看把 innodb_flush_log_at_trx_commit 改成 2,忘了改回来数据库主机崩溃后,最近一秒的「支付成功」订单查不到核心库保持 1;只在可丢数据的库上放松,并写进配置审计
以为 fsync = off 只是「快一点」断电后数据库启动报数据页损坏不在生产关 fsync;需要快用 synchronous_commit = off
单机刷盘但异步复制,failover 到落后的副本切换后几秒内的订单消失核心数据用同步或半同步复制,或 failover 前确认副本追平
业务规则只写在应用里,没有约束两个服务版本并存时写入违规数据能写成 CHECK / UNIQUE / FOREIGN KEY 的规则交给数据库
BASE 消费者不幂等积分偶尔加两次去重表或条件写,和业务变更放在同一个本地事务
「最终一致」没有监控某个消费者卡住两小时,用户投诉积分不到账才发现监控复制滞后、消费延迟和积压,设告警阈值

怎么选,回到三类数据

  • 订单与支付:ACID,默认刷盘,同步或半同步复制,约束写在库里。
  • 积分:跨服务用 BASE,靠 outbox + 至少一次投递 + 幂等消费收敛;积分库内部的「去重 + 加分」仍是一个 ACID 事务。
  • 浏览计数:BASE,可以放在 Redis 里用 appendfsync everysec 或更松的设置,丢一秒可以接受;定期汇总进数据库。

面试时这样回答

  1. 先按数据分类。 「订单不能丢、积分可以晚到但不能重复、计数可以丢一秒。」
  2. 把 durability 说成配置。 点名参数和默认值:InnoDB innodb_flush_log_at_trx_commit=1、PostgreSQL synchronous_commit=on,并说出放松后的丢失窗口(约 1 秒、约 600 ms)。
  3. 说清 C 是谁保证的。 数据库只保证声明过的约束,业务规则能下沉就下沉。
  4. BASE 要说怎么收敛。 outbox 保证消息不漏发,幂等消费保证不重复,补偿事件处理撤销;给出监控的滞后指标。
  5. 说一个故障。 例如异步复制 failover 丢了已提交的订单,修法是同步复制或切换前确认副本追平。

相关章节:Transactions、分布式事务、Database Replication、PACELC Theorem、Consistency Patterns、SQL databases、NoSQL databases。

一手证据

丢失订单数的算例用的是本章假设的 2,000 单/秒;三类数据的落位是 JR Academy 的归纳,文档支撑的是各参数的行为。