WAL机制是什么,在数据库内核中是否重要呢,好像WAL有很多种实现形式,效率不同,你举例呢,
創建於:2026年7月10日
使用 GPT-5.6 Thinking 回答 Chat01
創建於:2026年7月10日
使用 GPT-5.6 Thinking 回答 Chat01
WAL机制是什么,在数据库内核中是否重要呢,好像WAL有很多种实现形式,效率不同,你举例呢,
WAL(Write-Ahead Logging,预写日志)并不是某一种固定的日志格式,而是一条数据库内核必须遵守的原则:
修改后的数据页可以晚些写入磁盘,但描述该修改的日志必须先持久化。
它解决的核心问题是:数据库既想快,又不能因为宕机把已提交事务弄丢。
假设有一条事务:
sqlUPDATE account SET balance = balance - 100 WHERE id = 1;
数据库通常不会立刻把整个数据页刷到磁盘,而是先在内存中修改 Buffer Pool 中的数据页,同时生成一条日志,例如:
textLSN=1050 page_id=42 offset=128 old_value=1000 new_value=900
大致过程是:
text1. 读取数据页到内存 2. 在内存中修改数据页 3. 生成 WAL 日志 4. COMMIT 时先把 WAL 刷到磁盘 5. 向客户端返回提交成功 6. 数据页稍后再异步写回磁盘
关键约束是:
textWAL 已落盘,数据页可以未落盘 数据页不能比对应的 WAL 更早落盘
如果数据库在第 5 步之后、第 6 步之前宕机,重启时可以根据 WAL 重新执行修改,也就是 redo。
主要是因为随机写数据页通常很慢。
假设一次事务修改了 10 个不同位置的数据页:
textpage 3 page 107 page 2050 page 9011 ...
如果提交时直接刷数据页,就可能产生大量随机 I/O。
而 WAL 通常是顺序追加:
textwal segment: [record1][record2][record3][record4]...
顺序写具有几个优势:
所以 WAL 本质上是用“较小的顺序写”,替代“较大的随机写”。
非常重要。对大多数事务型数据库来说,WAL 是存储引擎的核心基础设施之一。
它直接影响:
事务返回 COMMIT 成功后,即使机器立即断电,也能恢复。
这是 ACID 中的 Durability。
数据库重启后,可以根据日志判断:
多个事务不必分别刷数据页,可以共同等待一次 WAL 刷盘,即 group commit。
数据库不能永远从日志开头恢复,因此会周期性建立 checkpoint,表示:
text从这个位置之后的 WAL 才需要重点恢复
很多数据库直接传输 WAL 给副本:
text主库生成 WAL ↓ 副本接收 WAL ↓ 副本重放 WAL
例如 PostgreSQL 的流复制就是典型的 WAL 复制。
如果有基础备份和之后的 WAL,就可以恢复到某一个时间点:
text基础备份 + WAL 重放到 14:35:20
“先写日志”的原则相同,但日志中到底记录什么,各数据库差异很大。
物理日志记录数据页的具体字节变化。
例如:
textpage_id = 42 offset = 128 write bytes = 0x0384
或者记录整个页面镜像。
优点:
缺点:
一个修改如果只影响几个字节,记录字节差异很高效;但如果需要记录完整 16 KB 页面,日志放大会比较明显。
逻辑日志记录“做了什么”,例如:
textUPDATE account SET balance = balance - 100 WHERE id = 1
或者更底层一些:
texttable=account primary_key=1 column=balance new_value=900
优点:
缺点:
例如:
sqlUPDATE t SET x = x + 1 WHERE category = 'A';
逻辑恢复时可能需要重新扫描大量记录,而物理日志只需要修改指定的数据页。
这是数据库内核中非常常见的折中方式。
它通常:
例如:
textpage_id = 42 insert record key=100,value=abc
而不是精确记录:
textoffset 120 写 0xAB offset 121 写 0xCD
这种日志既知道修改哪个页面,又不完全依赖页面内的字节偏移。
经典的 ARIES 恢复算法大量使用这种思想。
优点:
有些情况下,数据库会把整个页面写入 WAL。
假设页面大小是 8 KB:
textWAL record: page_id = 42 full page image = 8192 bytes
这看起来比较浪费,但可以避免“部分页面写入”问题。
例如断电时,一个 8 KB 页面只成功写了前 4 KB,剩余部分还是旧内容,这叫 torn page。
完整页面镜像可以直接覆盖损坏页。
缺点是 checkpoint 后第一次修改大量页面时,可能产生很大的 WAL。
PostgreSQL 的 full-page write 就是这类机制。
某些内存数据库不会记录页面修改,而是记录事务命令及其输入:
textprocedure=transfer parameters=(accountA, accountB, 100)
恢复时重新执行这些确定性事务。
优点:
缺点:
这种方式更适合确定性执行、内存数据库或者特殊架构。
PostgreSQL 的 WAL 偏物理/生理日志。
大致记录:
text哪个 relation 哪个 block 进行了什么页面修改
特点包括:
例如更新一行时,PostgreSQL 通常不是把 SQL 本身写入 WAL,而是写入与 tuple、page 有关的底层修改。
优势是恢复确定、复制可靠;代价是 WAL 量可能较大。
InnoDB 的 redo log 本质上也是 WAL。
InnoDB 修改 Buffer Pool 中的数据页时生成 redo record:
text修改某个 tablespace 某个 page 执行某种页内操作
提交时主要保证 redo log 持久化,而不是立即刷数据页。
需要特别区分:
textInnoDB redo log:存储引擎级,负责崩溃恢复 MySQL binlog:Server 层,负责复制、PITR 和逻辑变更传播 undo log:负责回滚和 MVCC
binlog 虽然也是日志,但它和 WAL/redo log 的作用不完全相同。
MySQL 事务提交还涉及 redo log 与 binlog 的一致性,因此需要两阶段提交。
SQLite 传统 rollback journal 的思路是:
text先保存旧页面 再修改数据库文件
WAL 模式则变成:
text原数据库文件暂时不修改 新页面追加写入 WAL 文件
读取时需要同时查看:
textdatabase file + WAL file
随后通过 checkpoint 把 WAL 中的页面合并回主数据库。
SQLite WAL 模式的优势:
限制:
ARIES 是经典的数据库恢复算法,其核心思想包括:
恢复分三个阶段:
textAnalysis:分析崩溃时事务和脏页状态 Redo:重复历史,将数据库恢复到宕机前状态 Undo:回滚未提交事务
为什么先 redo 再 undo?
假设未提交事务的部分修改已经写入数据页,那么数据库必须先重建崩溃时的真实状态,再撤销未完成事务。
不同 WAL 实现的性能差异,并不仅仅取决于“物理日志还是逻辑日志”,还取决于整套内核设计。
一次行更新可能产生:
text业务数据变化:20 字节 WAL:100 字节
也可能因为 full-page write 产生:
text业务数据变化:20 字节 WAL:8 KB
日志量越大:
这种现象称为 WAL amplification。
单个事务如果每次提交都调用一次 fsync:
text事务1 -> fsync 事务2 -> fsync 事务3 -> fsync
性能会很差。
group commit 可以合并为:
text事务1 ┐ 事务2 ├── 一次 fsync 事务3 ┘
因此 WAL 性能往往主要受“每秒可完成多少次可靠持久化”影响,而不仅是顺序写带宽。
事务通常先把日志写进内存 WAL buffer:
text事务线程 ↓ WAL buffer ↓ WAL writer ↓ 磁盘
设计差异包括:
在高并发数据库中,WAL buffer 本身可能成为争用热点。
记录粒度太细:
text每改一个字节产生一条日志
会有大量日志头和函数调用开销。
记录粒度太粗:
text修改一行就记录整个页面
又会产生严重的日志放大。
因此数据库会针对常见操作设计专用日志类型,例如:
textinsert tuple delete tuple split btree page update page header
checkpoint 过于频繁:
checkpoint 间隔过长:
所以 WAL、Buffer Pool 和 checkpoint 必须联合设计。
有些 WAL 必须严格串行重放:
textrecord1 -> record2 -> record3
有些可以按 page、表或事务并行重放:
textpage A: record1 -> record5 page B: record2 -> record4 page C: record3
并行恢复可以显著降低大规模数据库的恢复时间,但要求依赖关系清晰。
假设 10,000 个事务,每个事务更新一个随机数据页。
每个事务都刷一个 16 KB 页:
text10,000 × 16 KB ≈ 160 MB
而且大部分是随机写,并可能产生 10,000 次同步刷盘。
性能很差。
假设每条 WAL 记录 200 字节:
text10,000 × 200 B ≈ 2 MB
数据量很小,但仍然可能有 10,000 次 fsync,延迟依然很高。
还是约 2 MB 日志,但每 100 个事务共同刷一次:
text10,000 个事务 100 次 fsync
性能会明显提升。
提交路径只等待 WAL,脏页在后台排序、合并和批量写:
text前台:写 WAL、等待提交 后台:批量刷数据页
这是现代 OLTP 数据库常见的高性能方式。
这两个概念容易混淆。
回答:
已提交的修改如果没有进入数据文件,怎么重新做一遍?
例如:
textbalance: 1000 -> 900
回答:
未提交的修改如果已经进入数据页,怎么撤销?
例如:
textbalance: 900 -> 1000
有些数据库 WAL 同时包含 redo 和 undo 信息;有些数据库将 undo 单独保存在 undo segment 中。
例如 InnoDB:
textredo log:崩溃恢复 undo log:事务回滚和 MVCC
一个数据库可能同时存在多种日志:
textWAL / redo log undo log logical replication log binlog CDC log audit log consensus log
例如:
它们可能记录同一事务的不同表示。
在分布式数据库中,Raft log 有时也承担 WAL 的作用:只有日志条目被多数副本确认后,事务才算提交。但共识日志解决的是“多节点状态一致”,传统 WAL 解决的是“单节点崩溃恢复”,二者概念相关但不完全等价。
不能只看日志写入带宽,通常要综合衡量:
text事务提交延迟 吞吐量 WAL 字节数 / 业务修改字节数 fsync 次数 锁竞争程度 checkpoint 抖动 崩溃恢复时间 副本重放速度 日志保留空间 实现复杂度
有一个典型三角关系:
text正常运行性能 恢复速度 日志体积
很难同时做到三者最优。
例如:
概括来说,WAL 是数据库把“事务提交”和“数据页落盘”解耦的关键机制。没有 WAL,数据库要么提交非常慢,要么很难保证崩溃后数据正确。对于事务型数据库内核而言,它和 Buffer Pool、MVCC、锁、checkpoint、恢复算法几乎处于同一个重要级别。
假设一个数据库永远不会断电,中断,永远保持运行,是不是可以不需要WAL呢,因为不会出现需要恢复的情况
从纯理论上说,如果你真的能保证数据库永远不会发生任何故障,也永远不需要复制、备份、回滚到历史状态,那么可以不需要 WAL。
因为 WAL 最核心的用途,就是在数据页还没落盘时发生故障,依靠日志恢复已经提交的事务。如果故障概率严格为零,恢复机制自然没有存在的必要。
但这里要区分:不需要 WAL,不等于数据库不需要任何事务机制。
假设满足以下条件:
此时事务提交可以直接表示为:
text修改内存中的数据结构 更新事务状态为已提交 返回成功
甚至数据库可以是纯内存数据库,不把数据写到磁盘。
这种情况下,WAL 的持久化、redo、checkpoint、崩溃恢复都可以删除。
理论上,这样的数据库会更快,因为提交路径中没有:
text生成 WAL 复制 WAL 到日志缓冲区 刷新 WAL 等待 fsync 维护 LSN 执行 checkpoint
即使系统永不崩溃,事务本身仍然可能主动失败。
例如:
sqlBEGIN; UPDATE account SET balance = balance - 100 WHERE id = 1; UPDATE account SET balance = balance + 100 WHERE id = 2;
第二条语句可能因为约束冲突、死锁、用户主动执行 ROLLBACK 等原因失败。
这时数据库仍需要保证原子性:
text要么两条修改都成功 要么两条修改都不生效
但这不一定需要 WAL。可以采用其他机制,例如:
记录旧值:
textaccount 1: balance 1000 -> 900 undo: 900 -> 1000
事务失败时使用 undo 撤销。
事务不直接覆盖旧版本,而是生成新版本:
text旧版本:balance = 1000 新版本:balance = 900,事务 ID = 123
事务提交后,新版本才对其他事务可见;事务回滚时丢弃新版本。
修改时复制节点或页面,不覆盖原对象:
text旧版本保持不变 事务在副本上修改 提交时切换根指针
维护旧页面映射和新页面映射,事务提交时切换元数据指针。
所以可以说:
永不故障时,可以不需要 redo WAL,但仍可能需要 undo、MVCC、copy-on-write 等原子性机制。
再做一个更极端的假设:
此时连 undo log 也不需要。
数据库本质上就变成一个长期运行的内存状态机:
text状态 S0 执行操作 A 得到状态 S1 执行操作 B 得到状态 S2
每次操作只修改当前状态。
但这种系统严格来说已经不太像我们通常讨论的可靠事务数据库,而更像一个不会失败的内存程序。
即使电力绝对可靠,仍然存在很多其他故障来源:
text数据库进程崩溃 操作系统内核崩溃 CPU 或内存硬件错误 磁盘控制器故障 磁盘坏块 程序 bug 管理员误操作 文件系统损坏 软件升级重启 OOM 杀进程 数据中心火灾
WAL 防范的不只是断电,而是广义的“状态被中断或破坏”。
例如数据库进程因为空指针崩溃:
text事务已经返回提交成功 数据页还在 Buffer Pool 数据页尚未落盘
只要进程重启,内存中的修改就消失了。即使机器从未断电,仍然需要 WAL 来恢复。
现代数据库中的 WAL 往往不仅用于崩溃恢复。
主节点把 WAL 发送给副本:
text主库 WAL → 从库重放
没有 WAL,就需要采用其他变更传播机制。
需要恢复到某个时刻:
text基础备份 + WAL → 恢复到误删数据之前
WAL 可以表示自上一次备份之后发生了哪些变化。
下游系统通过日志捕获数据变更:
textINSERT UPDATE DELETE
分析谁在什么时候修改了哪些数据。
所以即使数据库本身永不崩溃,只要还需要这些功能,某种形式的变更日志仍然有价值。不过它不一定必须是传统的物理 WAL。
通常数据库使用 WAL,是因为它采用了:
textsteal + no-force
事务提交时,不强制把所有修改过的数据页写入磁盘。
因此需要 redo:
text提交了但数据页没落盘 → 用 WAL 重做
事务提交前,允许脏数据页被刷到磁盘。
因此需要 undo:
text未提交的数据已经进入磁盘 → 必须能够撤销
如果不用 WAL,也可以改成非常保守的策略。
事务提交时,把所有修改页都刷盘:
textCOMMIT → 所有数据页持久化 → 返回成功
这样不需要 redo WAL,但提交性能通常较差,因为要写很多随机数据页。
未提交事务修改的数据页绝不刷盘。
这样不需要处理磁盘中的未提交修改,但需要更大的内存,而且长事务会制造很大压力。
因此:
textno WAL + force + no-steal
理论上是可行的,但性能和资源利用率通常不好。
WAL 的价值正是在于让数据库可以安全地采用高性能的:
textsteal + no-force
如果数据库明确接受:
text进程一停,数据全部消失
那么完全可以不用 WAL。
例如某些:
这种系统可以认为数据是“可丢弃的”。
但如果纯内存数据库承诺持久性,它仍然需要某种机制:
text命令日志 WAL 快照 复制 共识日志 NVRAM 持久化
例如内存数据库可能通过定期快照加命令日志恢复。
text事务修改 100 个随机数据页 生成 10 KB 顺序 WAL 刷一次 WAL 返回提交成功 数据页后台慢慢写
优点是提交快。
text事务修改 100 个随机数据页 提交前必须把 100 个数据页全部写入磁盘 确认全部成功 返回提交成功
这样虽然不需要 redo WAL,但提交延迟可能大得多。
text修改内存 返回成功
最快,但数据库一旦停止,数据可能全部丢失。
在你提出的绝对理想条件下:
数据库确实可以不使用 WAL,因为不存在任何崩溃恢复需求。
但需要进一步区分:
因此,WAL 不是数据库逻辑上绝对必需的组件,而是在现实世界存在故障、同时又追求高性能持久化时,最重要、最普遍的工程方案之一。
数据库的MVCC机制实现的隔离级别和基于两段锁实现的隔离界别,在条件几乎相同的情况下,哪一个性能会更高呢
在“硬件、数据规模、事务逻辑和隔离级别尽量相同”的情况下,通常 MVCC 的综合吞吐量会更高,尤其是读多写少、长读事务较多、并发度高的场景。
但这不是绝对结论。若是高冲突写入、事务很短、读很少,两段锁 2PL 反而可能更简单、更稳定,甚至更快。
核心区别可以概括为:
MVCC 用“多版本”减少读写阻塞;
2PL 用“锁”保证事务之间的顺序。
MVCC 最大的优势是:
text读不阻塞写 写通常也不阻塞读
例如事务 T1 正在更新一行:
sqlUPDATE account SET balance = 900 WHERE id = 1;
同时事务 T2 查询:
sqlSELECT balance FROM account WHERE id = 1;
在 MVCC 中,T2 可以读取旧版本:
text旧版本:1000 新版本:900,尚未提交
T2 不需要等待 T1。
而在基于锁的 2PL 中,如果 T1 持有排他锁,T2 通常需要等待:
textT1:X lock T2:等待 S lock
因此在高并发读写混合场景中,MVCC 能显著减少:
这也是 PostgreSQL、Oracle、MySQL InnoDB 等主流数据库大量使用 MVCC 的原因。
每条语句读取一个已提交版本:
textSELECT 开始 → 找到当前可见版本 → 不需要对普通读加共享锁
读取时通常需要共享锁:
textSELECT → 加 S 锁 → 与 X 锁冲突
在读写并发较高时,一般:
textMVCC Read Committed > 2PL Read Committed
这里 MVCC 通常优势明显。
MVCC 可以通过事务快照实现:
text事务开始时确定快照 后续查询都读取该快照可见的版本
读事务通常不需要长期持有行锁。
而严格锁式实现可能需要一直持有共享锁到事务结束:
textBEGIN SELECT 某行 → 加 S 锁 一直持有 COMMIT → 释放
如果事务较长,2PL 会明显降低并发。
一般而言:
textMVCC Repeatable Read 通常明显优于锁式 Repeatable Read
尤其是:
这里比较就复杂了。
通常通过以下锁保证串行化:
优点是:
text事务如果成功提交,通常不会再因为串行化冲突而回滚
缺点是:
MVCC 实现串行化有几种方式:
优点是:
缺点是:
所以在低冲突场景:
textMVCC Serializable 往往吞吐更高
在高冲突场景:
text严格 2PL 可能更稳定
因为 2PL 往往是让事务提前等待,而 MVCC 可能让事务先执行,最后再失败。
MVCC 的性能优势是用版本管理换来的。
同一行可能有多个版本:
textv5 → v4 → v3 → v2 → v1
查询时需要根据事务快照判断哪个版本可见。
如果一行被频繁更新,版本链很长,读取开销会增加。
旧版本不能永久保留,需要回收:
textPostgreSQL:VACUUM Oracle:undo 清理 InnoDB:purge
如果存在长事务,旧版本不能及时删除,会产生:
例如某个读事务运行数小时,它可能要求数据库一直保留很老的版本。
MVCC 通常不能简单地原地覆盖一行。
例如 PostgreSQL 更新一行时,通常会创建新 tuple:
text旧 tuple 保留 新 tuple 插入
这会带来:
MVCC 只能有效缓解读写冲突,不能消除写写冲突。
两个事务同时更新同一行:
sqlT1: UPDATE account SET balance = balance - 100 WHERE id = 1; T2: UPDATE account SET balance = balance - 50 WHERE id = 1;
仍然需要:
因此 MVCC 并不是“无锁”。
更准确地说:
MVCC 通常让普通读不需要阻塞式共享锁,但写操作仍然需要并发控制。
两段锁的主要优势不是“并发度高”,而是:
text机制直接 冲突立即显现 版本管理成本低 提交结果更确定
锁式原地更新可能只保留当前值:
textbalance = 1000 直接改为 900
不必维护很多历史版本。
读路径可能更短:
text定位记录 检查锁 读取当前值
如果没有锁冲突,这个过程可能非常快。
假设 100 个事务都在更新同一行。
在 2PL 中:
text第一个事务获得锁 其他事务排队 依次执行
虽然等待明显,但浪费较少。
乐观 MVCC 可能出现:
text100 个事务都执行大量逻辑 最终只有少数提交 其他事务回滚并重试
此时 CPU 和 I/O 被大量浪费。
所以在高冲突热点场景下,悲观锁有时性能更好。
如果事务极短,例如:
sqlUPDATE counter SET value = value + 1 WHERE id = 1; COMMIT;
持锁时间只有几十微秒。
此时 2PL 的锁阻塞成本不一定很高,而 MVCC 的版本生成、提交时间戳、垃圾回收反而可能构成额外负担。
例如:
text95% SELECT 5% UPDATE
通常 MVCC 更好。
原因:
text推荐:MVCC
例如:
text50% SELECT 50% UPDATE
如果数据访问比较分散,MVCC 通常仍然更好。
如果更新集中在少量热点行,优势会减弱。
例如:
text10% SELECT 90% UPDATE
这时 MVCC 的核心优势“读写不阻塞”没有那么重要。
同时会产生:
如果写冲突不高,MVCC 仍可有不错性能。
如果写冲突非常高,2PL 可能更好。
例如所有事务更新同一个库存值:
sqlUPDATE inventory SET stock = stock - 1 WHERE product_id = 100;
这里真正瓶颈是写写串行化。
无论 MVCC 还是 2PL,同一行最终都无法真正并行修改。
两者区别是:
text2PL:提前阻塞排队 MVCC:可能执行后检测冲突或等待行锁
通常 2PL 或显式悲观锁更加可控。
例如一个报表查询运行 30 分钟,同时 OLTP 事务不断更新。
MVCC 的优势非常明显:
text报表读旧快照 OLTP 继续更新
2PL 下如果报表持有大量共享锁,可能严重阻塞写入。
但是 MVCC 也会付出代价:由于长事务存在,旧版本不能回收。
假设:
每次读都可能加共享锁:
text100 个事务 × 100 行 = 大量锁对象
若某事务更新一行,其他读写可能等待。
锁管理器需要维护:
普通读取主要做可见性判断:
text版本创建时间 < 快照时间 版本删除时间 > 快照时间
只有更新行需要写锁或写冲突控制。
在这种负载下,MVCC 通常吞吐量更高。
不能只看平均吞吐量,还要看延迟分布。
可能出现:
text大部分事务很快 少数事务因为锁等待,延迟很高
尾延迟可能较差。
读延迟通常更加稳定,但写事务在串行化隔离下可能:
text执行完成 提交时冲突 回滚重试
从业务角度看,这类重试也会导致高尾延迟。
所以:
现实数据库经常混合使用两者。
例如 InnoDB:
SELECT ... FOR UPDATE 使用排他锁;PostgreSQL:
Oracle:
所以现代系统更常见的是:
textMVCC 负责读写并发 锁负责写写冲突和结构性冲突
而不是完全不用锁。
用户所说的“条件几乎相同”仍然需要明确几个变量:
例如拿:
textMVCC Snapshot Isolation
和:
textStrict 2PL Serializable
直接比较是不公平的,因为前者可能不是真正的串行化隔离,保证更弱,当然通常更快。
必须比较:
textMVCC Serializable vs 2PL Serializable
才有意义。
可以给出一个经验结论:
| 场景 | 通常更有优势 |
|---|---|
| 读多写少 | MVCC |
| 大量并发查询 | MVCC |
| 长读事务 | MVCC |
| 读写分散、冲突低 | MVCC |
| 写多、版本更新频繁 | 差距缩小 |
| 高热点写冲突 | 2PL 或悲观锁 |
| 极短事务、低并发 | 2PL 可能更简单高效 |
| 低冲突 Serializable | MVCC/SSI 通常吞吐更高 |
| 高冲突 Serializable | 2PL 可能更稳定 |
最精确的结论是:
在现代通用 OLTP 负载中,MVCC 通常具有更高的并发性能,主要是因为它消除了绝大多数读写阻塞。
但在高写冲突、热点集中、短事务、读操作很少的场景中,2PL 可能更高效,因为它避免了版本维护和大量事务失败重试。
数据库设计中经常采用的最佳实践不是二选一,而是:
textMVCC + 写锁 + 冲突检测
即用 MVCC 优化读,用锁处理不可避免的写冲突。