数据库如何保证数据不出错?事务、WAL与隔离级别的底层机制 📅 发布时间:2026/9/4 20:38:04 👁 浏览次数: 你每天写 SQL可能很少停下来想一个问题关系型数据库到底是怎么保证数据不出错的前段时间帮一个团队看库存对账逻辑核心代码不复杂先查库存再更新扣减外面套一层事务。聊到一半有人随口问了一句如果 COMMIT 之前数据库进程正好崩溃那条 UPDATE 到底生效没有大多数人第一反应是没生效事务会自动回滚。这个结论当然没错但它只是 ACID 给我们的“外部承诺”。真正有意思的是下一个问题如果内存里的数据页已经改了甚至已经刷了一部分到磁盘而事务日志还没写完整数据库重启之后是凭什么判断“这个事务应该被抹掉”的能答上这个问题的人才是真正理解关系型数据库可靠性的人。我的判断是数据库对“不出错”的保证从来不是某一种单独机制的结果而是一层叠一层的协议组合。事务定边界日志保可恢复并发控制管争抢隔离级别是你在一致性和性能之间做的取舍。只要少理解一层你面对线上数据问题时就很可能把方向判断错。所以这篇文章不打算复述 ACID 的教科书定义而是想沿着“数据到底可能怎么出错”“数据库靠什么把它挡住”“哪些地方它挡不住”这条线把关系型数据库的可靠性机制拆开讲一遍。1. 数据库要挡的“错”不是一个错而是四类不同的事故1.1 先看清数据出错的方式比你想的多很多人提到数据出错首先想到的是“崩溃之后数据丢了”。实际上在真实系统里数据变错至少有四类不同来源第一类是物理层崩溃。进程突然被杀、机器断电、磁盘损坏、文件系统只写入了一半数据。这类问题最接近底层普通应用代码根本处理不了只能靠数据库自身的日志和恢复机制兜底。第二类是并发交错。两个事务同时读同一行同时更新同一个账户一个人扣款另一个人也在扣款执行顺序不同结果就不一样。这里不是数据库坏了而是并发操作把数据覆盖了。第三类是多步流程的部分成功。一个业务操作通常不是一条 SQL而是先插入订单、再更新库存、再给会员累积积分。执行到第二步失败前面那一步已经生效了业务数据就会处于中间态。第四类是业务规则被绕过。订单状态从“待支付”直接变成了“已完成”中间省略了“已支付”账户余额被扣成负数同一条消息被重试了两次生成了两笔转账。这类问题没有崩溃也没有锁竞争纯粹是数据和业务规则不一致。想明白这四类你就能理解数据库的设计目标它要处理的是前三类的一部分以及第四类里它能用约束表达的那一小部分。它并不能保证“业务的最终状态一定符合你的想象”它保证的是“在我划定的规则和边界内状态一定可解释、可恢复、不互相污染”。1.2 为什么普通文件写入挡不住这些事有人会问我不就是往磁盘里写个文件吗为什么非要让数据库用一套复杂的日志和事务机制来管理原因在于普通文件写入缺少两个关键能力原子边界和并发视图。你往文件里write()一串数据写到一半断电文件尾部可能是一个残缺的半块数据。你可以在应用层用“临时文件 rename”模拟单文件的原子替换但一旦操作需要同时更新多个文件、多张表或者需要让别的线程/进程在操作过程中读到一致状态文件系统本身不会给你任何协议。谁先写、谁后写、读的人会不会看到中间状态都需要你自己协调。数据库把这些问题收拢到一个统一模型里你声明一个事务边界边界内的操作要么整体生效要么整体失效并发访问通过锁和版本控制处理机器崩溃后通过日志和恢复算法把状态还原到某个一致点。这套机制并不是玄学它是一套工程协议。1.3 事务和 ACID 到底承诺了什么ACID 这四个字母每个人都会背但容易背成一串抽象名词。换成工程语言可以这样理解原子性一个事务里的操作要么全部生效要么全部不生效。数据库通过撤销日志和恢复流程保证。一致性事务执行前后数据库必须从一个符合规则的状态到另一个符合规则的状态。注意数据库能守住的“规则”是它认识的那些约束——类型、非空、唯一键、外键、自定义约束。剩下的业务一致性仍然要应用层配合。隔离性两个事务并发执行时它们不能互相看见对方未提交的中间状态或者至少要按你选择的隔离级别来保持可见性。持久性事务提交成功后哪怕机器立刻断电数据也不能丢。这是靠提交前把必须落盘的信息先落盘来保证的。注意这四件事并不是四个独立模块。原子性要治“做一半”持久性要防“丢了”两者本质上都靠日志隔离性靠锁和多版本一致性靠约束和执行器对规则的检查。它们交织在一起构成了数据库可靠性最核心的那层壳。2. 崩溃之后数据不散架靠的不是“马上写盘”而是“先写日志”2.1 数据库为什么不能每次都立刻把数据改到磁盘如果数据库每执行一条 UPDATE就把对应磁盘页面立刻覆盖掉崩溃恢复确实会简单很多。但问题有两个性能和部分写入。机械硬盘的随机写很慢SSD 虽然有改善但频繁小数据量同步写仍然代价很高。为了让性能可控数据库通常会在内存里维护一份数据缓存也就是常说的缓冲池。数据页先读进内存事务在内存页上修改之后再由后台线程择机把脏页刷回磁盘。这意味着一个严重问题事务提交成功之后“已提交”这个事实和“数据页真的落盘”并不是同一时刻发生的。内存里的脏页可能还没刷盘机器就断电了也可能脏页已经刷了一半只写了半个页面。所以数据库必须引入另一层机制让“事实”先以一种更廉价、更容易恢复的方式保存下来。这就是日志的由来。2.2 WAL提交之前先保证“能恢复”主流关系型数据库几乎都采用了 WAL也就是先写日志、再改数据。它的核心顺序可以简化成下面几步事务开始后每执行一次数据修改先把这一次修改的“必要信息”追加到日志里。日志记录里至少包含修改的是哪个数据页、改动前后需要知道什么信息才能够在恢复时重放或撤销。内存里的数据页被更新。提交事务时把“事务已提交”这个标记也写入日志并确保它已经真正落盘。数据库再向客户端返回“提交成功”。脏页在之后某个合适的时机刷到磁盘。为什么顺序必须是“日志先落盘然后才返回成功”因为日志是顺序追加写代价远小于随机刷数据页更重要的是只要提交标记落盘了哪怕脏页完全没刷出去恢复时也能从日志里把这次提交重放出来。反过来如果先刷数据页、再写日志崩溃时你可能刷了一半页面日志还不完整根本没法判断现场该按哪个版本来恢复。也正因为如此未提交事务的数据页可能已经被后台刷盘到磁盘。恢复时系统看到这个事务没有提交标记就知道必须把它的改动全部撤销掉。撤销的依据就是那些让旧值可以被找回的撤销信息。这里有一个容易误解的细节不是“事务没提交内存数据就不会刷盘”。数据库的内存管理考虑的是整体性能和空间压力不一定关心某个脏页属于已提交还是未提交事务。缓冲区紧张时哪怕一个事务还没提交它的脏页也可能先被写出去。所以崩溃恢复不能只看磁盘上的数据页而必须结合日志判断每个事务的最终命运。这个机制比大多数人想象的更严谨也更复杂。2.3 崩溃现场推演三类情况一个恢复目标我们把“崩溃瞬间处在哪个位置”拆开看就能理解日志和恢复流程为什么这么设计事务执行到一半还没有提交就崩溃。此时磁盘上可能残留这个事务改过的页面。恢复流程找到这个未提交事务用撤销信息把相关页面恢复到事务开始前的状态。事务已经提交提交标记也落盘了但数据页还没刷到磁盘。恢复流程从日志里找到这个事务把它做的修改重新执行一遍。这就是常说的重放。日志写了一半提交标记都还没写完就崩溃。这个事务在这台机器眼里根本没有成功提交。恢复时它会被当作未提交事务处理数据页上的修改会被撤销。可以看到无论崩溃落在哪个窗口恢复结果都是同一句话提交过的事务完整生效没提交过的事务干净消失。再补充一个常被忽略的细节部分页面写入。一个数据页通常是几 KB 甚至更大机器断电可能导致页面只写了开头部分磁盘上留下一个“撕裂页”。如果日志记录的信息不足以重建整个页面恢复也会遇到麻烦。所以很多数据库还会额外处理半页写问题比如双写缓冲或者在日志里保存足够恢复的信息。这些设计都在同一件事上做文章崩溃现场永远可能是残缺的数据库必须能用其他信息把残缺补回来。2.4 一个和你日常运维强相关的例子以 MySQL 为例很多 DBA 对innodb_flush_log_at_trx_commit这个参数很熟悉。它设置的就是“事务提交时日志要刷到什么程度”。设置为 1每次提交都把日志从内存刷到磁盘持久化这是最常见的安全配置。设置为 0 或 2提交延迟会下降但代价是数据库或操作系统崩溃时已经返回“提交成功”的事务也可能丢失最近一段时间的数据。事实是事务只要在崩溃中丢失就违背了它对调用方做出的持久性承诺。你可以为性能选择更低的值但必须清楚自己在牺牲什么。如果你的库跑在云上或由托管平台维护通常默认已经按安全标准配置好了。网上那些“一条参数让事务提交变快”的优化文章很多就是在教你降低持久性一定要先读官方文档再结合自己的业务容忍度判断。这里更像是一个通用处理思路具体参数和行为要以你实际使用的版本和部署环境为准。注意写日志的 fsync并不等于应用程序在日志里打印一行“操作成功”。数据库的崩溃恢复只认它自己那条日志的持久化状态不认业务日志。3. 并发下怎么不出错隔离级别和 MVCC 才是真正的战场崩溃恢复处理的是“单线程时间线上的中断”但真实数据库永远要面对多个事务同时操作同一份数据。这时候要防的错不是断电而是事务之间的互相干扰。3.1 三个经典异常脏读、不可重复读、幻读用一顿饭来类比如果一个事务读到了另一个还没提交的事务的修改而那个事务之后回滚了这个读到的值就是“从没存在过”的值这就是脏读。如果一个事务先查了一行数据过一会儿再查同一行却发现值被另一个已提交事务改了这就叫不可重复读。站在事务内部看同一行在前后两次读取中长不一样了。如果同一个查询条件第一次查出 10 条记录第二次查出 11 条多出来那条是别的事务插进来的这就叫幻读。它和不可重复读的区别在于不可重复读关心的是同一行变了幻读关心的是结果集的范围变了。这三个异常本质上都在问同一个问题你允许一个事务运行期间到底被外界的哪些变化影响3.2 隔离级别不是越高越好而是一把有代价的尺子如果所有事务都串行执行互相之间自然零影响数据最安全但这等于放弃了数据库最重要的并发能力。所以数据库给了你几档选择隔离级别允许脏读允许不可重复读允许幻读READ UNCOMMITTED可能可能可能READ COMMITTED不会可能可能REPEATABLE READ不会普通读通常不会取决于实现SERIALIZABLE不会不会不会实际使用时不要把这张表当成绝对真理。不同的数据库对同一档隔离级别的实现细节差很多。比如 MySQL InnoDB 的 REPEATABLE READ 在普通快照读下能挡住幻读但在某些加锁的当前读下仍然需要特殊机制而 PostgreSQL 的 REPEATABLE READ 又有一套自己的行为。最稳妥的做法是以你自己使用的数据库文档为准而不是凭名字猜语义。3.3 锁和 MVCC 是怎么把隔离级别落地的隔离级别的底层实现可以从两条主线理解锁和多版本并发控制。锁的思路比较直觉读写同一份数据前先申请锁。读锁允许大家一起读写锁只允许一个人持有。冲突发生时后来者等待如果两个事务相互等待就形成死锁数据库会选一个事务回滚让另一个继续走。多版本并发的思路则完全不一样。它不会在更新一行时直接覆盖旧值而是把这行数据的多个版本保留下来形成一个版本链。读事务根据自己能看到的时间点选择看到一个合适的旧版本而不是被写操作挡住。这就是为什么很多数据库里“普通的读”不会阻塞“写”写也不会阻塞读——双方各看各的。把两者结合后隔离级别就变成了一个“读什么时候定格、写什么时候加锁”的配置。READ COMMITTED 里事务内每条语句开始时重新取一个快照所以后一条语句能看到别的事务刚提交的修改而 REPEATABLE READ 的普通读通常会把快照固定住让整个事务里的查询保持一致。注意这里说的是普通快照读。如果代码里出现了SELECT ... FOR UPDATE或者执行 UPDATE、DELETE 这类语句数据库走的往往是“当前读”读取的是最新已提交版本并且要给相应行加锁。快照读和当前读分开理解是避开很多并发坑的前提。3.4 最常见的并发坑先查出来再算一算再写回去日常开发里最经典的错误是下面这个模式-- 不推荐应用层先查余额再在内存里减掉 10再写回去 SELECT balance FROM account WHERE id 100; -- 得到 100 -- 应用内计算 balance - 10 UPDATE account SET balance 90 WHERE id 100;如果两个请求同时执行它们可能都查到 100然后都把余额写成 90。你以为扣了两次 10 块实际上只扣了一次。这就是丢失更新。更稳的写法是用数据库的行锁来串行化UPDATE account SET balance balance - 10 WHERE id 100 AND balance 10;这条语句在数据库里是原子地“读旧值、减 10、写新值”并且会先拿行锁。第二个事务必须等第一个提交或回滚之后才能执行。如果更新结果影响的行数为 0说明余额不足或者记录不存在应用层再做业务判断。我第一次在实际项目里遇到类似问题是因为代码为了“逻辑清晰”先把用户余额查出来渲染到页面上等用户下单时又用页面里那个旧值直接覆盖。数据库本身没做错什么错的是应用层把一个本该原子的“读取-计算-更新”拆成了三步。4. 数据库兜底之后不等于整条业务链上的数据就正确关系型数据库的可靠承诺是有边界的。它能把“单个事务内部”的事情处理得非常干净但事务之外还有大量和正确性相关的环节需要应用层自己补齐。4.1 约束就是数据库免费送你的正确性先做一件成本很低的事把能用约束表达的规则交给数据库。主键保证行唯一。唯一键保证“同一张订单不会被插两次”这类业务规则。非空约束保证关键字段不会变成 NULL。CHECK 约束可以限制字段的取值范围虽然不同数据库支持程度差别很大。外键能维护表与表之间引用关系的完整性但在分库分表场景下通常不再可用。很多团队为了省事把唯一性校验全部放在应用层理由是“数据库加唯一索引会影响性能”。我的判断是除非你有明确的压测数据证明唯一键成了瓶颈否则先用数据库约束兜底比在应用层写一堆先查后插的判断可靠得多。先查后插天然有并发窗口两个请求同时发现“不存在”然后同时插入最终还是靠数据库的唯一索引来拦。4.2 转账和扣库存同一个模型的不同写法一个典型的需求是“扣库存并防止超卖”。不推荐的写法是SELECT stock FROM product WHERE id 100; -- 应用判断 stock 0 UPDATE product SET stock stock - 1 WHERE id 100;这段代码本身没问题但真正健壮的版本应该尽量把判断收敛进数据库语句UPDATE product SET stock stock - 1 WHERE id 100 AND stock 1;然后检查受影响行数。如果是 1扣减成功如果是 0可能是库存不足也可能是商品不存在。你还要再查一次商品是否存在才能区分但至少库存不会在并发下变成负数。同样的思路也适用于状态流转。一个订单要从“待支付”到“已支付”可以直接写UPDATE orders SET status PAID, paid_at NOW() WHERE order_id ? AND status PENDING;只有能匹配到“当前还是待支付”的订单查询才会更新成功。用这种方式把状态机守住在数据库层应用层就少了很多竞态判断。4.3 幂等性数据库管不了“客户端拿到结果之前就重试”这里要讲一个很隐蔽的坑也是我经常在团队里强调的。一个事务已经提交成功但服务进程在返回响应之前崩了或者网络超时了。客户端没有收到响应它通常会重试。第二次请求如果又执行了同样的扣款或下单操作就会产生重复数据。这时候问题不在数据库。数据库正确执行了两次独立事务两次都成功了。但从业务角度看用户只应该被扣一次款、只应该生成一笔订单。解法不是关掉重试而是让请求具备幂等性。最简单的做法是业务表里加一个幂等键比如request_id给它建唯一索引。处理请求时先插入或关联这个 request_id重复请求会因为唯一键冲突而失败。一个更常用的模式是在事务里把业务数据和一条“处理记录”一起写入利用唯一键约束保证同一个消息只被处理一次。很多消息队列的“至少一次投递”语义都必须配合消费端的幂等表才能变成业务上的“恰好一次”。注意事务提交成功不等于整段调用链成功。凡是可能触发重试的接口都要在应用层显式设计“重复请求会发生什么”。4.4 不要在事务里做慢操作更不要阻塞其他事务事务并不会在“业务方法结束”时自动结束它只会在 COMMIT 或 ROLLBACK 时释放锁。如果事务里夹带了 HTTP 请求、调用别人接口、等待人工确认这期间所有被它碰过的行都会被锁住。实际场景里最常见的问题是一条库存更新包在一个事务里事务里还要调用远程库存中心结果远程服务慢事务迟迟不提交其他订单的扣库存请求全部被卡住数据库连接池被占满整个系统跟着雪崩。我个人在处理这类问题时的原则是事务里只放必须一致的数据库操作把外部调用移出去。如果确实需要先调用外部再更新本地可以把本地状态先改成“处理中”事务保存一个中间状态并提交等外部调用成功后再开启新事务把状态流转到“成功”。整套链路的一致性要靠补偿、对账或状态机来兜而不是靠一个跨 SQL 和远程调用的大事务。5. 真发现“数据不对”时建议按这个顺序排查团队一旦碰到“库存少了一笔”“用户余额不对”“订单状态跳变”第一反应往往是想甩锅给数据库。但我的经验是数据库自己在没有硬件故障、没有参数乱调的情况下悄悄把已提交数据改错的情况极少。绝大多数问题都出现在隔离级别、事务边界、重试语义和日志时序上。5.1 把“正确”两个字先定义清楚排查数据问题的第一步不是去看数据库日志而是先定义“正确”到底是什么。用户说“数据错了”要分清楚是哪一种错是读到旧值还是确实写入错误是多了一行还是少了一行是金额数字不对还是更新丢失是某个交互场景里看到的界面状态不对还是底层表记录已经不对这个定义至关重要。因为“读到的数据不对”和“写入的数据不对”排查路径完全不一样。前者大概率要查隔离级别、快照时机、主从延迟后者要查事务提交结果、唯一约束、锁