WAL机制是什么,在数据库内核中是否重要呢,好像WAL有很多种实现形式,效率不同,你举例呢,

Creado el: 10 de julio de 2026

Respondido usando GPT-5.6 Thinking por Chat01

Pregunta

WAL机制是什么,在数据库内核中是否重要呢,好像WAL有很多种实现形式,效率不同,你举例呢,

Pensamiento

Thought for a few seconds

Respuesta

WAL(Write-Ahead Logging,预写日志)并不是某一种固定的日志格式,而是一条数据库内核必须遵守的原则:

修改后的数据页可以晚些写入磁盘,但描述该修改的日志必须先持久化。

它解决的核心问题是:数据库既想快,又不能因为宕机把已提交事务弄丢。

1. WAL 是怎么工作的

假设有一条事务:

sql
UPDATE account SET balance = balance - 100 WHERE id = 1;

数据库通常不会立刻把整个数据页刷到磁盘,而是先在内存中修改 Buffer Pool 中的数据页,同时生成一条日志,例如:

text
LSN=1050 page_id=42 offset=128 old_value=1000 new_value=900

大致过程是:

text
1. 读取数据页到内存 2. 在内存中修改数据页 3. 生成 WAL 日志 4. COMMIT 时先把 WAL 刷到磁盘 5. 向客户端返回提交成功 6. 数据页稍后再异步写回磁盘

关键约束是:

text
WAL 已落盘,数据页可以未落盘 数据页不能比对应的 WAL 更早落盘

如果数据库在第 5 步之后、第 6 步之前宕机,重启时可以根据 WAL 重新执行修改,也就是 redo。

2. 为什么数据库不直接写数据页

主要是因为随机写数据页通常很慢。

假设一次事务修改了 10 个不同位置的数据页:

text
page 3 page 107 page 2050 page 9011 ...

如果提交时直接刷数据页,就可能产生大量随机 I/O。

而 WAL 通常是顺序追加:

text
wal segment: [record1][record2][record3][record4]...

顺序写具有几个优势:

  • 写入位置连续;
  • 可以合并多条日志;
  • 容易进行 group commit;
  • 提交时只需保证较小的日志被持久化;
  • 脏数据页可以由后台线程批量刷盘。

所以 WAL 本质上是用“较小的顺序写”,替代“较大的随机写”。


3. WAL 在数据库内核中重要吗

非常重要。对大多数事务型数据库来说,WAL 是存储引擎的核心基础设施之一。

它直接影响:

事务持久性

事务返回 COMMIT 成功后,即使机器立即断电,也能恢复。

这是 ACID 中的 Durability。

崩溃恢复

数据库重启后,可以根据日志判断:

  • 哪些事务已经提交;
  • 哪些修改需要重做;
  • 哪些事务未完成;
  • 是否需要回滚未提交修改。

并发性能

多个事务不必分别刷数据页,可以共同等待一次 WAL 刷盘,即 group commit。

检查点

数据库不能永远从日志开头恢复,因此会周期性建立 checkpoint,表示:

text
从这个位置之后的 WAL 才需要重点恢复

复制

很多数据库直接传输 WAL 给副本:

text
主库生成 WAL 副本接收 WAL 副本重放 WAL

例如 PostgreSQL 的流复制就是典型的 WAL 复制。

备份和时间点恢复

如果有基础备份和之后的 WAL,就可以恢复到某一个时间点:

text
基础备份 + WAL 重放到 14:35:20

4. WAL 有哪些不同实现形式

“先写日志”的原则相同,但日志中到底记录什么,各数据库差异很大。

4.1 物理日志 Physical Logging

物理日志记录数据页的具体字节变化。

例如:

text
page_id = 42 offset = 128 write bytes = 0x0384

或者记录整个页面镜像。

优点:

  • 恢复过程简单直接;
  • 重放结果确定;
  • 不需要重新执行 SQL;
  • 通常恢复速度快。

缺点:

  • 日志量可能较大;
  • 与数据页格式强绑定;
  • 数据结构版本兼容较困难。

一个修改如果只影响几个字节,记录字节差异很高效;但如果需要记录完整 16 KB 页面,日志放大会比较明显。


4.2 逻辑日志 Logical Logging

逻辑日志记录“做了什么”,例如:

text
UPDATE account SET balance = balance - 100 WHERE id = 1

或者更底层一些:

text
table=account primary_key=1 column=balance new_value=900

优点:

  • 日志可读性更好;
  • 与物理页布局耦合较低;
  • 适合异构复制、审计、CDC;
  • 日志有时更小。

缺点:

  • 恢复时可能需要重新搜索索引;
  • 重放成本高;
  • 并发情况下较难保证完全相同的物理结果;
  • 某些非确定性操作不好重放。

例如:

sql
UPDATE t SET x = x + 1 WHERE category = 'A';

逻辑恢复时可能需要重新扫描大量记录,而物理日志只需要修改指定的数据页。


4.3 生理日志 Physiological Logging

这是数据库内核中非常常见的折中方式。

它通常:

  • 在页级别上是物理定位;
  • 在页内部记录逻辑操作。

例如:

text
page_id = 42 insert record key=100,value=abc

而不是精确记录:

text
offset 120 写 0xAB offset 121 写 0xCD

这种日志既知道修改哪个页面,又不完全依赖页面内的字节偏移。

经典的 ARIES 恢复算法大量使用这种思想。

优点:

  • 比纯逻辑日志恢复更快;
  • 比纯物理字节日志灵活;
  • 适合 B+ 树、堆表等页式存储结构。

4.4 全页日志 Full-page Image

有些情况下,数据库会把整个页面写入 WAL。

假设页面大小是 8 KB:

text
WAL record: page_id = 42 full page image = 8192 bytes

这看起来比较浪费,但可以避免“部分页面写入”问题。

例如断电时,一个 8 KB 页面只成功写了前 4 KB,剩余部分还是旧内容,这叫 torn page。

完整页面镜像可以直接覆盖损坏页。

缺点是 checkpoint 后第一次修改大量页面时,可能产生很大的 WAL。

PostgreSQL 的 full-page write 就是这类机制。


4.5 命令日志 Command Logging

某些内存数据库不会记录页面修改,而是记录事务命令及其输入:

text
procedure=transfer parameters=(accountA, accountB, 100)

恢复时重新执行这些确定性事务。

优点:

  • 日志非常小;
  • 正常运行期间写日志开销低。

缺点:

  • 恢复时需要重新执行大量事务;
  • 恢复速度可能很慢;
  • 必须保证事务是确定性的;
  • 复杂外部依赖难处理。

这种方式更适合确定性执行、内存数据库或者特殊架构。


5. 不同数据库中的具体例子

PostgreSQL WAL

PostgreSQL 的 WAL 偏物理/生理日志。

大致记录:

text
哪个 relation 哪个 block 进行了什么页面修改

特点包括:

  • 使用 LSN 标识日志位置;
  • commit 依赖 WAL flush;
  • 支持 full-page write;
  • 支持流复制;
  • 支持归档和 PITR;
  • checkpoint 会影响 WAL 量和恢复时间。

例如更新一行时,PostgreSQL 通常不是把 SQL 本身写入 WAL,而是写入与 tuple、page 有关的底层修改。

优势是恢复确定、复制可靠;代价是 WAL 量可能较大。


MySQL InnoDB Redo Log

InnoDB 的 redo log 本质上也是 WAL。

InnoDB 修改 Buffer Pool 中的数据页时生成 redo record:

text
修改某个 tablespace 某个 page 执行某种页内操作

提交时主要保证 redo log 持久化,而不是立即刷数据页。

需要特别区分:

text
InnoDB redo log:存储引擎级,负责崩溃恢复 MySQL binlog:Server 层,负责复制、PITR 和逻辑变更传播 undo log:负责回滚和 MVCC

binlog 虽然也是日志,但它和 WAL/redo log 的作用不完全相同。

MySQL 事务提交还涉及 redo log 与 binlog 的一致性,因此需要两阶段提交。


SQLite WAL 模式

SQLite 传统 rollback journal 的思路是:

text
先保存旧页面 再修改数据库文件

WAL 模式则变成:

text
原数据库文件暂时不修改 新页面追加写入 WAL 文件

读取时需要同时查看:

text
database file + WAL file

随后通过 checkpoint 把 WAL 中的页面合并回主数据库。

SQLite WAL 模式的优势:

  • 读写可以更好地并发;
  • 写入主要是追加;
  • 通常比 rollback journal 性能好。

限制:

  • 通常仍然只有一个 writer;
  • checkpoint 可能受长读事务影响;
  • WAL 文件可能持续增长。

ARIES

ARIES 是经典的数据库恢复算法,其核心思想包括:

  • WAL;
  • steal/no-force;
  • LSN;
  • pageLSN;
  • 重复历史;
  • compensation log record,简称 CLR。

恢复分三个阶段:

text
Analysis:分析崩溃时事务和脏页状态 Redo:重复历史,将数据库恢复到宕机前状态 Undo:回滚未提交事务

为什么先 redo 再 undo?

假设未提交事务的部分修改已经写入数据页,那么数据库必须先重建崩溃时的真实状态,再撤销未完成事务。


6. WAL 效率差异主要来自哪里

不同 WAL 实现的性能差异,并不仅仅取决于“物理日志还是逻辑日志”,还取决于整套内核设计。

日志量

一次行更新可能产生:

text
业务数据变化:20 字节 WAL:100 字节

也可能因为 full-page write 产生:

text
业务数据变化:20 字节 WAL:8 KB

日志量越大:

  • 磁盘带宽占用越高;
  • 网络复制开销越大;
  • 恢复需要读取的数据越多。

这种现象称为 WAL amplification。


fsync 次数

单个事务如果每次提交都调用一次 fsync:

text
事务1 -> fsync 事务2 -> fsync 事务3 -> fsync

性能会很差。

group commit 可以合并为:

text
事务1 ┐ 事务2 ├── 一次 fsync 事务3 ┘

因此 WAL 性能往往主要受“每秒可完成多少次可靠持久化”影响,而不仅是顺序写带宽。


WAL Buffer 设计

事务通常先把日志写进内存 WAL buffer:

text
事务线程 WAL buffer WAL writer 磁盘

设计差异包括:

  • 全局锁还是分区锁;
  • 单一 buffer 还是多个 buffer;
  • 事务线程自行 flush 还是专门线程 flush;
  • 是否支持并行日志生成;
  • 是否存在日志预留和日志复制。

在高并发数据库中,WAL buffer 本身可能成为争用热点。


日志记录粒度

记录粒度太细:

text
每改一个字节产生一条日志

会有大量日志头和函数调用开销。

记录粒度太粗:

text
修改一行就记录整个页面

又会产生严重的日志放大。

因此数据库会针对常见操作设计专用日志类型,例如:

text
insert tuple delete tuple split btree page update page header

Checkpoint 策略

checkpoint 过于频繁:

  • 脏页刷盘多;
  • full-page WAL 可能增加;
  • 前台 I/O 抖动明显。

checkpoint 间隔过长:

  • WAL 积累多;
  • 崩溃恢复时间变长;
  • 副本落后时需要保存更多日志。

所以 WAL、Buffer Pool 和 checkpoint 必须联合设计。


并发重放能力

有些 WAL 必须严格串行重放:

text
record1 -> record2 -> record3

有些可以按 page、表或事务并行重放:

text
page A: record1 -> record5 page B: record2 -> record4 page C: record3

并行恢复可以显著降低大规模数据库的恢复时间,但要求依赖关系清晰。


7. 一个效率对比例子

假设 10,000 个事务,每个事务更新一个随机数据页。

方案 A:提交时直接刷数据页

每个事务都刷一个 16 KB 页:

text
10,000 × 16 KB ≈ 160 MB

而且大部分是随机写,并可能产生 10,000 次同步刷盘。

性能很差。

方案 B:使用 WAL,但每次提交单独 fsync

假设每条 WAL 记录 200 字节:

text
10,000 × 200 B ≈ 2 MB

数据量很小,但仍然可能有 10,000 次 fsync,延迟依然很高。

方案 C:WAL + group commit

还是约 2 MB 日志,但每 100 个事务共同刷一次:

text
10,000 个事务 100 次 fsync

性能会明显提升。

方案 D:WAL + group commit + 后台批量刷脏页

提交路径只等待 WAL,脏页在后台排序、合并和批量写:

text
前台:写 WAL、等待提交 后台:批量刷数据页

这是现代 OLTP 数据库常见的高性能方式。


8. WAL 和 Undo 的区别

这两个概念容易混淆。

Redo/WAL

回答:

已提交的修改如果没有进入数据文件,怎么重新做一遍?

例如:

text
balance: 1000 -> 900

Undo

回答:

未提交的修改如果已经进入数据页,怎么撤销?

例如:

text
balance: 900 -> 1000

有些数据库 WAL 同时包含 redo 和 undo 信息;有些数据库将 undo 单独保存在 undo segment 中。

例如 InnoDB:

text
redo log:崩溃恢复 undo log:事务回滚和 MVCC

9. WAL 和复制日志也不完全相同

一个数据库可能同时存在多种日志:

text
WAL / redo log undo log logical replication log binlog CDC log audit log consensus log

例如:

  • WAL 面向数据页恢复;
  • binlog 面向逻辑复制;
  • undo 面向事务回滚;
  • Raft log 面向分布式共识;
  • audit log 面向安全审计。

它们可能记录同一事务的不同表示。

在分布式数据库中,Raft log 有时也承担 WAL 的作用:只有日志条目被多数副本确认后,事务才算提交。但共识日志解决的是“多节点状态一致”,传统 WAL 解决的是“单节点崩溃恢复”,二者概念相关但不完全等价。


10. 如何判断一种 WAL 设计好不好

不能只看日志写入带宽,通常要综合衡量:

text
事务提交延迟 吞吐量 WAL 字节数 / 业务修改字节数 fsync 次数 锁竞争程度 checkpoint 抖动 崩溃恢复时间 副本重放速度 日志保留空间 实现复杂度

有一个典型三角关系:

text
正常运行性能 恢复速度 日志体积

很难同时做到三者最优。

例如:

  • 命令日志体积小、运行快,但恢复慢;
  • 完整页面日志恢复简单,但日志量大;
  • 逻辑日志适合异构复制,但重放成本高;
  • 生理日志在通用磁盘数据库中通常更均衡。

概括来说,WAL 是数据库把“事务提交”和“数据页落盘”解耦的关键机制。没有 WAL,数据库要么提交非常慢,要么很难保证崩溃后数据正确。对于事务型数据库内核而言,它和 Buffer Pool、MVCC、锁、checkpoint、恢复算法几乎处于同一个重要级别。

Pregunta

假设一个数据库永远不会断电,中断,永远保持运行,是不是可以不需要WAL呢,因为不会出现需要恢复的情况

Pensamiento

Thought for a couple of seconds

Respuesta

从纯理论上说,如果你真的能保证数据库永远不会发生任何故障,也永远不需要复制、备份、回滚到历史状态,那么可以不需要 WAL。

因为 WAL 最核心的用途,就是在数据页还没落盘时发生故障,依靠日志恢复已经提交的事务。如果故障概率严格为零,恢复机制自然没有存在的必要。

但这里要区分:不需要 WAL,不等于数据库不需要任何事务机制。

1. 在这个理想模型中,WAL 可以省掉什么

假设满足以下条件:

  • 进程永不崩溃;
  • 操作系统永不崩溃;
  • 机器永不掉电;
  • 内存永不损坏;
  • 磁盘永不损坏;
  • 不发生网络故障;
  • 数据库永不重启;
  • 不需要主从复制;
  • 不需要时间点恢复;
  • 不需要增量备份;
  • 不需要审计和 CDC。

此时事务提交可以直接表示为:

text
修改内存中的数据结构 更新事务状态为已提交 返回成功

甚至数据库可以是纯内存数据库,不把数据写到磁盘。

这种情况下,WAL 的持久化、redo、checkpoint、崩溃恢复都可以删除。

理论上,这样的数据库会更快,因为提交路径中没有:

text
生成 WAL 复制 WAL 到日志缓冲区 刷新 WAL 等待 fsync 维护 LSN 执行 checkpoint

2. 但事务回滚仍然可能需要

即使系统永不崩溃,事务本身仍然可能主动失败。

例如:

sql
BEGIN; UPDATE account SET balance = balance - 100 WHERE id = 1; UPDATE account SET balance = balance + 100 WHERE id = 2;

第二条语句可能因为约束冲突、死锁、用户主动执行 ROLLBACK 等原因失败。

这时数据库仍需要保证原子性:

text
要么两条修改都成功 要么两条修改都不生效

但这不一定需要 WAL。可以采用其他机制,例如:

Undo log

记录旧值:

text
account 1: balance 1000 -> 900 undo: 900 -> 1000

事务失败时使用 undo 撤销。

MVCC

事务不直接覆盖旧版本,而是生成新版本:

text
旧版本:balance = 1000 新版本:balance = 900,事务 ID = 123

事务提交后,新版本才对其他事务可见;事务回滚时丢弃新版本。

Copy-on-write

修改时复制节点或页面,不覆盖原对象:

text
旧版本保持不变 事务在副本上修改 提交时切换根指针

Shadow paging

维护旧页面映射和新页面映射,事务提交时切换元数据指针。

所以可以说:

永不故障时,可以不需要 redo WAL,但仍可能需要 undo、MVCC、copy-on-write 等原子性机制。

3. 如果连事务都不会失败,机制还能进一步简化

再做一个更极端的假设:

  • 所有事务都一定成功;
  • 没有约束冲突;
  • 没有死锁;
  • 用户永远不执行回滚;
  • 程序没有异常;
  • 所有操作都按顺序完成;
  • 系统永不故障。

此时连 undo log 也不需要。

数据库本质上就变成一个长期运行的内存状态机:

text
状态 S0 执行操作 A 得到状态 S1 执行操作 B 得到状态 S2

每次操作只修改当前状态。

但这种系统严格来说已经不太像我们通常讨论的可靠事务数据库,而更像一个不会失败的内存程序。

4. “永远不断电”还不够

即使电力绝对可靠,仍然存在很多其他故障来源:

text
数据库进程崩溃 操作系统内核崩溃 CPU 或内存硬件错误 磁盘控制器故障 磁盘坏块 程序 bug 管理员误操作 文件系统损坏 软件升级重启 OOM 杀进程 数据中心火灾

WAL 防范的不只是断电,而是广义的“状态被中断或破坏”。

例如数据库进程因为空指针崩溃:

text
事务已经返回提交成功 数据页还在 Buffer Pool 数据页尚未落盘

只要进程重启,内存中的修改就消失了。即使机器从未断电,仍然需要 WAL 来恢复。

5. 即使不恢复,WAL 也可能承担其他功能

现代数据库中的 WAL 往往不仅用于崩溃恢复。

复制

主节点把 WAL 发送给副本:

text
主库 WAL → 从库重放

没有 WAL,就需要采用其他变更传播机制。

时间点恢复

需要恢复到某个时刻:

text
基础备份 + WAL → 恢复到误删数据之前

增量备份

WAL 可以表示自上一次备份之后发生了哪些变化。

CDC

下游系统通过日志捕获数据变更:

text
INSERT UPDATE DELETE

审计

分析谁在什么时候修改了哪些数据。

所以即使数据库本身永不崩溃,只要还需要这些功能,某种形式的变更日志仍然有价值。不过它不一定必须是传统的物理 WAL。

6. 可以不用 WAL,但必须选择其他写入策略

通常数据库使用 WAL,是因为它采用了:

text
steal + no-force

No-force

事务提交时,不强制把所有修改过的数据页写入磁盘。

因此需要 redo:

text
提交了但数据页没落盘 → 用 WAL 重做

Steal

事务提交前,允许脏数据页被刷到磁盘。

因此需要 undo:

text
未提交的数据已经进入磁盘 → 必须能够撤销

如果不用 WAL,也可以改成非常保守的策略。

Force

事务提交时,把所有修改页都刷盘:

text
COMMIT → 所有数据页持久化 → 返回成功

这样不需要 redo WAL,但提交性能通常较差,因为要写很多随机数据页。

No-steal

未提交事务修改的数据页绝不刷盘。

这样不需要处理磁盘中的未提交修改,但需要更大的内存,而且长事务会制造很大压力。

因此:

text
no WAL + force + no-steal

理论上是可行的,但性能和资源利用率通常不好。

WAL 的价值正是在于让数据库可以安全地采用高性能的:

text
steal + no-force

7. 纯内存数据库也不一定需要 WAL

如果数据库明确接受:

text
进程一停,数据全部消失

那么完全可以不用 WAL。

例如某些:

  • 缓存系统;
  • 临时分析系统;
  • 测试数据库;
  • 可从上游重新计算的状态;
  • 无状态服务的本地索引。

这种系统可以认为数据是“可丢弃的”。

但如果纯内存数据库承诺持久性,它仍然需要某种机制:

text
命令日志 WAL 快照 复制 共识日志 NVRAM 持久化

例如内存数据库可能通过定期快照加命令日志恢复。

8. 一个简单对比

有 WAL

text
事务修改 100 个随机数据页 生成 10 KB 顺序 WAL 刷一次 WAL 返回提交成功 数据页后台慢慢写

优点是提交快。

无 WAL,但要求持久性

text
事务修改 100 个随机数据页 提交前必须把 100 个数据页全部写入磁盘 确认全部成功 返回提交成功

这样虽然不需要 redo WAL,但提交延迟可能大得多。

无 WAL,也不要求持久性

text
修改内存 返回成功

最快,但数据库一旦停止,数据可能全部丢失。

结论

在你提出的绝对理想条件下:

数据库确实可以不使用 WAL,因为不存在任何崩溃恢复需求。

但需要进一步区分:

  • 不发生系统故障:可以不要 redo WAL;
  • 事务仍可能回滚:仍需要 undo、MVCC 或 copy-on-write;
  • 事务也永不失败:连 undo 都可以省掉;
  • 需要复制、备份、CDC 或审计:仍然需要某种变更日志;
  • 需要持久性但不用 WAL:通常就必须在提交时直接刷数据页,性能可能更差。

因此,WAL 不是数据库逻辑上绝对必需的组件,而是在现实世界存在故障、同时又追求高性能持久化时,最重要、最普遍的工程方案之一。

Pregunta

数据库的MVCC机制实现的隔离级别和基于两段锁实现的隔离界别,在条件几乎相同的情况下,哪一个性能会更高呢

Pensamiento

Thought for a couple of seconds

Respuesta

在“硬件、数据规模、事务逻辑和隔离级别尽量相同”的情况下,通常 MVCC 的综合吞吐量会更高,尤其是读多写少、长读事务较多、并发度高的场景

但这不是绝对结论。若是高冲突写入、事务很短、读很少,两段锁 2PL 反而可能更简单、更稳定,甚至更快

核心区别可以概括为:

MVCC 用“多版本”减少读写阻塞;
2PL 用“锁”保证事务之间的顺序。


一、为什么 MVCC 通常更快

MVCC 最大的优势是:

text
读不阻塞写 写通常也不阻塞读

例如事务 T1 正在更新一行:

sql
UPDATE account SET balance = 900 WHERE id = 1;

同时事务 T2 查询:

sql
SELECT balance FROM account WHERE id = 1;

在 MVCC 中,T2 可以读取旧版本:

text
旧版本:1000 新版本:900,尚未提交

T2 不需要等待 T1。

而在基于锁的 2PL 中,如果 T1 持有排他锁,T2 通常需要等待:

text
T1:X lock T2:等待 S lock

因此在高并发读写混合场景中,MVCC 能显著减少:

  • 锁等待;
  • 死锁;
  • 上下文切换;
  • 锁管理开销;
  • 长查询阻塞短事务的情况。

这也是 PostgreSQL、Oracle、MySQL InnoDB 等主流数据库大量使用 MVCC 的原因。


二、同一隔离级别下的典型性能比较

1. Read Committed

MVCC 实现

每条语句读取一个已提交版本:

text
SELECT 开始 → 找到当前可见版本 → 不需要对普通读加共享锁

2PL 实现

读取时通常需要共享锁:

text
SELECT → 加 S 锁 → 与 X 锁冲突

在读写并发较高时,一般:

text
MVCC Read Committed > 2PL Read Committed

这里 MVCC 通常优势明显。


2. Repeatable Read

MVCC 可以通过事务快照实现:

text
事务开始时确定快照 后续查询都读取该快照可见的版本

读事务通常不需要长期持有行锁。

而严格锁式实现可能需要一直持有共享锁到事务结束:

text
BEGIN SELECT 某行 → 加 S 锁 一直持有 COMMIT → 释放

如果事务较长,2PL 会明显降低并发。

一般而言:

text
MVCC Repeatable Read 通常明显优于锁式 Repeatable Read

尤其是:

  • 长报表查询;
  • 在线分析;
  • 读写混合;
  • 大事务扫描。

3. Serializable

这里比较就复杂了。

严格两段锁 S2PL

通常通过以下锁保证串行化:

  • 行锁;
  • 范围锁;
  • 谓词锁;
  • next-key lock;
  • 锁一直持有到事务结束。

优点是:

text
事务如果成功提交,通常不会再因为串行化冲突而回滚

缺点是:

  • 阻塞多;
  • 死锁多;
  • 并发度较低;
  • 范围查询可能锁住大量数据。

MVCC Serializable

MVCC 实现串行化有几种方式:

  • Snapshot Isolation 加冲突检测;
  • Serializable Snapshot Isolation;
  • 乐观并发控制;
  • 提交阶段验证。

优点是:

  • 运行期间阻塞少;
  • 读事务并发度高。

缺点是:

  • 事务可能运行很久后,在提交时被判定冲突并回滚;
  • 高冲突场景下重试开销很大。

所以在低冲突场景:

text
MVCC Serializable 往往吞吐更高

在高冲突场景:

text
严格 2PL 可能更稳定

因为 2PL 往往是让事务提前等待,而 MVCC 可能让事务先执行,最后再失败。


三、MVCC 并不是没有代价

MVCC 的性能优势是用版本管理换来的。

1. 版本链遍历

同一行可能有多个版本:

text
v5 → v4 → v3 → v2 → v1

查询时需要根据事务快照判断哪个版本可见。

如果一行被频繁更新,版本链很长,读取开销会增加。


2. 垃圾回收

旧版本不能永久保留,需要回收:

text
PostgreSQL:VACUUM Oracle:undo 清理 InnoDB:purge

如果存在长事务,旧版本不能及时删除,会产生:

  • 表膨胀;
  • 索引膨胀;
  • 缓存污染;
  • 更多磁盘 I/O;
  • 版本链变长。

例如某个读事务运行数小时,它可能要求数据库一直保留很老的版本。


3. 更新放大

MVCC 通常不能简单地原地覆盖一行。

例如 PostgreSQL 更新一行时,通常会创建新 tuple:

text
旧 tuple 保留 新 tuple 插入

这会带来:

  • 更多写入;
  • 更多 WAL;
  • 更多空间占用;
  • 后续清理成本。

4. 写写冲突仍然存在

MVCC 只能有效缓解读写冲突,不能消除写写冲突。

两个事务同时更新同一行:

sql
T1: UPDATE account SET balance = balance - 100 WHERE id = 1; T2: UPDATE account SET balance = balance - 50 WHERE id = 1;

仍然需要:

  • 行锁;
  • 写入意向;
  • 原子比较;
  • 提交冲突检测。

因此 MVCC 并不是“无锁”。

更准确地说:

MVCC 通常让普通读不需要阻塞式共享锁,但写操作仍然需要并发控制。


四、2PL 的性能优势在哪里

两段锁的主要优势不是“并发度高”,而是:

text
机制直接 冲突立即显现 版本管理成本低 提交结果更确定

1. 没有版本链

锁式原地更新可能只保留当前值:

text
balance = 1000 直接改为 900

不必维护很多历史版本。

读路径可能更短:

text
定位记录 检查锁 读取当前值

如果没有锁冲突,这个过程可能非常快。


2. 高冲突时可能更合适

假设 100 个事务都在更新同一行。

在 2PL 中:

text
第一个事务获得锁 其他事务排队 依次执行

虽然等待明显,但浪费较少。

乐观 MVCC 可能出现:

text
100 个事务都执行大量逻辑 最终只有少数提交 其他事务回滚并重试

此时 CPU 和 I/O 被大量浪费。

所以在高冲突热点场景下,悲观锁有时性能更好。


3. 短事务场景

如果事务极短,例如:

sql
UPDATE counter SET value = value + 1 WHERE id = 1; COMMIT;

持锁时间只有几十微秒。

此时 2PL 的锁阻塞成本不一定很高,而 MVCC 的版本生成、提交时间戳、垃圾回收反而可能构成额外负担。


五、不同负载下的结论

读多写少

例如:

text
95% SELECT 5% UPDATE

通常 MVCC 更好。

原因:

  • 大量读无需加共享锁;
  • 读写不互相阻塞;
  • 长查询不会阻塞短写事务。
text
推荐:MVCC

读写比较均衡

例如:

text
50% SELECT 50% UPDATE

如果数据访问比较分散,MVCC 通常仍然更好。

如果更新集中在少量热点行,优势会减弱。


写多读少

例如:

text
10% SELECT 90% UPDATE

这时 MVCC 的核心优势“读写不阻塞”没有那么重要。

同时会产生:

  • 大量旧版本;
  • 垃圾回收压力;
  • 写放大;
  • undo 或版本链膨胀。

如果写冲突不高,MVCC 仍可有不错性能。

如果写冲突非常高,2PL 可能更好。


极高热点冲突

例如所有事务更新同一个库存值:

sql
UPDATE inventory SET stock = stock - 1 WHERE product_id = 100;

这里真正瓶颈是写写串行化。

无论 MVCC 还是 2PL,同一行最终都无法真正并行修改。

两者区别是:

text
2PL:提前阻塞排队 MVCC:可能执行后检测冲突或等待行锁

通常 2PL 或显式悲观锁更加可控。


长读事务

例如一个报表查询运行 30 分钟,同时 OLTP 事务不断更新。

MVCC 的优势非常明显:

text
报表读旧快照 OLTP 继续更新

2PL 下如果报表持有大量共享锁,可能严重阻塞写入。

但是 MVCC 也会付出代价:由于长事务存在,旧版本不能回收。


六、一个直观例子

假设:

  • 100 个并发事务;
  • 每个事务读取 100 行;
  • 更新其中 1 行;
  • 事务持续 100 ms。

2PL

每次读都可能加共享锁:

text
100 个事务 × 100 行 = 大量锁对象

若某事务更新一行,其他读写可能等待。

锁管理器需要维护:

  • 锁表;
  • 等待队列;
  • 死锁图;
  • 锁升级;
  • 唤醒机制。

MVCC

普通读取主要做可见性判断:

text
版本创建时间 < 快照时间 版本删除时间 > 快照时间

只有更新行需要写锁或写冲突控制。

在这种负载下,MVCC 通常吞吐量更高。


七、延迟角度也不同

不能只看平均吞吐量,还要看延迟分布。

2PL

可能出现:

text
大部分事务很快 少数事务因为锁等待,延迟很高

尾延迟可能较差。

MVCC

读延迟通常更加稳定,但写事务在串行化隔离下可能:

text
执行完成 提交时冲突 回滚重试

从业务角度看,这类重试也会导致高尾延迟。

所以:

  • 2PL 的失败表现通常是“等待”;
  • 乐观 MVCC 的失败表现通常是“回滚”。

八、不能简单认为 MVCC 和 2PL 完全对立

现实数据库经常混合使用两者。

例如 InnoDB:

  • 普通一致性读使用 MVCC;
  • 更新使用行锁;
  • SELECT ... FOR UPDATE 使用排他锁;
  • Serializable 下可能让普通读加锁;
  • 范围冲突使用 next-key lock。

PostgreSQL:

  • 普通读使用 MVCC;
  • 写写冲突使用行级锁;
  • DDL 使用表级锁;
  • Serializable 使用 SSI 冲突检测。

Oracle:

  • 查询使用多版本一致性;
  • 写写冲突仍使用锁。

所以现代系统更常见的是:

text
MVCC 负责读写并发 锁负责写写冲突和结构性冲突

而不是完全不用锁。


九、严格公平比较需要控制哪些条件

用户所说的“条件几乎相同”仍然需要明确几个变量:

  1. 隔离级别是否完全相同;
  2. 是否要求真正 Serializable;
  3. 读写比例;
  4. 热点冲突比例;
  5. 事务长度;
  6. 是否有长查询;
  7. 版本垃圾回收成本是否计入;
  8. 回滚重试成本是否计入;
  9. 锁等待时间是否计入;
  10. 数据是否主要在内存中;
  11. 是否使用范围查询;
  12. 是否存在大量更新或删除。

例如拿:

text
MVCC Snapshot Isolation

和:

text
Strict 2PL Serializable

直接比较是不公平的,因为前者可能不是真正的串行化隔离,保证更弱,当然通常更快。

必须比较:

text
MVCC Serializable vs 2PL Serializable

才有意义。


十、总体判断

可以给出一个经验结论:

场景通常更有优势
读多写少MVCC
大量并发查询MVCC
长读事务MVCC
读写分散、冲突低MVCC
写多、版本更新频繁差距缩小
高热点写冲突2PL 或悲观锁
极短事务、低并发2PL 可能更简单高效
低冲突 SerializableMVCC/SSI 通常吞吐更高
高冲突 Serializable2PL 可能更稳定

最精确的结论是:

在现代通用 OLTP 负载中,MVCC 通常具有更高的并发性能,主要是因为它消除了绝大多数读写阻塞。
但在高写冲突、热点集中、短事务、读操作很少的场景中,2PL 可能更高效,因为它避免了版本维护和大量事务失败重试。

数据库设计中经常采用的最佳实践不是二选一,而是:

text
MVCC + 写锁 + 冲突检测

即用 MVCC 优化读,用锁处理不可避免的写冲突。

Compartir este Q&A