
很多搞开发的朋友对ACID这四个字母都不陌生面试前背得滚瓜烂熟——原子性、一致性、隔离性、持久性张口就来。可真到了线上出问题的时候或者要解释为什么某个事务会回滚、为什么并发一高就报错、为什么主从数据对不上的时候很多人就只剩下一个模糊的印象ACID是数据库为了保证数据正确性做的一套机制。但这套机制到底解决了什么问题它四个特性各自的职责边界在哪里为什么少了其中任何一个都会出大事这篇文章就把ACID彻底掰开揉碎讲清楚。我不打算停留在“是什么”的层面而是从“到底为了啥”这个角度切入把每个特性的设计动机、底层实现、以及实际开发中怎么用它来排查问题都过一遍。文章适合正在学数据库原理的学生、刚接触后端开发的新人也适合写了好几年业务代码但没系统捋过这块的工程师。看完之后你再回头面对并发、事务、崩溃恢复这些问题思路会清晰很多。1. 数据库为什么非要搞出ACID这一套1.1 两个躲不开的物理现实先说一个最朴素的问题我们为什么需要事务为什么需要ACID因为真实世界有两个非常讨厌的物理现实躲都躲不掉。第一个是断电和崩溃。数据存在磁盘上但磁盘的写入不是瞬间完成的。你执行一条UPDATE数据库先在内存里改了数据页然后找个时机刷到磁盘。如果在这个过程中机器突然断电磁盘上的数据可能只更新了一半或者压根没更新。更麻烦的是如果一条业务操作要改好几条数据比如转账要扣A账户、加B账户机器崩在两步之间账就平不了了。第二个是并发。数据库几乎永远是被多个连接同时访问的。你改这一行数据的同时别人可能也在改同一行别人可能正在读这一行的旧值甚至可能读到你已经改了但还没提交的新值。如果不对并发访问做任何约束数据很快就乱成一锅粥。ACID就是冲着这两个问题来的。它本质上是一套契约数据库承诺只要你在事务里操作数据在某些边界条件下数据要么彻底改好要么压根不变并发访问时你看到的数据是符合某种隔离规则的一旦事务提交成功数据就不会因为崩溃丢失。1.2 ACID不是四个孤立要求而是一套组合拳很多人把ACID理解成四个互相独立的规则其实不是。这四个特性之间有非常强的依赖关系甚至可以说它们是一套组合拳。打个比方你把ACID想象成一个公司的四个岗位。原子性Atomicity是财务出纳所有操作必须整单完成不能出现支付了一半这种烂账。一致性Consistency是业务经理负责保证每一笔交易都符合公司制度和法规。隔离性Isolation是排队系统保证多个客户同时办理业务时互相看不到对方没办完的私下进度。持久性Durability是档案室一旦业务盖章办完合同就得锁进铁柜里不管后面机房着火还是停电都不能把记录弄丢。这四个岗位里真正干活的是原子性、隔离性、持久性这三个一致性更像一个验收标准——它不直接决定怎么做而是规定了事务执行完“必须长成什么样”。这个理解上的差异很重要后面我会专门展开讲一致性。2. 原子性与持久性先从“不丢数据、不留半截”说起2.1 原子性到底为了啥原子性的官方定义是事务中的所有操作要么全部成功要么全部失败回滚不存在中间状态。用户看到的只有提交和回滚两个结果没有“改了一半”这个说法。那它到底为了啥一句话为了在出错时能反悔。假设你要做一个订单创建流程SQL大概是这样的START TRANSACTION; UPDATE products SET stock stock - 1 WHERE product_id 100; INSERT INTO orders (order_id, user_id, product_id, amount) VALUES (12345, 99, 100, 199.00); INSERT INTO order_logs (order_id, action) VALUES (12345, create); COMMIT;如果数据库不提供原子性第三步INSERT刚执行完机器崩了库存扣了、订单建了但流水没记上。回头排查的时候会发现少了一条日志但谁也不知道是故意没写还是系统丢了。这种“半截”状态在业务上是灾难性的。原子性的实现靠的是undo日志回滚日志。在事务执行过程中每修改一条数据InnoDB都会在undo日志里记录一条对应的“反操作”如果是UPDATE就记录改之前的老值如果是INSERT就记录主键信息方便回滚时删除如果是DELETE就记录被删行原来的完整内容方便回滚时恢复。一旦事务执行到一半出错或者用户显式执行ROLLBACK数据库就顺着undo日志把每一条修改都恢复成原来的样子。这时候其他事务从头到尾就没看到过这些中间修改因为行锁一直没释放。2.2 一个崩溃恢复案例看undo很多人以为undo只在主动回滚时才用其实崩溃恢复时它更重要。举一个真实的场景。我在一个电商系统里排查过一个问题凌晨数据库被强制重启重启后发现有一批商品库存数据和前一天晚上的账务流水对不上。查下来原因就是有个批量更新的事务执行到一半操作系统把进程杀了。崩溃恢复重启后InnoDB启动时会做两件事把崩溃前还没提交的事务用undo日志回滚掉把崩溃前已经提交但数据页还没刷盘的事务用redo日志补上。这个“回滚未提交 补上已提交”的流程就是数据库的崩溃恢复。所以原子性不只是给应用层用的它更是数据库自己在崩溃恢复时的行为准则。没有undo数据库重启后根本不知道哪些未提交的修改要抹掉留给你的就是一堆脏数据。2.3 持久性到底为了啥持久性的核心承诺是只要事务COMMIT成功数据就必须永久落盘哪怕下一秒数据库崩溃、机器断电数据也不能丢。但这里有个矛盾。如果每次提交都把数据页直接刷到磁盘性能会差到没法用。因为一条UPDATE想改某个数据页这个页可能已经很久没动过了磁盘上对应的位置是随机的随机写磁盘是非常慢的操作。一次普通的事务提交如果等一个随机写完成响应时间直接上毫秒甚至十几毫秒高并发场景根本扛不住。怎么破数据库通用的解法是WALWrite-Ahead Logging预写日志先写日志后写数据。写入流程是这样的事务提交时先把本次修改追加到redo log重做日志里redo log是顺序写的顺序写磁盘非常快然后再去改内存里的数据页。内存里的数据页不用立刻刷盘可以慢慢攒着等后台线程在合适的时候批量刷。只要redo log已经落盘事务就算提交成功了。如果之后数据页还没刷盘机器就崩了重启后数据库从redo log里把这次修改重放一遍就能把数据页恢复到提交后的状态。2.4 为什么大家都选WAL你可能要问redo log真的不会丢吗会不会它自己还没落盘就崩了这就是WAL的关键设计事务提交成功的前提就是redo log已经落盘。MySQL的innodb_flush_log_at_trx_commit参数控制这个落盘策略。设为1时每次事务提交都必须把redo log刷到磁盘这是最安全的方式也是默认推荐值设为2时只刷到操作系统缓存每秒再统一刷一次盘性能更好但如果操作系统崩了可能会丢最多一秒的数据。有一段时间我用innodb_flush_log_at_trx_commit2在某些对安全性要求不高的业务上换性能后来还是改回了1。因为虽然2在很多场景下也能接受但真遇上主机断电丢失的恰好是最近一秒所有已提交事务这个后果在账务类系统里没法向业务解释。原子性和持久性一个管回滚一个管重放配合起来的效果就是提交的不丢、未提交的不留。这正好是一致性的两个重要前提。3. 隔离性并发场景的守护者3.1 没有隔离会看到什么怪象原子性和持久性解决的是一次事务在“时间维度”上的问题。可数据库永远同时跑着很多事务它们在“空间维度”上还会互相干扰。隔离性就是管这块的。如果完全没有隔离性并发执行事务时会看到三类经典怪象脏读。事务A改了某条数据但还没提交事务B读到了这个改动后的值然后A因为出错回滚了。B等于读到了一个现实中根本不存在的值拿着这个假数据去做业务判断后果可想而知。不可重复读。事务A在第一次查询时读到某个值事务B修改并提交了这条数据A再读一次发现值变了。A在同一个事务里同样的查询条件两次读到的数据不一致。幻读。事务A按条件查出一批记录事务B往这个范围内插入了新记录并提交A再查一次多出了几行。原本没有的记录凭空出现了像幻觉一样。这三个问题就是隔离性要解决的三个等级差异。脏读最严重不可重复读次之幻读相对最轻。3.2 四个隔离级别怎么选SQL标准定义了四个隔离级别对应不同强度的并发保护和性能代价隔离级别解决脏读解决不可重复读解决幻读并发能力读未提交READ UNCOMMITTED否否否最高读已提交READ COMMITTED是否否高可重复读REPEATABLE READ是是否中串行化SERIALIZABLE是是是最低这里有个细节容易搞混MySQL默认的隔离级别是REPEATABLE READ并且通过间隙锁在绝大多数场景下顺便解决了幻读问题这是InnoDB特有的实现策略而Oracle、PostgreSQL默认是READ COMMITTEDPostgreSQL在REPEATABLE READ下也能防止幻读。达梦、人大金仓这类国产数据库基本也是对标Oracle的行为。所以不要拿一套认知套所有数据库细节差别还挺大的。从开发者的角度我的建议很简单大多数业务系统用默认隔离级别就够了。READ COMMITTED和REPEATABLE READ在绝大多数场景下都能满足需求手动调成SERIALIZABLE一般只会带来严重的锁竞争和性能下降。真正需要思考的不是怎么调高隔离级别而是你的业务到底能不能接受不可重复读或幻读。4. 隔离级别背后的两条实现路线4.1 悲观锁路线隔离级别怎么实现核心思路有两条一条是加锁一条是MVCC。加锁的思路最直观读数据前先加读锁写数据前先加写锁。不同事务之间读写锁互斥、写写锁互斥谁先拿到锁谁先操作其他人排队。这就是悲观锁——我假定一定会有人来抢数据所以每次操作都先把门锁上。SERIALIZABLE级别就是典型的全加锁策略并发度最低但最安全。READ UNCOMMITTED基本不加锁所以能看到别人的未提交修改。READ COMMITTED和REPEATABLE READ的做法就复杂一些加锁和MVCC混合使用不同数据库差别还很大。4.2 MVCC路线MVCC多版本并发控制的思路更巧妙。它不给读操作加锁而是让读操作读取一个“历史快照”。InnoDB实现MVCC靠的是三样东西数据行上的隐藏列trx_id和roll_pointer、undo版本链、ReadView。每一行数据被修改时旧版本不会立刻删除而是变成一条undo记录通过roll_pointer串成一条版本链。事务读取数据的时候生成一个ReadView根据已提交事务的ID分布判断这条记录对当前事务是否可见。简单说一个事务在生成ReadView的那个时间点已经提交的数据是可见的未提交的或者之后才提交的就通过版本链往前找老版本。这样读操作既不会被写操作阻塞也不会读到别人提交前的脏值并发能力大大提升。这就是为什么REPEATABLE READ下同一个事务里反复SELECT结果总是一样的——因为这个事务从头到尾用的都是同一个ReadView相当于定在了一个时间快照上。这也是普通的SELECT不加锁就能做到高并发的根本原因。4.3 事务里的锁与死锁MVCC解决了快照读的问题但事务里还有一种特殊的读叫当前读比如SELECT ... FOR UPDATE、UPDATE、DELETE。这些操作必须读到最新已提交的数据而且必须加锁否则多个事务同时更新同一行就会互相覆盖。于是就有了死锁的可能性。两个事务各拿了一把锁又都在等对方手里的锁形成了环。前面提到用户搜索热词里有“数据库死锁”说明这几乎是每个上点规模的项目都会遇到的事。典型的死锁场景事务A先锁了订单表某行再想锁用户表事务B先锁了用户表那行再想锁订单表。两边各自持有对方需要的锁谁也不让谁数据库的死锁检测机制发现环之后会选一个代价较小的事务作为牺牲者强制回滚释放锁让另一个事务完成。实际开发里降低死锁概率的办法是让所有事务按照一致的顺序访问表和行比如约定先锁用户再锁订单不要今天这个顺序明天那个顺序。另一个是用好索引让UPDATE尽量只锁住必要的行而不是锁了一堆本来不用碰的行扩大锁范围。5. 一致性最容易被误读的一个字母5.1 一致性到底是谁说了算终于说到一致性了。这个C是ACID里最玄乎的一个也是网上解释最容易把人绕晕的。很多资料说一致性是“数据库从一个一致状态变到另一个一致状态”这话没错但等于没说。我一贯的理解是这样的ACID里的C其实是应用层对数据库提出的业务规则要求数据库本身并不创建一致性它只是给你提供了保证一致性可达成的能力。举个例子转账业务规定扣款和加款必须同时成功账户余额不能为负。这是业务规则不是数据库自己推导出来的。数据库能做的是给你原子性这两个操作要么都成要么都败、隔离性别的并发事务不会看到中间的失衡状态、持久性提交之后不会丢账然后再加上外键、唯一约束、CHECK约束这些“强制条款”尽量阻止你写入不合规的数据。也就是说一致性更像一份验收清单而原子性、隔离性、持久性是施工团队。施工团队干活靠谱验收才能通过。5.2 一致性如何与AID相互配合理解了上面这一点你就知道为什么网上总说“ACID里AID是手段C是目标”了。拿一个具体流程来说。如果你往主键重复的行里插入数据唯一索引直接拒绝违反约束回滚报错——这是一致性的强制规则在起作用。如果你的事务逻辑写到一半发现余额不够主动抛出异常并触发回滚这是靠着原子性保证不会产生“扣了款却说没扣”的烂账。如果两个事务同时改同一个账户余额隔离性保证改动是串行生效的不会一加一减互相覆盖导致金额莫名变少。所以你看一致性不是某个具体技术实现的它是一套结构性保障的结果。理解了这层关系面试时被问到ACID时就不会只背四条定义了。顺便提一下ACID的一致性和分布式系统里经常提的CAP一致性不是一个东西。CAP说的是多节点之间数据副本的一致性是分布式系统的命题ACID的C说的是单库、单事务内数据与业务规则的符合程度。两者一个横向一个纵向混在一起说基本必然是错的。6. 实战中怎么用好ACID这套认知6.1 别在事务里做慢操作知道ACID的原理之后再回头看日常开发里的事务使用习惯有些问题是真的一眼就能看出来。最常见的问题就是在事务里做网络请求、调用外部API、等待用户输入。一个事务的存活时间越长它持有的锁就越久其他事务阻塞的概率越大整个数据库的并发能力就越差。我接手过一个老系统里面有个接口在UPDATE之后调第三方支付回调网络超时设了30秒结果这30秒里相关的数据行全是锁死的一到业务高峰就报警。事务的基本纪律是快进快出。只把必须保证原子性的那几个SQL放进事务其他耗时操作一律挪出去。6.2 死锁排查思路真遇到死锁了怎么查MySQL里有个很实用的方法SHOW ENGINE INNODB STATUS;输出里的LATEST DETECTED DEADLOCK部分会把你最后一个死锁涉及的两条SQL、持锁和等锁的索引、事务ID都列得清清楚楚。根据这个信息一般就能定位问题是不是两个事务加锁顺序不一致是不是某个SQL没走索引导致锁了太多行是不是隔离级别下间隙锁和插入锁冲突了我电脑上还有一段经验是建一张information_schema.innodb_trx的监控表定时记录下来长时间未提交的事务看它们的SQL和耗时基本能提前发现死锁和长事务的前兆。6.3 隔离级别误用的坑另一个高频问题是对隔离级别的无脑调节。有人为了让业务“更稳”把整个数据库调成SERIALIZABLE然后发现并发性能暴跌大量请求堆积在锁等待上。也有人为了性能把隔离级别调到READ UNCOMMITTED结果报表全是脏数据对账怎么都对不上。我的建议是先确实搞清楚业务对数据一致性的容忍度再动手。一般的读多写少场景默认REPEATABLE READ没有任何问题如果确实需要更低的锁开销先分析你的核心查询能不能接受不可重复读和幻读能接受再考虑降级。数据库的隔离级别是全局配置影响范围大调之前一定要想清楚收益和代价。最后再分享一个我自己实际项目的体会。接手一个老系统时经常看到状态位在业务代码里改来改去数据库层面既没有唯一约束也没有外键一切“一致性”全靠代码自觉。后来我在关键表上补了唯一索引把核心更新改成带条件更新UPDATE ... WHERE status待处理事务短暂且清晰很多脏数据和并发问题就再没出现过了。ACID不是一个抽象概念它落到工程上就是这些具体的设计决策。把这些原理吃透写代码的时候心里会踏实很多。