什么是ACID?分享一次数据库事务最底层原理的全拆解
如果你写过哪怕一条转账 SQL,就一定听过“ACID”这四个字母。但说实话,我在刚工作那几年,对这一组概念的认知其实是残缺的:背得出“原子性、一致性、隔离性、持久性”,可真要我说清楚“隔离性和一致性到底差在哪”“原子性回滚是靠什么做到的”“断电那瞬间数据库靠什么保证不丢数据”,立刻就含糊了。后来在线上排查过几回数据不一致的问题,再自己动手做过断电模拟、改过隔离级别、看过 undo log 和 WAL 的日志之后,才算是把这四个特性真正拼成了一个整体。
这篇就把 ACID 掰开了讲一遍,每个特性不光是定义,更重要的是“它靠什么机制落地”“它在真实数据库里是怎么工作的”“选型时怎么权衡”。无论你是刚要面试、正在写业务代码,还是准备系统性补一遍数据库底层知识,照着这条线走下来,你会有一种“原来这几个概念是互相咬合”的贯通感。
1. 先搞懂 ACID 到底在解决什么问题
1.1 从一次转账谈起
讲 ACID 最经典的例子永远是转账,因为它是我们每个写业务的人都能直接感知到的场景。假设账户 A 要给账户 B 转 100 块,在数据库层面就是两条更新语句:一条把 A 的余额减 100,一条把 B 的余额加 100。这两条语句放在同一个事务里执行,就产生了一个核心问题:如果减了 A 的钱,还没来得及给 B 加钱,数据库突然宕机了,这笔账怎么算?
ACID 就是数据库对这个问题的“官方承诺”。它不是一个空洞的口号,而是由一组底层机制共同保证的:原子性管“要么全做要么全不做”,一致性管“业务规则不被破坏”,隔离性管“并发事务之间不互相干扰”,持久性管“一旦提交就绝不丢失”。四个特性彼此独立,又彼此支撑,少了任何一个,另外三个都会失去意义。
1.2 没有事务的数据库会怎样
为了把这个问题看得更透,不妨设想一个没有任何事务机制的数据库。你执行那两条更新语句,第一条成功了,第二条因为磁盘满了报错。数据库不会帮你回滚,A 少了 100,B 没多 100,钱凭空消失了。
你可能觉得这种低级问题是早期的玩具数据库才会犯的错。可直到现在,我在生产环境里看到的不少数据坑,本质上就是开发者没用好事务:程序里自己 try-catch 掉异常,两条表更新没有包在同一个事务注解里,或者干脆在事务中间做了远程调用导致连接超时回滚失败。这些情况不是数据库破坏了 ACID,而是我们自己没有正确使用事务边界,把 ACID 的“承诺范围”给架空了。
数据库真正承诺的 ACID 只针对“被正确声明的事务”——你告诉它“这三条语句是一个整体”,它才负责让这三条语句具备原子、隔离、持久这些属性。你把它拆成三个独立操作,它自然也没义务替你兜底。
2. 原子性(Atomicity):要么全做,要么全不做
2.1 原子性的本质不是“同时”,而是“可回滚”
很多刚接触事务的人会误以为原子性是“多条语句瞬间同时生效”。实际上,从微观时间线看,事务里的语句在磁盘上一定是一个一个依次执行的,不可能有一台机器让多行数据“量子纠缠”式地同时变化。真正让事务具备原子性的,是一条写在事务日志里的记录,配合一套完整的回滚机制。
以我现在开发中使用的关系型数据库为例,每条事务在真正修改数据之前,都会先把旧值写成一条“反向操作”日志,这份日志在行存储引擎里通常叫 undo log,在不少日志结构存储引擎里则体现为日志记录里的前像。事务执行到一半出错,数据库不会假装什么都没发生,而是沿着 undo 日志把已经改过的每一行都恢复成修改前的样子。
2.2 undo log 是怎么工作的
我尽量用人话解释这条链路。你在事务里执行了“A 减 100”,数据库会先往 undo log 里写一条记录:“如果你要回滚,请把 A 余额改成旧值,例如 500”。然后才真正修改内存中的对应数据页,再在合适的时机把修改同步到磁盘。
等事务提交成功,这条 undo 记录就可以被清理或复用;事务一旦失败或主动回滚,数据库就按 undo 记录里的“逆向操作”逐行恢复。你不需要在代码里手动记录 A 原来的余额是什么,引擎把这件事全包了。
这里有一个特别有趣的点:undo log 不仅在回滚时起作用,它还是实现多版本并发控制的基础。后面讲隔离性时你会看到,一行数据被事务修改后,其他事务为什么还能读到之前的版本,靠的就是这些历史版本,而它们就存在 undo 记录里。
2.3 实操中如何保证原子性
从应用层角度,保证原子性就一条:把你的所有关联变更都放进同一个数据库事务里。
START TRANSACTION; UPDATE accounts SET balance = balance - 100 WHERE account_id = 'A'; UPDATE accounts SET balance = balance + 100 WHERE account_id = 'B'; COMMIT;这两条 UPDATE 必须处于同一个事务块内,中间不要夹杂远程调用、消息发送、文件写入这种数据库无法回滚的外部副作用。我在业务代码里最常见的坑就是“先调了第三方支付接口,再更新本地订单状态”,结果本地更新失败,前面的接口调用已经把钱划走了。类似问题可以靠事务和外部操作编排来修正:要么把外部调用放到事务提交之后,并配套补偿机制;要么引入本地消息表,把外部调用的结果作为独立事务的一条记录先落库。
提示:事务块里尽量别做耗时操作。长事务会一直持有锁和快照资源,高并发下极容易出现锁等待堆积,这是很多线上死锁和连接池打满的根源。
3. 一致性(Consistency):业务规则的最后防线
3.1 一致性的两层含义
一致性是所有 ACID 成员里最容易被误读的一个,因为它其实有两层含义。
一层是数据库层面的完整性约束:主键不能重复、外键必须存在、非空字段不能为空、余额不能小于零这样的 CHECK 约束。这些规则是你在建表时声明好的,数据库会在每次写入时强制校验,违反就报错回滚。
另一层是业务层面的一致性:转账前后双方总金额不变、只允许从已审核通过的订单去发货、一个用户不能同时有两个未完成订单……这类规则数据库并不知道,它需要你在代码里显式保证,或者通过带业务语义的约束来间接表达。
3.2 约束与触发器
我见过不少团队把业务一致性寄托于“代码逻辑写好就行”,可一旦代码因为历史版本、多个服务并发调用出现漏洞,数据就直接脏了。更稳妥的做法是能力允许时把关键规则下沉到数据库层。
例如,账户余额的字段如果定为“不允许小于 0”,那么你可以直接写一个约束:
ALTER TABLE accounts ADD CONSTRAINT chk_balance_positive CHECK (balance >= 0);有了这条约束,哪怕业务代码算错了,更新的结果一旦把余额改成负数,数据库也会拒绝并报错,本来含冤的转账就会立即回滚,而不会把负数余额落盘。我的经验是,凡是能一眼看明白的强规则,尽量建表时就落成约束;有些场景下也可以配合触发器做更复杂的联动校验,但触发器要谨慎使用,调试成本高、性能损耗大,弄不好还会给迁移和运维增加不少负担。
3.3 一致性是最终目标,其他三个特性是手段
理解 ACID 的全局结构,最省力的一种方式是:一致性是“目的”,原子性、隔离性、持久性是“手段”。原子性让事务内部的中间状态不会被看到,隔离性让并发事务不会互相污染,持久性让已提交的成果不会因为宕机而丢。三个机制各自守住一条线,共同服务“让数据从一个一致状态跳到另一个一致状态”的目标。
这一点在面试里被问“ACID 之间是什么关系”时非常加分。一致性并不是一个可以独立打开的开关,而是由原子性、隔离性和持久性合起来推导出的最终性质。主键冲突这类约束属于另一条独立的防线,它不需要前三个机制也能生效。
4. 隔离性(Isolation):并发世界的介质
4.1 四种隔离级别到底差在哪
隔离性处理的是并发问题。如果没有隔离控制,两个事务同时改一行数据、同时读写一个范围,就可能出现脏读、不可重复读、幻读三类异常。数据库给出的解决方案并不是只能选“全隔离”或“全不隔离”,而是提供了四个档位由你按业务取舍,默认值和可用性在各数据库中也完全不同。
这里梳理一个最实用的速查表:
| 隔离级别 | 脏读 | 不可重复读 | 幻读 | 并发性能 |
|---|---|---|---|---|
| READ UNCOMMITTED | 可能 | 可能 | 可能 | 最高 |
| READ COMMITTED | 避免 | 可能 | 可能 | 偏高 |
| REPEATABLE READ | 避免 | 避免 | 多数引擎可避免 | 中等 |
| SERIALIZABLE | 避免 | 避免 | 避免 | 最低 |
- 脏读:能读到别的事务尚未提交的修改,如果对方后来回滚,你读到的就是压根没存在过的数据。
- 不可重复读:同一事务里两次读同一行,结果不一样,因为另一事务在两次读之间提交了修改。
- 幻读:两次范围查询期间,另一事务插入了符合条件的新行,导致结果集变多。
我自己的经验是:绝大多数业务系统选 READ COMMITTED 或 REPEATABLE READ 就是安全的,完全没必要上 SERIALIZABLE,那通常意味着把所有并发事务串行化,性能代价极其惨痛。
4.2 锁与多版本并发控制是怎么配合的
隔离性落地的两个主要武器:锁和快照。
锁走的是“我改的时候你等着”的路线。一片数据被事务 A 加了写锁,事务 B 想写同一片数据就得排队。锁能直接避免冲突,但会把高并发场景的吞吐拖垮。
多版本并发控制则是另一种思路:事务在修改一行数据时,不直接覆盖旧值,而是生成一个新版本,并把旧版本保留在 undo 历史里。读事务根据自己的可见性规则去找合适的版本,写写之间依然通过锁互斥,读写之间则互不阻塞。这套机制在同一时刻能够并存很多版本的数据,从应用层的角度看,每次读到的都是一份“一致快照”。
业界几个主流引擎的默认隔离级别并不一致,最稳妥的做法是不背默认值,而是明确建连时设定级别,并把隔离级别写进团队开发规范。
4.3 隔离级别选型的实战经验
怎么选级别,我的判断标准是“先看有没有并发写同一行”。如果你开发的系统里,同一账户的余额会被大量功能并发修改,那么读提交和可重复读在应对写冲突时实际差别不大,写写冲突照样要靠锁等待或应用层重试;如果你有一个“事务内多次读同一明细不能变”的强诉求,那就考虑可重复读,否则读提交往往已经够用。
可重复读在有些引擎里还会顺带把幻读也防住了,这依赖的是间隙锁的辅助。间隙锁锁的是“两个索引值之间的空隙”,它虽然防了幻读,却也让并发插入的能力下降。所以选隔离级别别只盯着“越严越安全”,还得看清你的业务到底最在意一致性还是吞吐。
5. 持久性(Durability):断电那一瞬靠什么救命
5.1 先写日志还是先写数据
持久性回答的问题只有一个:“你告诉我事务提交成功了,那数据就绝对不能丢”,哪怕是下一秒机器断电。想做到这一点,最扎实的工程基石是预写日志。
逻辑非常简单:要修改磁盘上的数据页之前,先把这次修改以追加写的方式落进日志文件。日志顺序写在磁盘尾部,速度远快于随机位置的数据页改写。当系统崩溃重启,数据库扫描日志,把“已经记录但尚未落盘”的修改重新应用到数据页,这个过程就是重放或前滚恢复。
你会发现这条链路的顺序至关重要,必须遵循“日志先行”。假若先把数据页改了,日志还没写,此时断电,内存里的修改全丢,磁盘上因为没写日志也无法得知补多久修改,数据直接丢失。日志先行的前提下,最坏情况也只是日志里存在但数据页没更上,重启后重放即可。
5.2 刷盘策略与性能权衡
持久性不是免费的。为了让事务提交的确认可以尽量快地返回给客户端,很多数据库允许你在“即时刷盘”和“缓冲合并写”之间做取舍。
拿常见的两种策略举例,一种是每次提交都强制把日志缓冲刷到磁盘,这样可以做到严格的不丢;另一种是允许日志缓冲每隔一小段时间批量刷一次,性能好了很多,但如果机器在缓冲区尚未刷出的窗口内断电,最近一批已提交事务就有丢失风险。到底选哪种策略,本质上是在磁盘 IO 和可靠性之间选。
我的建议是:金融、订单、账务这类核心链路,用最严格的刷盘策略;日志、统计、非关键缓存类数据,可以考虑放宽,换取吞吐。这个话题如果面试里被追问,你可以顺着“组提交”“双写缓冲”“从库可退化”这些关键词往深处讲,但落到一线开发,真正要记住的是——别为了性能把核心资金的刷盘等级调松。
5.3 断电测试怎么做
持久性不能靠嘴说,得靠实验验证。我在本地环境做过一次比较笨但有效的断电模拟:开一个事务写一批数据,commit 成功后立即把虚拟机的电源直接关闭,而不是优雅退出;重启数据库,检查这批次数据是否完整。
这个测试的价值在于帮你建立对数据库崩溃恢复的真实体感。你还能顺手观察恢复日志、启动等待时间、损坏页检测这些细节。有条件的朋友建议在测试环境用脚本循环做几十次,每次插入一批带序号的数据后随机 kill -9,再启动校验连续性,这套流程做下来,你对你所使用的数据库的持久性边界会有一个非常量化的理解。
注意:不要用杀客户端进程来模拟数据库崩溃,那只是让连接断了,数据库本身还在正常运行。要测持久性,必须在数据库实例层面断电。
6. 原子性、一致性、隔离性、持久性是怎么协同作战的
6.1 崩溃恢复视角下的完整链路
把四个特性放进一个故障场景里串一遍,整个体系的面貌会清晰得多。
假设一个转账事务已经执行了“A 减 100”,正要把“B 加 100”写入时,数据库崩溃。重启后系统检查事务日志,发现这个事务的提交记录不存在,判定它处于未提交状态,于是用 undo 信息把 A 上的减额操作回滚掉。这是原子性和持久性在分工:未提交的回滚,已提交的重放。
再看另一个场景:事务刚刚提交成功,提交记录已经盘,但数据页还没刷出去,这时断电。重启后,数据库读取提交记录,重放日志里的变更,让数据页恢复到提交后的状态。这就是持久性发挥作用的地方。
那么隔离性呢?如果这个事务在崩溃之前,已经持有某些行锁并写入了新版本,那么其他并发事务在崩溃恢复期间读到的是什么?它们会按照可见性规则,看到事务回滚前的旧版本。所以恢复那一刻,一致性并没有被破坏——崩溃、并发、回滚三件事同时发生,ACID 也能保证数据库最终回到一个自洽状态。
整个过程里最妙的点就在这:原子性、持久性依赖日志两端——undo 管未提交的清理,redo 管已提交的重放;隔离性依赖锁保证写写互斥、依赖版本链实现读写的并行;一致性则是它们共同作用后的业务保证。四个特性不是你背过的四个孤立名词,而是一套互相咬合的恢复协议。
6.2 常见误区和面试高频考点
这些年我见过不少常见的误解,整理成几个典型,你可以自测一下:
- 误区一:把一致性理解为“最终一致”。ACID 的一致性更偏“事务执行前后,数据库都应满足完整性约束”,和分布式领域里的最终一致性是两码事。
- 误区二:以为 READ COMMITTED 在任何数据库里都避免不可重复读。大多数关系型数据库能避免,但严谨地说,隔离级别的约定在各引擎之间本来就存在细微差异。
- 误区三:把持久性等同于“随时保存”。实际上,操作系统也有自己的页面缓存,数据库写入并不一定立即落物理磁盘。数据库能做的是通过日志刷盘和直接 IO 选项去缩小这个不确定性,并非完全消除。
- 误区四:见了“快照读”就以为没有锁。快照读通常不需要行锁,但写快照对应的数据时仍需获取最新版本的行锁,两者并不矛盾。
面试里容易踩的追问也集中在这几个点:undo log 和 redo log 分别什么时候写、什么时候清;隔离级别和锁、快照的关系是什么;为什么有了 binlog 还需要本地 undo/redo。能用自己的话把这些问题讲顺,基本就真的理解了,而不是只是在讲义里背过定义。
以我常用的关系型数据库为例,它把 binlog 作为逻辑日志用于复制,把 redo 作为物理日志用于崩溃恢复,把 undo 作为回滚和多版本快照的来源。三者生命周期不同、用途不同,但共同保证了事务语义。面试里回答这类问题,尽量按“用途—写入时机—清理策略”三个维度去组织,你会显得比单纯背名词的人扎实得多。
7. 实践心得与避坑指南
7.1 事务边界和代码分层
如果只能给一条关于 ACID 的最强实践建议,我会说“把事务边界画清楚,别让数据库事务管太多不该管的事”。
我见过很多高并发系统的故障,追根溯源并不是数据库的 ACID 失效了,而是事务跨越了太多非数据库操作,比如在事务里发 HTTP 调用、写对象存储、等消息队列响应。这样做的后果是,事务持有数据库连接和锁的时间被大幅拉长,某次依赖超时就让整个事务回滚,还可能反复重试导致连接池被打爆。
我的规范是:一个数据库事务只包含必要的数据库读写;外部接口调用放在事务提交之后,失败走补偿;实在需要保证一致性的跨资源操作,优先考虑本地消息表或事务性消息框,而不是硬把一个分布式动作塞进单个数据库事务。
7.2 排查数据一致性问题的顺序
线上如果出现了对不上账、重复扣款这类数据问题,我建议你按固定顺序排查,不要病急乱投医:
先看业务的日志和调用链,确认事务边界是否被正确声明。再看数据库通用日志和慢查询日志,确认是否存在超长时间事务和锁等待。接着查隔离级别有没有被某个连接池参数悄悄改掉,别只看数据库默认配置。然后把问题 SQL 单独拿出来做一次小范围重放,观察是否触发回滚、异常覆盖了提交。最后才去检查硬件、断电、主从切换这类底层因素。
实际上,前两步就能解决大多数线上问题。很多所谓“并发下数据不对”的情况,本质上是开发者在代码里没有正确开启事务,或是在异常分支里吞掉了异常后继续提交。只要能确认事务边界和锁行为,定位就成功了一大半。
7.3 最后分享一个小技巧
每当要评估一个数据库新特性或新版本对事务语义的影响,我喜欢在测试环境里用最小复现程序加随机故障注入的方式跑一轮冒烟。具体做法是:写一个脚本,持续开启并发事务执行若干条写入,每轮随机一个时间点杀掉数据库进程,重启后统计不变量是否被破坏。
这套“随机断电 + 一致性校验”的做法看起来糙,但它能暴露你在文档里读不到的问题:事务提交确认的边界、日志刷盘策略的真实效果、副本同步窗口内的数据差异。做完一轮,你对 ACID 在真实系统中的边界会有一个非常扎实的判断,而这种判断是任何博客和面经都给不了你的。
ACID 这四个字看着简单,背后却是数据库工程几十年的沉淀。真正把它理解透,需要从应用层的错误处理一路追到存储引擎的日志机制。希望这篇能把你的知识结构往前推一步:下次你写下一个事务时,不再只是把它当成一个“要么全成功要么全失败”的魔法开关,而是能感知到它背后那一整套精心设计的回滚、重放、锁、版本链和刷盘策略。真到了这一步,你离一个能扛住生产事故的开发者,就更近了一点。