什么是崩溃一致性?

设计背景

在涉及状态持久化的系统中(如数据库、元数据服务、资产系统、任务状态系统等),写操作不可避免地面临以下风险:

  • 进程异常退出
  • 主机宕机或掉电
  • 磁盘写入中断
  • 写入顺序被打乱

如果缺乏专门设计,系统可能在崩溃后处于一种历史上从未合法存在过的中间状态,导致:

  • 数据损坏不可解析
  • 业务状态不一致(如部分更新生效)
  • 无法自动恢复,只能人工介入

因此,需要一种崩溃一致性(Crash Consistency)保障机制,确保系统在异常情况下仍能恢复到一个合法、可解释的状态。

👉 Crash Consistency(崩溃一致性):系统在“写入过程中崩溃(掉电 / 宕机)时,如何保证数据不损坏、不自相矛盾”

常见场景

  • 钱扣了,余额没加
  • 索引更新了,数据没写
  • 指针改了,数据还没落盘

这在掉电时是最容易发生的。

设计目标

本方案的设计目标如下:

  1. 防止写入中间态被持久化
  2. 支持系统崩溃后的自动恢复
  3. 尽量降低对写性能的影响
  4. 方案可工程化落地,复杂度可控

3类崩溃问题

概述

从底层视角看,存储系统在崩溃时主要面临三类问题:

  1. 部分写入(Partial Write)
    数据页只写入了一半,导致数据结构损坏。
  2. 写入顺序失序(Reordering)
    实际落盘顺序与程序逻辑顺序不一致。
  3. 原子性破坏(Atomicity Violation)
    一个逻辑操作仅部分生效。

① 部分写入(Partial Write)

一页 / 一个 block 只写成功了一半

  • 磁盘页被撕裂
  • 校验失败
  • 数据不可解析
方案 是否解决
WAL
双写 ✅(主要就是干这个)
CoW

② Reordering(写顺序被打乱)

你以为是 A → B
实际落盘顺序是 B → A

方案 是否解决
WAL ✅(顺序日志 + fsync)
双写
CoW ✅(指针原子切换)

③ Atomicity(原子性破坏)

一个操作要么全成,要么全不成

方案 是否解决
WAL
双写
CoW ✅(天然)

3种解决方案

Write-Ahead Logging(WAL)

方案说明

WAL 的核心思想是:

在修改数据之前,先将“将要发生的操作”可靠记录下来。

通过保证日志先于数据落盘,即使在任意时刻发生崩溃,也可以基于日志对系统状态进行回放或回滚。

工作流程

1
2
3
4
1. 将操作追加写入日志(顺序写)
2. fsync 日志,确保持久化
3. 修改数据页(随机写)
4. 后台 checkpoint / 刷盘

崩溃恢复行为

  • 日志未落盘:操作视为未发生
  • 日志已落盘、数据未完成:通过 redo 恢复
  • 日志与数据均完成:状态一致

优点

  • 能完整表达事务语义
  • 掉电恢复逻辑清晰
  • 顺序写为主,性能较优
  • 工程成熟度高

缺点

  • 实现复杂度较高
  • 需要日志管理与回收机制

适用场景

  • 数据库事务系统
  • 消息队列
  • 元数据 / 状态型服务

双写(Double Write)

方案说明

双写机制通过冗余写入来防止单次写入失败导致的数据页损坏。

其核心假设是:

一次写入不可靠,但两次写入至少能保留一份完整数据。

典型流程(以 MySQL 为例)

1
2
3
1. 数据页写入 doublewrite buffer
2. fsync
3. 再写回原始数据页位置

崩溃恢复行为

  • 若数据页损坏,可使用 doublewrite buffer 中的完整页进行修复

优点

  • 有效解决页级部分写问题
  • 对现有存储结构侵入较小

局限性

  • 不具备事务语义
  • 无法描述“操作是否完成”
  • 不支持 redo / undo

适用场景

  • 作为 WAL 的补充机制
  • 解决页级物理损坏问题

Copy-on-Write(CoW)

方案说明

CoW 通过写新版本、保留旧版本的方式,彻底避免覆盖写入,从设计上杜绝中间态。

写入流程

1
2
3
1. 新数据写入新位置
2. fsync 新数据
3. 原子性更新根指针 / 元数据

崩溃恢复行为

  • 指针未更新:仍指向旧版本
  • 指针更新完成:新版本生效

优点

  • 天然具备崩溃一致性
  • 不需要 redo / undo

缺点

  • 写放大严重
  • 数据碎片化
  • 实现复杂,对内存与结构设计要求高

适用场景

  • 文件系统
  • 不可变数据结构
  • 快照优先的存储系统

方案对比与选型依据

维度 WAL 双写 CoW
解决层级 事务 / 语义 页完整性 结构一致性
掉电一致性
事务支持 完整 需额外设计
写性能
工程复杂度
典型使用 MySQL InnoDB、PostgreSQL,MongoDB的WiredTiger InnoDB doublewrite ZFS、LMDB
  • WAL 是事务型系统中最通用、最成熟的一致性方案,在“性能 / 语义 / 复杂度 / 可扩展性”上是最均衡解,可以用工程手段控制复杂度
  • 双写适合作为底层页安全补丁,治标不治本
  • CoW 设计太重,不适合高更新 OLTP,更适用于从架构层设计的一致性系统

如果你在 自己设计系统

  • 要事务 + 高写入 → WAL
  • 只是怕页损坏 → 双写
  • 不可变数据 / 快照优先 → CoW
  • 配置 / 元数据系统 → WAL 或 CoW

WAL思想的使用场景

WAL(Write-Ahead Logging)是一种用于保证系统在崩溃或掉电情况下仍能恢复到一致状态的通用设计思想。
它并不局限于数据库,而是一种适用于所有“需要持久化状态、且不能接受中间态”的系统的基础机制。WAL 的核心原则只有一句话:在修改任何持久化数据之前,必须先把“将要发生的操作”可靠地记录下来。 只要这条日志已经落盘,即使系统随后发生崩溃,也可以通过重放或回滚日志,把系统恢复到一个合法状态。

WAL(Write-Ahead Logging)的思想,凡是满足下面三点之一,就值得用:

1️⃣ 有“写状态”
2️⃣ 不能接受中间态 / 数据损坏
3️⃣ 需要崩溃后自动恢复

满足任意两点,WAL 基本就是最优解

从工程角度看,WAL 之所以成立,是因为日志比真实数据更容易被可靠写入。 日志通常是顺序追加、数据量小、结构简单,可以高效地 fsync 到磁盘;而真实数据往往是随机写入、体积大、结构复杂,更容易在掉电时出现页撕裂或写入顺序错乱。WAL 的设计并不试图让“数据写入本身变得绝对安全”,而是通过先保证日志的安全性,把恢复的可能性提前锁死。只要系统存在不可丢失的状态,并且不能接受写到一半的结果,那么 WAL 就应该被认真考虑;而一旦系统本身不关心崩溃恢复,WAL 就不再是必需品。

WAL 在哪里是“必选项”(已经内建)

1️⃣ 数据库(你最熟的)

数据库系统中,WAL 是存储引擎层面的基础机制,而不是一种可选优化。无论是 MySQL InnoDB、PostgreSQL 还是 MongoDB 的 WiredTiger,引擎都会强制要求:事务提交之前,相关的 redo 日志必须先落盘。数据库并不要求数据页在提交时已经写入磁盘,只要日志是完整的,就可以在重启时通过日志重放恢复数据。这意味着,在数据库语境下,WAL 是事务原子性和持久性的根基,而不是开发者“建议采用”的最佳实践。

MySQL的redo log详解:MySQL三大日志(binlog、redo log和undo log)详解 | JavaGuide

  • MySQL / PostgreSQL / Oracle
  • MongoDB(WiredTiger)
  • SQLite

作用:

  • 事务原子性
  • 掉电恢复
  • 崩溃一致性

👉 在这里:
WAL = 存储引擎的一部分,不可绕过

2️⃣ 消息队列 / 日志系统

消息队列和日志系统中,WAL 以另一种形式存在,但解决的是同一类问题。以 Kafka 为例,消息首先被顺序追加到 commit log 中,这个 commit log 本质上就是一条 WAL。只要消息已经写入日志文件并持久化,即使 Broker 崩溃,消息也不会丢失。消费者的位点、生产者的确认语义,都是围绕这条日志构建的。这里的 WAL 不一定叫 “redo log”,但它承担的职责完全一致:先保证事实被记录,再考虑派生状态。

  • Kafka
  • Pulsar
  • RocketMQ

WAL 的形态:

  • 顺序 append 的 commit log

解决的问题:

  • 消息不丢
  • 顺序可恢复
  • at-least-once / exactly-once

👉 Kafka 本质上就是一个 WAL-first 系统

WAL 在哪里是“最佳实践”(但你要自己实现)

3️⃣ 自研存储 / KV / 状态服务

自研的状态型系统中,WAL 通常以“操作日志”或“事件日志”的形式出现。例如配置中心、元数据服务、资产关系系统、任务调度系统等,只要系统内部存在“状态变更”,并且希望在进程崩溃后自动恢复,就可以引入 WAL 思想:先把状态变更写入日志,再更新内存状态或当前视图,最后通过快照或异步刷盘固化结果。这类系统往往并不需要完整的数据库级事务,但 WAL 能以较低成本提供足够强的崩溃一致性保障。

典型场景:

  • 配置中心
  • 资产系统(你之前做的那种)
  • 小型元数据服务

用法:

1
2
3
append log → fsync
apply to memory
async flush snapshot

解决:

  • 掉电
  • 进程 crash
  • 滚动升级

4️⃣ 分布式一致性组件

分布式一致性系统中,WAL 是共识算法落地的基础载体。以 Raft 为例,每个节点都会先把日志条目追加到本地磁盘,再参与提交与状态机应用。Raft 本身解决的是“多副本一致性”问题,而 WAL 解决的是“单节点崩溃后如何恢复状态”的问题。两者关注的层级不同,但 WAL 是共识系统能够正确工作的前提条件。

  • etcd / Zookeeper / Consul

WAL 的角色:

  • Raft log
  • 状态机 replay

👉 共识 ≠ WAL,但 WAL 是共识的落盘载体

5️⃣ 文件系统 / 存储引擎

  • ext4 / XFS(journal)
  • 自研对象存储元数据

解决:

  • inode / 元数据一致性
  • 文件系统修复时间

WAL 在业务系统里的“隐形用法”(很多人忽略)

6️⃣ 任务 / 工作流系统

比如:

  • 定时任务
  • 状态机流转
  • 审批流程

用 WAL 思想你会这样做:

  • 状态变更先写事件表
  • 再更新当前状态

👉 Event Sourcing = WAL 思想的高级形态

7️⃣ 异步系统 / 补偿系统

  • 订单创建
  • 资金流水
  • 资产变更

WAL 化表现:

  • 操作日志表
  • outbox 表
  • binlog

什么时候不适合用 WAL?

需要明确的是,WAL 并不是在任何场景下都值得使用。如果系统是纯缓存型、允许数据丢失、状态可以通过上游重算,或者所有写入都是全量覆盖式的,那么引入 WAL 只会增加复杂度而没有实际收益。WAL 的价值只在于:系统真的在乎“崩溃之后还能不能自动回到正确状态”。

不适合 WAL 的情况:

  • 纯缓存(Redis cache-only)
  • 可丢数据(metrics、trace)
  • 只读系统
  • 全量覆盖写(例如每天重算)

fsync 的原理

fsync 的核心作用是确保操作系统缓存中的数据被真正写入到物理存储介质上。
在现代操作系统中,文件写入通常是先写入内存页缓存(page cache),而不是直接写入磁盘。这样做是为了性能,因为内存读写速度远快于磁盘。当程序调用 write() 或者文件写操作时,数据通常先留在 page cache 中,这意味着即使 write() 返回成功,数据也可能还没落到磁盘上。如果此时系统突然掉电或者宕机,这些数据可能丢失。fsync 正是为了解决这个问题而存在的。它的作用是告诉操作系统:请把指定文件的所有缓存数据,以及文件的元数据(如文件大小、修改时间、inode 信息)同步写入磁盘,并等到写入完成后再返回给用户进程。 换句话说,fsync 是一种强制持久化保证,确保在调用返回时,数据已经安全落盘。

ChatGPT Image 2026年1月6日 23_44_49

从底层原理来看,fsync 涉及以下几个步骤:

  1. 刷新页缓存(Dirty Pages):操作系统会扫描指定文件的 page cache,把所有“脏页”(已修改但未写入磁盘的页面)排队进行写入。
  2. 下发写命令到存储设备:操作系统通过块设备接口(block device)把这些页发送给磁盘或者 SSD。
  3. 等待写入完成:操作系统不会立即返回,而是等待存储设备确认所有数据已经物理写入。
    • 对传统机械硬盘,确认意味着磁头已写入磁道。
    • 对 SSD,确认意味着控制器已完成闪存写入并更新持久映射表。
  4. 同步元数据:为了保证文件结构完整性,fsync 还会写入 inode、目录信息、分配表等元数据。

需要注意的是,fsync 的保证依赖两个条件:操作系统和存储设备都必须支持强一致性。大多数现代操作系统(Linux、Windows、MacOS)提供了可靠的 fsync,但部分廉价 SSD 或者虚拟化环境可能使用缓存策略,掉电时仍有数据丢失的风险。在关键系统中,还会要求开启硬件写入缓存刷盘(flush on power loss)或者使用电容缓存的存储阵列来保证掉电安全。

在数据库或 WAL 场景下,fsync 是实现 Durability(持久性) 的关键操作。WAL 的日志先写入 page cache,然后通过 fsync 强制落盘,保证事务提交的操作在系统崩溃后依然可恢复。没有 fsync,即使日志写入了内存,也可能因为掉电而丢失,破坏事务持久性。

核心 Q&A

Q1:不可靠的底层

问题:

为什么我们在代码里按顺序写了 A 然后写了 B,且都收到了 write 成功的返回值,但在断电重启后,磁盘上却可能出现“B 写入了但 A 没写入”或者“A 只写了一半”的情况?

标准答案:

这是因为应用程序看到的“写入成功”与物理磁盘的“真实落盘”之间存在多层抽象和缓冲:

  1. Page Cache 的“欺骗”:

    应用程序调用 write() 时,数据实际上只是被拷贝到了操作系统的内存缓冲区(Page Cache)。此时操作系统会立刻返回“成功”,但数据并未真正落盘。如果此时掉电,内存数据丢失,导致“写成功但数据丢了”。

  2. I/O 调度导致的乱序(Reordering):

    操作系统(IO Scheduler)和磁盘控制器为了优化性能,会重新排列写入指令的顺序。例如,为了减少磁头移动,可能会先写物理位置较近的 B,再写 A。如果写完 B 瞬间断电,就会出现“逻辑后发生的 B 存在,逻辑先发生的 A 丢失”的现象。

  3. 物理原子性的不对等(造成页撕裂):

    数据库通常以 Page(如 16KB)为单位管理数据,但磁盘的原子写入单位通常较小(如 4KB 或 512B)。如果一个 16KB 的页在写到第 8KB 时断电,磁盘上就会留下一半旧数据、一半新数据的“撕裂页”,导致数据彻底损坏不可解析。

Q2:WAL 的性能悖论

问题:

WAL(Write-Ahead Logging)要求“先写日志,再写数据”。这听起来像是多做了一次磁盘 I/O(写两遍)。为什么说 WAL 反而在很多场景下能提升写性能?

标准答案:

WAL 提升性能的核心在于将“随机写”转换为“顺序写”,并将耗时的落盘操作“异步化”:

  1. I/O 模式的转换

    • 没有 WAL 时:直接修改数据库文件(B+树)。由于数据分布在磁盘各处,这属于随机写(Random Write),磁头需要频繁寻道,速度极慢。
    • 有 WAL 时:先写日志。日志是追加写入的,这属于顺序写(Sequential Write),磁头几乎无需移动,吞吐量极高。
  2. 异步刷盘(Group Commit / Checkpoint):

    只要日志(Log)成功落盘,事务就有了持久性保证。此时,真实的数据库页(Data Page)修改可以先停留在内存中,由后台线程稍后慢慢地、批量地刷入磁盘。这极大地降低了关键路径上的 I/O 延迟。

一句话总结:WAL 用低成本的日志落盘,换取了高成本数据落盘的延后执行。

Q3:双写(Double Write)vs. Copy-on-Write (CoW)

问题:

MySQL 的 Double Write Buffer 和 ZFS 的 Copy-on-Write(CoW)都能解决“部分写入”(页撕裂)的问题。请问它们解决问题的手段有何本质区别?

标准答案:

两者的核心区别在于:是“原地修补”还是“异地更新”

  1. Double Write(双写)—— 原地覆盖 + 备份(In-Place Update)
    • 机制:在覆盖旧数据之前,先把新数据写入到一个专门的备份区域(Double Write Buffer)并落盘。
    • 策略:依然信任覆盖写,但为了防止覆盖失败(撕裂),留了一份“草稿”。如果崩溃,就用备份区的数据覆盖回去。
    • 典型应用:MySQL InnoDB。
  2. Copy-on-Write(CoW)—— 异地写 + 指针切换(Out-of-Place Update)
    • 机制:永远不覆盖旧数据。将新数据写入到一个全新的空闲位置。写入完成并落盘后,再原子地将父节点的指针指向新位置。
    • 策略:从根本上规避了“覆盖”这个动作。旧数据要么是有效的(指针没改),要么是无效的(指针改了),不存在中间态。
    • 典型应用:ZFS, Btrfs, Redis RDB (OS层面)。

Q4:fsync 的生死攸关

问题:

在 WAL 的工作流程中(Append Log -> fsync -> Update Memory),如果为了追求极致性能,去掉了中间的 fsync,会导致什么后果?此时 WAL 还能保证崩溃一致性吗?

标准答案:

不能。 去掉 fsync 会导致 持久性(Durability)丧失,WAL 将形同虚设。

  1. 具体后果
    • 应用程序认为数据已经写入(因为 write 调用成功),并向用户返回了“操作成功”。
    • 实际上,日志数据仅停留在操作系统的 Page Cache 中。
    • 一旦发生掉电或系统崩溃,Page Cache 瞬间清空。
    • 重启后,WAL 恢复程序在磁盘上找不到这条日志(因为它没落盘)。
  2. 灾难场景
    • 业务层:用户被告知“钱存进去了”。
    • 存储层:数据库回滚到存钱之前的状态(因为没日志证明发生过存钱)。
    • 最终结果:数据丢失,且无法自动恢复,导致严重的数据不一致事故。

结论fsync 是连接“逻辑写入”与“物理持久化”的唯一桥梁。在需要强一致性的系统中,它是不可省略的性能开销。

拓展阅读

  1. MySQL三大日志(binlog、redo log和undo log)详解 | JavaGuide
  2. WiredTiger: WiredTiger Architecture Guide
  3. https://share.google/aimode/VysnWHf1gVSJGZFNC