ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

WAL日志内容形态解析:物理日志、逻辑日志与混合日志的区别与选型

WAL日志内容形态解析:物理日志、逻辑日志与混合日志的区别与选型 1. 从“日志”这个老古董说起做数据库的没有人能绕开 WALWrite-Ahead Logging预写式日志。就算你只是个写业务代码的只要跟 MySQL、PostgreSQL、SQLite 打过交道大概率也听过“先写日志再写数据”这句话。但很多人对 WAL 的理解停留在“保证崩溃不丢数据”这个层面再往深了问——日志里到底记了什么为什么不同数据库的日志文件长得完全不一样同一个 WAL 机制为什么有的数据库恢复快、有的恢复慢——能答上来的人就不多了。我第一次被 WAL 的内容变种问题卡住是在看 PostgreSQL 源码的时候。当时想搞明白一条 UPDATE 语句到底在 WAL 里写了几条记录结果发现它跟 InnoDB 的 redo log 完全是两套逻辑。同样是 WAL一个偏物理一个偏逻辑恢复时的行为、占用的空间、并行度表现差异大到像两个物种。这篇文章想跟你聊的就是 WAL 里记录内容的几种“变种”物理日志、逻辑日志、以及介于两者之间的混合形态。搞懂这几者的区别你再看任何数据库的持久化实现都会有“原来如此”的通透感。适合谁来读如果你是做数据库内核开发的这篇文章能帮你厘清不同日志设计的取舍如果你只是 DBA 或者后端开发搞懂这些也能解释很多线上怪现象——比如为什么磁盘 IO 不高但 commit 很慢为什么崩溃恢复的时间忽长忽短为什么某些数据库的从库复制带宽占用高得离谱。这些问题的根源往往就在 WAL 记录的内容形态上。另一个让我觉得这事值得写的原因是近几年“日志即数据”的理念开始流行。有些系统直接把 WAL 当消息队列用有些云数据库把 WAL 作为跨区域同步的唯一依据。当 WAL 从“内部机制”变成“对外接口”之后记录内容的选择就不再只是内核工程师的私事而是直接影响使用者成本和体验的设计决策。简单交代一下背景WAL 的核心思路是把“变更数据的动作”提前落盘。数据页可以留在内存里慢慢刷但日志必须先写到稳定的存储上。这样宕机之后数据库可以根据日志重放操作把内存里还没来得及落盘的数据找回来。整件事听起来简单但“日志记录什么”这个问题一旦深入进去就会发现里面全是取舍。2. 先搞清楚 WAL 里的“内容”都有什么形态2.1 物理日志记“页面上哪个字节变成了什么”先看最直观的一种物理日志。物理日志记录的是数据页面的“像素级”变化。你说得笼统点它就像给页面拍了一张“补丁图”哪个页、哪个偏移量、原来的字节是什么、现在的字节是什么。以 InnoDB 的 redo log 为例它记录的跟页相关的内容本质上偏向物理格式。比如某条 redo 记录里写着“表空间 5页号 100偏移量 2048写入 16 字节的新数据”。恢复的时候数据库拿到这条记录直接定位到对应页面的对应位置把字节覆盖上去就行了不需要理解这 16 个字节的业务含义。物理日志最大的优势在于恢复简单且速度快。因为重放操作就是一次内存拷贝memcpy不需要解析任何语义。这是一个极其重要的特性尤其在崩溃恢复阶段——那时候数据库可能连索引结构都处于半损坏状态如果还要做语义解析风险就大了。物理日志完全绕开这个问题它不关心页面上是不是一棵 B 树不关心哪个槽位是空闲的只关心字节层面的覆盖。但物理日志也有明显的短板**它记录的是“变化后的结果”。**如果你对同一个页面连续修改了 10 次物理日志就得记 10 次这个页面的变化。不管业务上是不是同一条记录也不管中间过程是否有意义。这就导致物理日志在写入密集场景下体积膨胀得比较快。换句话说物理日志“忠厚老实”但有时候有点“啰嗦”。2.2 逻辑日志记“做了什么操作”逻辑日志走的是另一条路。它不记录页面字节而是记录“语义动作”。最典型的例子就是 MySQL 的 binlog 和 PostgreSQL 的 WAL 中的逻辑复制内容。逻辑日志会写“在表 t 上执行 UPDATESET age 30 WHERE id 5”或者“向表 t 插入一行各列值分别是什么”。恢复或者同步的时候数据库需要先在目标端把这条语句执行一遍从找到对应行、加锁、更新、索引维护……整个流程都要在目标端重走一遍。这个开销显然比物理日志大不少。但这正因为如此运营成本高且恢复路径复杂所以原本数据库内核里的崩溃恢复很少用纯逻辑日志——性能代价太大。逻辑日志的主战场是数据复制和订阅分发。因为它的语义清晰目标端可以是一个异构的数据库甚至是一套消息队列系统。你可以在主库执行一条 UPDATE然后通过逻辑日志把这条变更翻译成 JSON推送到千里之外的 Kafka 里完成数据集成。这一能力是物理日志不具备的。逻辑日志的问题很直接重放开销大、强依赖上下文。试想一下如果一条逻辑日志写着“更新 id 5 的行”但目标端的表里恰好没有 id 5 的行比如因为之前的逻辑错误漏了一条 DML重放就会失败。它不像物理日志那样无脑覆盖它是一种“需要环境配合”的日志。2.3 物理逻辑混合日志大家都在用的“中间态”听名字就知道这种形态试图鱼与熊掌兼得。它既不像物理日志那样完全无脑也不像逻辑日志那样全语义化。典型的做法是定位方式是物理的内容是逻辑的。具体来说一条物理逻辑混合日志可能会这样描述在表空间 5、页号 100物理定位这一页上执行“插入一条数据key 5”逻辑内容。恢复时数据库找到对应的页然后在这个页上执行一次插入操作——而这个插入操作本身需要理解页面的内部结构比如空闲空间在哪里、槽位如何维护所以它又带有逻辑性。PostgreSQL 的 WAL 就是这么干的。你去看它的 XLOG 记录会发现很多记录的格式是“哪个文件、哪个页、哪个位置”但具体操作是“在页面某处插入一个 tuple”这种偏逻辑的动作。这样设计的好处是既能准确定位到页又能在页内做更紧凑的记录——不用像纯物理日志那样把整页变化都写进去。这种混合形态的恢复速度介于前两者之间但它有一个非常独特的优势WAL 记录体积小。因为页面的物理布局可能变化很大但逻辑变化的描述可能很稳定。而且它天然适合在页级别加锁、在页级别进行并发控制因此主流的现代数据库——PostgreSQL、Oracle、SQL Server——的内核恢复日志都选择了这种混合路线。2.4 三种形态的本质差异我用一个生活化的比方来总结。物理日志像“监控录像”记录的是每个画面像素的变化回放的时候照着覆盖就行但数据量巨大。逻辑日志像“文字剧本”记的是角色做了什么动作、说了什么台词回放需要重演一遍但剧本可以寄给别人在别处舞台上演。物理逻辑混合日志则像“分镜脚本”——精确到哪个场景、哪台机位物理定位但要演员在这个场景里做具体动作逻辑内容。三者之间的取舍本质上是在回答三个问题回放时是否需要理解上下文恢复速度是快还是慢日志能不能被用来做跨系统同步维度物理日志逻辑日志混合日志记录内容页面字节变化语义操作物理定位逻辑操作恢复速度最快直接覆盖最慢需重放执行中等需要页内解析日志体积容易膨胀通常最小与页结构相关上下文要求无任何状态下都能重放强依赖外部状态依赖页的基本结构跨系统复制几乎不可能非常合适较难但部分支持典型代表InnoDB redo log 的部分场景MySQL binlogPostgreSQL WAL这张表建议你收藏起来。以后不管是看源码还是排查问题遇到疑惑先想想你面对的这个日志属于哪个变种方向就清晰不少。3. 深入源码一个页面更新的多形态记录前面说的还是概念层面这段我们来点实在的。用一个具体场景看看一条UPDATE语句如何在不同日志机制里被转译成不同形态的记录。3.1 在 PostgreSQL 里的发生过程PostgreSQL 的 WAL 体系是我认为最能体现混合日志思想的设计。看一条 UPDATE 的 WAL 记录其实要理解它的“页面级逻辑操作”到底怎么编码。PostgreSQL 的每个 WAL 记录由XLogRecord头部加上若干XLogRecordData块组成。对一条 UPDATE 来说通常会有以下内容一个xl_heap_update结构体描述这是更新操作、旧 tuple 的 offset、新 tuple 的 offset旧 tuple 的数据如果配置了整行日志或旧 tuple 的 key新 tuple 的完整数据如果需要热更新HOT update还包含索引信息的逻辑描述。恢复时PostgreSQL 通过 WAL 记录里记录的页面编号找到对应的 buffer然后调用heap_xlog_update()函数。这个函数会解析xl_heap_update结构体在页面上执行一次“逻辑更新”找到旧 tuple 的位置把新 tuple 插入页面并调整页面的空闲空间、行指针、事务信息等。你看定位是“物理”的精确到一个 buffer 页但页内的行为是“逻辑”的调用的是 heap 层的操作逻辑而不是纯粹的字节覆盖。这种设计有一个让我很佩服的地方它允许页面的物理布局在版本升级后发生变化但只要逻辑结构不变旧的 WAL 记录依然可以在新版本上正确回放。PostgreSQL 大版本升级时数据文件不需要做不可逆的转换这跟它的 WAL 内容形态有直接关系。3.2 在 MySQL/InnoDB 里的发生过程对比看 InnoDB 的 redo log。InnoDB 里同样有“逻辑”成分比如MLOG_REC_UPDATE这类 redo 类型记录的也是“在哪个索引、哪个页的哪个位置做一次更新”并携带更新后的字节流。但它的操作更偏向物理——恢复时通过MLOG_REC_UPDATE直接在页的指定偏移处覆盖数据并更新页内 header 里的信息。从这个意义上说InnoDB 的 redo 比 PostgreSQL 的 WAL 更“物理”一些。这里顺手讲一个很多人困惑的点那么 InnoDB 的 redo log 和 MySQL 的 binlog 是什么关系答案很明确前者是物理或偏物理的崩溃恢复日志后者是逻辑的复制日志。两个日志服务于不同目的内容形态也因此不同。InnoDB 崩溃恢复时需要快速重放 redo而 binlog 需要被下游解析成任意格式所以它是纯逻辑的。理解了内容变种你就理解为什么 MySQL 要维护两套日志而不是合成一套。3.3 从恢复时间看变种的实际影响日志形态直接影响恢复时长。这个结论很多人不知道。纯物理日志的恢复时间跟“修改了多少页”成正比逻辑日志的恢复时间跟“有多少操作”以及“操作复杂度”成正比混合日志介于两者之间但通常更接近物理日志。假设一次批量 UPDATE 扫描了 10 万行每行都修改一个字段。如果某字段恰好让每行的字节长度不变物理日志可能只需要记一个页面变化恢复时间极短如果每行分散在不同的页上物理日志就得记 10 万个页的变化恢复时间会明显拉长。而在逻辑日志里这条批量 UPDATE 可能只记一条 SQL重放却要重新执行一遍全表扫描。你看在恢复时间这件事上没有绝对最优只有场景适配。4. 我踩过的坑WAL 变种引发的一系列“诡异”现象做技术的光知道概念不算会真正有价值的是那些在实战中踩到的坑。下面几个问题都是在理解了 WAL 内容变种之后才想通的也说明选错日志形态会带来什么后果。4.1 “为什么从库负载比主库高那么多”最常见的就是复制延迟和从库高负载问题。如果你用逻辑日志做复制比如 MySQL 的 binlog 复制从库需要回放 SQL走一遍完整执行链路——索引查找、加锁、更新缓冲池。这意味着从库的 CPU 成本通常比主库还要高因为主库可以利用 redo log 的物理特性做优化而从库却在实打实地执行语句。我在实际运维中见过不止一次有人嫌 MySQL 的并行复制效率低想换成物理复制方案。但这里的问题不在于复制方案本身而在于 binlog 的内容形态决定了它只能按逻辑回放。你没法要求一条 binlog 像 redo log 那样“无脑覆盖”。物理复制可以做到从库分担主库的读写压力逻辑复制则会放大主库的 CPU 消耗。这没有好坏之分但你必须提前有个心理预期。4.2 “为什么 WAL 文件那么大但事务才几条”另一个常见的坑是 WAL 体积膨胀。PostgreSQL 默认 WAL 文件大小 16MB如果大量更新集中在同一批页面但每次更新都需要记录完整的旧行REPLICA IDENTITY FULL时会包含整行旧值那么逻辑日志或者混合日志的体积就会非常夸张。有个真实的案例我之前给一个系统做优化时发现 WAL 产生速度远超预期。排查到最后原因是我们把一张表的REPLICA IDENTITY设置为FULL导致即使是只更新一个字段旧行全量也要写进 WAL以便逻辑复制时能唯一定位旧记录。这个细节属于“逻辑日志变种对 WAL 体积的放大效应”。如果你的表有逻辑复制需求而且经常更新尽量保证主键或唯一索引能使用默认的REPLICA IDENTITY DEFAULT省下的是真金白银的磁盘和 IO 带宽。4.3 “崩溃恢复时为什么 PostgreSQL 有时候要先跑 checkpoint 再回放”这个问题来自对混合日志的理解偏差。PostgreSQL 的崩溃恢复不是简单地从 WAL 第一条记录开始无脑回放。它会先做一个“部分检查点”处理找到最后一个检查点的位置然后从那个点开始回放。回放的时候因为 WAL 记录是“物理定位逻辑操作”数据库能直接从对应页面开始处理不需要全局解析。但这里有个细节在 crash recovery 的早期阶段也就是pg_control读取之后PostgreSQL 需要先找到有效的重做起点。这个起点可能不是文件的起点而是文件内部的一个偏移。如果你不太理解 WAL 记录的物理布局可能会在某一天看到日志里出现redo starts at 5/7A23B0F8这样的信息然后心里嘀咕为什么不是从 5/0 开始原因就是混合日志的“物理定位”属性让系统可以精确跳过不需要重做的部分。4.4 关于“日志回放会不会产生相同结果”的认知偏差“日志是幂等的吗”这个问题也常被误解。物理日志通常是幂等的——同一页面覆盖两次最终结果一样。逻辑日志不一定——同一页更新两次可能第二次会因为有上下文依赖而出错。所以逻辑复制通常要保证“至少一次”语义可能产生重复数据物理复制则可以大大简化幂等性考量。如果你在设计一套基于 WAL 的同步方案务必先想清楚下面这个问题**你的下游拿到 WAL 记录后是做一次幂等重放还是需要精确的变更事件**如果选错了内容变种后续的兼容会非常痛苦——比如你用逻辑日志的记录格式去做物理重放下游会遇到“找不到旧行”的尴尬反过来用物理日志做事件流下游会收到一堆完全看不懂的字节补丁。5. 实操如何在真实数据库里观察 WAL 的内容变种直接把内容看懂胜过看十篇文档。我建议你亲自做两个实验感受不同日志形态。5.1 用 pg_waldump 剖析 PostgreSQL 的混合日志PostgreSQL 自带一个神器叫pg_waldump可以解析 WAL 文件并打印出可读的记录内容。操作步骤如下找到当前 WAL 文件的位置SELECT pg_current_wal_lsn(), pg_wal_lsn_diff(pg_current_wal_lsn(), 0/0);使用pg_waldump打印一段 WAL 内容pg_waldump -p /var/lib/postgresql/14/main/pg_wal/ -s 0/16AAD20 -t 30执行一条简单的 UPDATEBEGIN; UPDATE account SET balance balance - 100 WHERE id 1; COMMIT;再次用pg_waldump查看新产生的 WAL 记录。你会看到类似这样的输出简化版rmgr: Heap len (rec/tot): 70/ 70, tx: 0, lsn: 0/16AAE20, prev 0/16AAD20, desc: UPDATE off 2, blkref #0: rel 1663/16384/16390 blk 1里面这一行UPDATE off 2, blkref #0: rel 1663/16384/16390 blk 1就是混合日志的典型标记它首先明确告诉你这条记录属于哪个表空间1663、哪个数据库16384、哪个表16390、哪个块blk 1这是物理定位然后告诉你操作类型是UPDATE且是页内的偏移off 2这是逻辑操作。你不妨在此时对比一下 InnoDB 的 redo 输出会更直观地感受什么叫“混合”。5.2 用 mysqlbinlog 解析 binlog 的逻辑日志形态如果你有 MySQL 环境可以这么观察逻辑日志FLUSH LOGS; UPDATE account SET balance balance - 100 WHERE id 1; FLUSH LOGS;然后使用mysqlbinlog查看 binlog 文件mysqlbinlog --base64-outputdecode-rows -vv mysql-bin.000002你会看到一条 UPDATE 被解析成了类似这样的格式### UPDATE test.account ### WHERE ### 11 ### 2500 ### SET ### 2400注意WHERE后面跟的是旧值SET后面是新值这就是纯逻辑日志的形态——下游拿到这条记录需要自己去定位旧行然后在目标端执行更新。它跟 PostgreSQL 的blkref那种物理定位完全不是一个风格。5.3 如何选择系统的日志内容形态如果你在设计自己的存储系统或者在做数据库选型可以通过下面几个问题来定日志形态如果首要诉求是崩溃恢复速度建议走物理日志或混合日志。纯物理日志实现最直接混合日志体积更优。如果要做跨库/跨系统的数据分发逻辑日志是绕不开的需要在源端额外生成一份逻辑变更流。如果要同时兼顾恢复和复制那么像 PostgreSQL 那样核心 WAL 用混合日志再借助解码插件将混合日志翻译成逻辑日志是当前最成熟的架构。如果业务负载是大量更新同一行热点行物理日志会非常划算多次变化可合并逻辑日志反而每次都要记录操作细节浪费空间。下面这个决策表可以帮你快速对齐方案需求推荐形态需要考虑的问题崩溃恢复极速物理日志文件膨胀IO 压力大跨系统订阅/分发逻辑日志复制链路成本高幂等性复杂恢复复制的平衡混合日志逻辑解码实现复杂度最高数据完整性审计逻辑日志需要保留完整前后像只考虑最低实现成本物理日志恢复依赖页面结构兼容6. 一些配置实践的经验建议不同日志形态下数据库的配置参数也完全不同。以下是我的一些经验按场景梳理。6.1 同样叫 WAL参数却天差地别PostgreSQL 的wal_level就体现了对逻辑能力的开关控制。minimal模式下WAL 只记录物理恢复所需内容某些操作如建临时表可能不产生日志logical模式下WAL 必须记录足够的信息让逻辑解码器工作代价是 WAL 体积明显增大。MySQL 的binlog_format也很典型STATEMENT格式记录 SQL 文本ROW格式记录行变更前后值MIXED则根据语句类型动态选择。如果选择STATEMENTWALbinlog体积小但复制不安全比如NOW()这种函数在主从执行结果不同如果选ROW复制绝对安全但 binlog 体积可能暴涨几倍。理解了“逻辑日志变种”的内核你就能理解为什么官方会越来越倾向ROW格式——因为它虽然体积大一些但语义最精确。6.2 体积控制的关键理解记录内容里哪些是“冗余”WAL 体积优化的大方向就是去掉冗余内容。物理日志里如果页面变化集中在页的前半段而日志把整页都写一遍那就是最大的冗余混合日志里如果旧 tuple 的完整镜像不是必须的就没必要记全量逻辑日志里如果能用主键代替整行旧值就尽量只记主键。PostgreSQL 里有一个参数直接控制这一点wal_compression。开启后对整页写做压缩能极大降低 WAL 伴随频繁 checkpoint 时的体积。MySQL 里对应的思路是 binlog 的binlog_row_image参数可以设置minimal让 row 格式只记录主键和变化的列显著减小 binlog 体积。这些参数的本质都是在减少日志记录的“冗余描述”。6.3 从库复制效率的优化思路如果你用的是逻辑复制从库的优化方向往往是提升并行度。PostgreSQL 的逻辑复制可以通过设置max_parallel_apply_workers_per_subscription提高回放并行度。MySQL 的并行复制则依赖slave_parallel_workers和slave_parallel_type。但我要强调的是逻辑日志回放的瓶颈往往不在 CPU而在锁和 IO 的竞争。如果多条逻辑记录回放时产生锁等待并行度再高也没用。所以逻辑复制的核心优化方向是尽量让同一行的变更路由到同一个 apply worker减少跨 worker 的锁冲突。这同样是对“逻辑日志”内容特征的深度应用。7. 日志形态的未来从 WAL 到“逻辑解码”聊点稍微前沿的。这几年有个趋势大家不再把 WAL 只看作“崩溃恢复工具”而是当成数据流底座。PostgreSQL 的逻辑解码logical decoding机制本质上是把混合日志翻译成逻辑日志然后对接各种数据管道。Citus、Debezium、Flink CDC 等工具核心都是围绕这一能力展开的。这条路线之所以成立依赖的正是 WAL 内容里那部分“逻辑”信息。如果没有 WAL 中的逻辑内容解码器将无从翻译如果 WAL 里纯物理字节下游不可能理解变更语义。所以混合日志不仅是内核的实现细节更是现代数据生态的重要接口。当我看到有人用 Flink CDC 监听 PostgreSQL 的变更事件把它变成流式管道时我会提醒自己这条链路的起点是 WAL 记录内容里的“逻辑变种”。如果某天 PG 的 WAL 改成纯物理格式整个 CDC 生态就塌了。再补充一个我的观点未来存储引擎做日志设计时“内容形态”的选择权重会越来越高。嵌入式场景SQLite需要极简日志云数据库需要跨区域同步的 WAL流式数据平台需要变更为事件——各有各的“变种偏好”。不存在一种万能的日志格式。8. 最后的几个实用提醒文章快结束了但我还想留几个具体的、不写进文档的“私房笔记”不要迷信物理日志。恢复是快但如果你用了压缩文件系统或者奇怪的块设备物理日志的块级拷贝可能会踩到意想不到的性能坑因为覆盖操作和文件系统的 COW 机制可能冲突。关于 checkpoint 频率。崩溃恢复时长跟你日志形态强相关。混合日志恢复时可以“精确跳页”所以 checkpoint 可以相对宽松但如果你的日志是纯物理的checkpoint 过于频繁会让 WAL 里堆满整页镜像恢复性能反而下降。这里的平衡点需要结合具体工作负载压测不要只看默认值。逻辑日志的“元数据同步”从不是免费的。当你用 binlog 或者 PG 的逻辑复制传到下游下游往往需要同步表结构变化DDL。因为逻辑日志依赖表结构来解析如果结构变了旧日志可能直接失效。很多生产事故发生在凌晨 DDL 之后——下游消费逻辑日志突然报错排查半天发现是表结构变更导致的兼容问题。如果你正在设计自己的 WAL 格式把我的建议放在你桌面上不要把物理日志打散在多个文件里尽量按 LSNLog Sequence Number排序成一个连续的流。逻辑日志可以容忍乱序物理日志一旦乱序崩溃恢复的复杂度会指数级上升。坦白说WAL 的内容变种这个话题越往深挖越觉得它是数据库设计的一把钥匙。物理、逻辑、混合这三者的选择贯穿了崩溃恢复、主从复制、数据集成、流式计算等无数场景。搞懂了它你看数据库的眼神都会变得不一样——从“别人告诉我怎么配置”变成“我理解它为什么这么设计”。如果你也是在某个数据库上踩过日志的坑或者对文中某个细节有不同看法欢迎在评论区留个言咱们继续把这话题往深里聊。
返回列表