数据库如何保证数据不出错?事务、WAL与隔离级别的底层机制
2026/9/4 20:37:57 网站建设 项目流程

你每天写 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,也就是先写日志、再改数据。它的核心顺序可以简化成下面几步:

  1. 事务开始后,每执行一次数据修改,先把这一次修改的“必要信息”追加到日志里。日志记录里至少包含:修改的是哪个数据页、改动前后需要知道什么信息,才能够在恢复时重放或撤销。
  2. 内存里的数据页被更新。
  3. 提交事务时,把“事务已提交”这个标记也写入日志,并确保它已经真正落盘。
  4. 数据库再向客户端返回“提交成功”。
  5. 脏页在之后某个合适的时机刷到磁盘。

为什么顺序必须是“日志先落盘,然后才返回成功”?因为日志是顺序追加写,代价远小于随机刷数据页;更重要的是,只要提交标记落盘了,哪怕脏页完全没刷出去,恢复时也能从日志里把这次提交重放出来。反过来,如果先刷数据页、再写日志,崩溃时你可能刷了一半页面,日志还不完整,根本没法判断现场该按哪个版本来恢复。

也正因为如此,未提交事务的数据页可能已经被后台刷盘到磁盘。恢复时,系统看到这个事务没有提交标记,就知道必须把它的改动全部撤销掉。撤销的依据,就是那些让旧值可以被找回的撤销信息。

这里有一个容易误解的细节:不是“事务没提交,内存数据就不会刷盘”。数据库的内存管理考虑的是整体性能和空间压力,不一定关心某个脏页属于已提交还是未提交事务。缓冲区紧张时,哪怕一个事务还没提交,它的脏页也可能先被写出去。所以崩溃恢复不能只看磁盘上的数据页,而必须结合日志判断每个事务的最终命运。这个机制,比大多数人想象的更严谨,也更复杂。

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 把“正确”两个字先定义清楚

排查数据问题的第一步,不是去看数据库日志,而是先定义“正确”到底是什么。

用户说“数据错了”,要分清楚是哪一种错:

  • 是读到旧值,还是确实写入错误?
  • 是多了一行,还是少了一行?
  • 是金额数字不对,还是更新丢失?
  • 是某个交互场景里看到的界面状态不对,还是底层表记录已经不对?

这个定义至关重要。因为“读到的数据不对”和“写入的数据不对”,排查路径完全不一样。前者大概率要查隔离级别、快照时机、主从延迟;后者要查事务提交结果、唯一约束、锁

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询