干数据库运维这一行,最怕半夜接到电话说主库宕机了。那会儿所有人都在问你同一个问题:数据有没有丢?而我心里清楚,真正决定答案的,往往不是备份脚本写得有多勤,而是这套数据库的WAL机制到底靠不靠谱。WAL(Write-Ahead Logging,预写式日志)是关系型数据库持久性和崩溃恢复的基石,几乎所有主流数据库底层都靠它在兜底。这篇东西我想以我实际接触和调试过的一套云原生数据库产品——大云海山数据库(代号He3DB)为例,把WAL机制从头到尾拆开讲清楚。它不仅是单机数据库里的“日志文件”,在存储计算分离的云架构下,WAL已经变成了整个系统的确定性来源。如果你正在做DBA、数据库内核开发,或者公司正在选型云原生数据库,这篇内容应该能帮你少走不少弯路。
1. 为什么先写日志:WAL的底层逻辑
1.1 数据文件与日志文件的“先后博弈”
很多刚接触数据库内核的人会有一个直觉:事务提交了,把数据写到磁盘数据文件里,不就持久化了吗?理论上是没错,但问题出在“怎么写”这个细节上。磁盘上一张数据页通常是8KB甚至更大,而一次事务可能只修改其中几十个字节。如果系统在写入数据页的过程中突然掉电,这个页面可能停留在“写了一半”的状态——前半段是新值,后半段还是旧值,数据文件就处于一个既不是旧版本也不是新版本的中间态。
这时候靠什么恢复?靠日志。WAL的核心思路是:任何对数据页的修改,都必须先把“我要做什么修改”以追加写的方式记录到日志文件里,然后才能去改数据页。日志是顺序写的,速度快,而且天然是append-only的;数据页是随机写的,速度慢,而且容易写一半。先保证“修改意图”安全落盘,数据页哪怕写坏了,也能根据日志重做一遍。
打个比方。餐厅点餐,服务员先在你桌上放一张手写点菜单,然后后厨才开始炒菜。万一中途停电,后厨的锅里还有半熟的菜没关系,凭着点菜单就能知道这份菜该做成什么样。WAL就是数据库的点菜单,数据文件就是后厨的锅。点菜单必须先写,而且要写得足够详细,后厨才能动火。
1.2 WAL记录里到底写了什么
我见过不少人以为WAL记录就是一个简单的“SQL语句”。实际上,WAL记录里存的是物理级别的修改描述,不是逻辑SQL。一条典型的WAL记录包含以下几部分:
- LSN(Log Sequence Number,日志序列号):单调递增的日志位置标识,用于定位和排序。
- 事务ID:标识这条修改属于哪个事务。
- 资源管理器/页面标识:指明修改的是哪个数据文件、哪个页面。
- 页面内偏移量和变更内容:具体改了页面里哪个偏移位置,旧值是什么、新值是什么,或者一个能够重做修改的delta描述。
- 如果是full_page_writes(整页写)机制开启,还会包含修改前的完整页面镜像。
为什么要存物理描述而不是SQL?因为WAL要服务的不仅仅是事务回滚,还包括崩溃恢复、主从复制、时间点恢复(PITR)。如果只记SQL,那么恢复时必须重新解析、重新执行,性能差而且可能产生不确定结果。物理级别的WAL可以直接对页面做确定性重放,恢复速度快,行为也可控。
这里要顺手澄清一个常见误区:WAL不等于undo log。WAL解决的是“持久性和重做”的问题,崩溃后把已提交事务的修改重新应用;而undo log解决的是“回滚和MVCC”的问题,事务主动回滚或者需要读取旧版本数据时,靠undo把修改撤销。He3DB继承了这条经典的区分思路,日志层面不会把undo和WAL混在一起。
1.3 没有WAL或WAL设计不当时会发生什么
不用WAL的数据库不是没有,但基本上都把自己的应用场景限制得很窄。没有WAL,你会有三个绕不过去的坑:
第一个坑是崩溃恢复不可靠。数据页写一半崩溃,系统无法判断这个页面是旧还是新,只能做全量校验和修复,或者直接丢数据。单机小数据量还能靠全量备份硬扛,集群环境下根本玩不转。
第二个坑是主从复制做不了。主备复制最优雅的实现方式就是传输WAL,备库拿到WAL后重放。如果每个节点各自为战,数据的一致性视图就无法保证,主备切换一定会出乱子。
第三个坑是时间点恢复(make PITR)基本做不了。你想把数据库恢复到某个误操作之前的时刻,没有WAL就无法精确回放到那个时间点。He3DB这类云原生数据库,PITR能力直接依赖于WAL的连续性和完整性,WAL留得不够,恢复窗口就得缩水。
2. He3DB的WAL机制:从单机日志到云原生日志服务
2.1 传统单机WAL的局限性
如果你熟悉PostgreSQL或者MySQL的InnoDB,你会知道传统WAL是“本地优先”的。WAL文件写在计算节点的本地磁盘上,崩溃恢复时从本地盘读日志;主从复制时,主库通过网络把WAL传给备库;备份时把数据文件和WAL归档传到备份存储。
这套方案在单机时代没有问题,但在云原生存储计算分离架构下,痛点非常明显。本地盘不是可靠的持久化介质,一旦计算节点宕机,本地盘上的WAL可能随节点一起消失;如果业务要求RPO=0,就不能依赖“本机日志+异步同步到备机”这种弱一致方案。另外,单机WAL的恢复范围受限于本节点,而云数据库常常需要在另一个计算节点上拉起新实例,这时新节点要去哪里找日志?又怎么保证日志和数据页之间的顺序一致?
这些矛盾不是靠“把本地盘换大一点”能解决的,必须重新设计WAL的存储和消费模型。He3DB的答案,是让WAL不再依附于某个计算节点,而是成为一个独立的、分布式的日志服务。
2.2 存储计算分离下的WAL新定位
在He3DB的架构里,计算节点与存储节点是分离的。计算节点负责SQL解析、执行计划、事务管理,但它不直接持有数据文件的持久化副本;数据文件落在分布式存储集群上。这个架构下,WAL的地位发生了本质变化:日志不再仅仅是数据页的“保护伞”,它本身成了系统的第一公民。
这里有一个关键设计理念,行业里叫“日志即数据库”(log is the database)。意思是,可以把分布式存储上的数据页看作WAL日志经过重放后的“物化视图”。真正不可丢失、不可篡改的真相,是那份连续、有序、多副本冗余的WAL日志。哪怕某个数据页的所有副本都坏了,只要WAL日志还在,系统就能从最近的检查点重新构造出这个页面。
He3DB在实现上把WAL做成了一条日志分片流。计算节点产生WAL后,会发送给底层的日志服务;日志服务负责把日志写入多个副本,并保证副本之间的顺序一致。设计上通常采用多数派确认(quorum ack)或者强同步副本确认,避免单点故障造成日志丢失。计算节点收到日志服务的持久化确认之后,才向客户端返回“事务提交成功”。这样的链路,把传统“刷本地磁盘”的语义,平移到了“远端日志服务多副本持久化”的语义上,RPO才有可能做到零丢失。
2.3 He3DB的WAL生命周期全景
把WAL的整个生命周期串起来看,在He3DB里大概是这样的:
- 事务在计算节点内执行,内存中的缓冲页被修改。
- 每条修改都会生成对应的WAL记录,记录先缓存在内存中的WAL缓冲区。
- 事务提交时,系统把这些WAL记录按LSN顺序打包,通过内部网络发送给底层日志服务。
- 日志服务写入多个副本,多数派确认后返回成功。
- 计算节点收到确认,向客户端返回提交成功。
- 在此之后,存储节点会根据WAL记录,在后台把变更应用到数据页上。
- 数据页无需立即刷盘,因为已有WAL兜底;当检查点推进之后,某些早期WAL分片可以被安全回收。
和传统单机WAL相比,这套流程有几个显著差异。一是提交延迟里多了网络RTT和日志服务写入耗时,所以He3DB对group commit(组提交)的依赖比单机数据库更强,后面我会专门讲。二是崩溃恢复不再由单个进程扫描本地日志,而是新计算节点从日志服务拉取WAL重新回放。三是WAL的回收策略变得更复杂,既要考虑检查点位置,也要考虑日志分片的冗余策略、备份保留策略和复制槽消费进度。
3. 核心环节实操:从事务提交到崩溃恢复的完整链路
3.1 一次普通事务的WAL旅程
我用一个最简单的UPDATE语句来走一遍流程,假设要把某张用户表的余额字段从100改成200。
第一步,事务开始,分配事务ID。第二步,在内存缓冲页里找到目标数据页,修改余额字段。第三步,在WAL缓冲区里生成一条WAL记录,记录下“页面X上偏移Y处的值需要改成200”,并附上LSN。这时候数据页改了,日志在内存里,都还没落盘。
第四步,事务提交。这里出现了一个关键选择:要不要等WAL持久化之后再返回提交成功?如果等待,就是“同步提交”;如果不等待,就是“异步提交”。He3DB默认面向金融级和政企级场景,一般要求同步提交,并且要等日志服务的多数派副本确认。
第五步,WAL记录通过网络批量发送到日志服务。这里有个工程细节:一批提交中的多个事务的WAL记录会合并成一次网络请求,减少交互次数。底层的日志服务写入多个副本,完成确认。
第六步,计算节点向客户端返回“提交成功”。第七步,后台存储节点按序回放WAL,把数据页上的余额更新成200。注意,这个回放不一定要在提交返回之前完成,它可以在提交后慢慢做;因为WAL已经安全持久化了,即使回放还没完成时发生宕机,新的计算节点也能基于WAL把数据页重建出来。
实操中,控制这个链路的关键参数有三个:
- wal_level:控制WAL里记录的信息丰富程度。He3DB至少要开到replica级别,才能支持归档恢复和备库回放;如果需要logical解码做数据同步,需要logical级别。
- synchronous_commit:决定提交时等不等WAL持久化。on表示等本地和日志服务都确认;off表示不等。remote_write和remote_apply是面向备库的中间级别,适合主备架构中牺牲一点延迟换取更强一致性。
- commit_delay和commit_siblings:这是组提交的两个配套参数。commit_delay让提交稍微“等一等”,攒一批事务再一起刷WAL;commit_siblings表示至少攒够多少个并发事务才启用这个延迟。在He3DB这种网络往返延迟较高的架构下,组提交对吞吐量的提升非常明显,我实测下来开启后的事务吞吐能提升近乎一倍。
3.2 检查点与WAL回收:怎么控制日志不把磁盘撑爆
WAL是追加写的,如果不回收,日志分片迟早会占满存储。回收的时机不由“日志写了多久”决定,而是由检查点(checkpoint)决定。
检查点做的事情,是确保某个LSN之前的日志所对应的数据页变更,已经全部应用到数据文件上。一旦检查点完成,这个位置之前的WAL就失去了“重做保护”的意义,理论上可以安全回收。所以检查点越频繁,可回收的WAL窗口越小,恢复时需要重放的日志越少;但检查点本身有IO成本,频繁做会拖慢业务。
在He3DB里,检查点的语义还要稍微修正一下。因为数据页的落盘是存储层在后台持续执行的,所以检查点更多是“在日志流中标记一个安全的截断点”,并告诉日志服务:这个LSN之前的日志分片可以进入回收流程。这个设计和传统“把所有脏页刷到磁盘”的检查点有区别,理解这一点对排查日志堆积问题很重要。
实操中的调优经验是:先把max_wal_size调到一个能让常规峰值业务跑得动的值,然后观察检查点触发频率。如果每个检查点间隔时间远小于checkpoint_timeout,说明WAL生成速度太快,日志堆积风险高,需要增大max_wal_size或优化写入放大;如果检查点间隔时间长期超过checkpoint_timeout好几倍,说明数据页落盘压力大,但恢复时间可能会变长。两者需要平衡。
3.3 崩溃恢复的REDO流程
He3DB的崩溃恢复,表面上是“启动数据库”,底层其实就是“重放WAL,把数据库恢复到崩溃前的一致状态”。
恢复流程从读取控制文件开始,找到最近一次完成的检查点LSN。然后从日志服务拉取这个LSN之后的WAL分片,按顺序重放。重放时,系统会判断每条WAL记录对应的事务是否已提交:如果已提交,就执行重做;如果未提交,就跳过或者配合undo做回滚。这个判断依赖事务提交状态记录(CLOG),所以恢复时日志和提交状态记录必须配套。
重放不是简单的一条条执行。He3DB在存储节点上做了并行回放,把不同数据页的日志分发给多个回放线程并行处理,只有同一页面的日志才严格串行。这样能大幅缩短故障切换时间。但并行回放的坑在于页面依赖关系:比如一个事务先插入了一行,另一个事务随后更新了这行,两条日志在不同分片里并行重放时,如果顺序错了就会产生不一致。工程上的常见解法是按页面ID和LSN做双重重排序,同一页面内部严格按LSN执行,不同页面之间允许并行。这也解释了为什么WAL记录里一定要有页面标识和LSN。
还有一个必须开的参数是full_page_writes。如果在检查点之后,某个页面第一次被修改时没有写整页镜像,而是只写了delta,那么一旦这个页面本身已经损坏(比如存储硬件的某个扇区坏了),恢复时手里只有delta,连原始页面的底子都没有,根本无从修起。开启full_page_writes后,每个页面在检查点后的第一次修改会带上完整页面镜像,代价是WAL体积暴涨,但换来的恢复可靠性是完全值得的。我见过不少团队为了省那点日志空间关掉这个参数,结果一次存储故障直接导致页面无法恢复,教训非常惨痛。
4. 调优实践:关键参数与监控指标
4.1 WAL相关参数速查
下面的参数表以He3DB兼容的PostgreSQL生态为基础,实际部署时请以你手头版本自带的文档为准。我把关键参数、作用、默认情况以及我的推荐值整理成了一张表:
| 参数 | 作用 | 默认/常见值 | 推荐配置与理由 |
|---|---|---|---|
| wal_level | 控制WAL记录的信息层级 | replica | 至少replica;有逻辑复制需求用logical |
| synchronous_commit | 提交时是否等待WAL持久化确认 | on | 金融/核心业务用on;高吞吐分析业务可评估off |
| full_page_writes | 检查点后首次修改页面时写整页镜像 | on | 保持on,除非有备用页面修复方案 |
| checkpoint_timeout | 强制检查点时间间隔 | 5min | 建议5~15min,核心库倾向更低值 |
| max_wal_size | WAL累积到多大触发检查点 | 1GB | 按业务写入量估算,至少覆盖峰值阶段 |
| max_slot_wal_keep_size | 复制槽允许保留的最大WAL量 | -1 | 要设置上限,防止备库故障导致主库日志堆积 |
| commit_delay | 组提交等待延迟 | 0 | 在写密集场景适当调高,结合commit_siblings |
| commit_siblings | 触发组提交的最小并发事务数 | 0 | 建议3~5,避免低并发时人为增加延迟 |
| wal_compression | 是否压缩WAL | off | 日志服务网络带宽紧张时开启,注意CPU开销 |
4.2 存储计算分离下的WAL性能特征
单机数据库里,提交延迟主要等于“把WAL刷到本地磁盘”的时间。而He3DB作为存储计算分离架构,一次提交的耗时是:本地内存拷贝耗时+网络发送耗时+日志服务多数派写入耗时。网络RTT这一个环节,就比本地磁盘慢了一个数量级。所以,如果按照单机的思路一条条提交,吞吐量会非常难看。
解决思路就是组提交。He3DB在WAL发送层会做攒批,多个事务的WAL记录合并成一个大包一次发给日志服务。你可以把组提交理解成地铁“高峰期多等几分钟再发车,一趟车装更多人”。commit_delay和commit_siblings配合起来控制“攒多久、攒多少人”。我自己的调优习惯是先看业务的并发事务数,如果平均并发都超过5个,commit_delay从0调到500微秒到1毫秒往往效果明显。
另外一个要理解清楚的概念是同步级别与RPO的关系。synchronous_commit=off时,主库返回成功但日志可能还没持久化,一旦节点瞬间宕机就会丢最近一批事务;local相当于只要本地日志落盘就算成功,适合数据最终由存储层异步复制的场景;on则要求日志服务确认持久化,RPO接近0。He3DB面向主备高可用时,我通常建议至少配到on,追求极端一致再加到remote_apply级别。
4.3 监控与告警:WAL不是“写完了就完了”
WAL机制在正常运行时不显山不露水,一旦出问题就是大事。所以日常监控里,我会关注这五个指标:WAL生成速率、WAL发送延迟、日志服务确认耗时、日志堆积量、回放进度。
如果你用的是兼容PostgreSQL生态的He3DB,可以通过类似下面的SQL拿到当前日志位置:
-- 查看当前WAL写入位置 SELECT pg_current_wal_lsn(); -- 查看WAL写入统计 SELECT * FROM pg_stat_wal; -- 查看复制槽的状态和保留的日志量 SELECT slot_name, active, restart_lsn, pg_wal_lsn_diff(pg_current_wal_lsn(), restart_lsn) AS wal_retained_bytes FROM pg_replication_slots;上面pg_wal_lsn_diff算出来的差值如果持续增长,说明有复制槽在拖住WAL的回收,这是日志堆积最常见的元凶。日志服务确认耗时可以通过日志服务自己的监控面板看,我一般会对p99耗时设置告警,阈值看网络环境,超过5毫秒就要关注了。
这里分享一个排查经验:WAL生成速率和业务写入量比例是固定的,如果某个时段WAL速率猛涨但业务QPS没变,大概率是发生了写放大。常见的原因包括:大量小事务频繁更新、full_page_writes在检查点后集中触发、或者某个表缺少索引导致更新扫描放大。这个比例异常是很好的提前预警信号,比业务报障早得多。
5. 常见故障与排查实录
5.1 WAL目录空间暴涨(日志回收不了)
现象:监控告警磁盘使用率超过80%,检查发现日志目录持续增长,checkpoint正常但日志就是不回收。
排查思路:先看有没有复制槽卡住。用上面给的SQL查pg_replication_slots,重点看active和restart_lsn。如果发现某个复制槽长期不活跃、restart_lsn离当前LSN很远,那就说明回收被它卡住了。然后是看归档是否失败,归档进程如果反复失败,日志也会只增不减。
解决办法:如果是不需要的复制槽,直接删除;注意先确认没有订阅端依赖它,否则删了会破坏数据同步。如果是归档目标有问题,先修复归档路径,再手动触发一次归档。对于已经堆积的日志,可以手动执行一次CHECKPOINT,把安全截断点往前推,但前提是日志消费端都已经跟上。
5.2 主库提交成功但切换后丢数据
这是最可怕的一种故障,业务侧明明收到了“事务成功”,主备切换后数据却少了。
排查思路:这种场景十有八九是synchronous_commit配置低于预期,或者日志服务的副本确认策略不满足多数派。比如配置成local,主库只确认本地落盘;一旦主库所在物理节点瞬间宕机,本地盘上的WAL不一定能完整保留。在He3DB架构里,还需要确认日志服务是否真的做了多副本确认,有些部署为了省成本只写了单副本,那就失去了冗余意义。
解决办法:把同步级别提高到on或remote_apply,并确认底层日志服务的副本数>=3,并且确认策略是多数派,而不是单副本成功。另外建议每个季度做一次真实的故障切换演练,直接在业务低峰期kill掉主库进程,观察RPO是不是真的为0。内存里算得再漂亮,都不如一次演练有说服力。
5.3 重启或FAILOVER后长时间卡在REDO
现象:节点拉起后一直处于恢复状态,业务迟迟不能对外服务,日志显示一直在重放WAL。
排查思路:首先要看“恢复窗口”有多大,也就是最近一次检查点到日志末尾之间的距离。如果这个距离非常大,说明检查点频率太低或者上次检查点还没完成就发生了故障。其次看full_page_writes有没有关闭。关了的话,恢复时需要从更早的页面版本结合delta重建,扫描量会翻倍。再看并行回放有没有生效,如果并行度被限制成1,恢复速度必然慢。
解决办法:调低checkpoint_timeout、调小max_wal_size,强制检查点更密集;确保full_page_writes开启;然后检查并行回放的线程数配置,通常建议与存储节点的CPU核数挂钩。
5.4 WAL文件损坏或校验失败
现象:数据库启动恢复时报错,提示某个WAL分片校验和失败,无法继续读取。
排查思路:先区分哪一层损坏。如果是日志服务里有多个副本,优先检查其他副本是否完整;如果只有单副本损坏,那就只能看有没有归档备份。千万别急着把损坏日志跳过去,跳过一个WAL片段可能导致后续所有页面不一致,这种残缺恢复风险极高。
解决办法:从健康副本或归档中提取缺失的WAL片段,放到指定位置后重新启动。如果所有副本都坏了,只能退回到最近一次全量备份+完整的WAL归档做恢复,丢失的数据范围等于“备份时间点到崩溃时间点之间未能归档的WAL”。这一点要在应急预案里写清楚,让业务方知道最坏情况。
把常见故障整理成速查表:
| 故障现象 | 可能原因 | 排查命令/手段 | 处理建议 |
|---|---|---|---|
| 日志堆积、磁盘告警 | 复制槽异常/归档失败/检查点少 | 查pg_replication_slots、归档日志 | 清理无用槽、修复归档、手动CHECKPOINT |
| 切换后丢数据 | 同步级别低/副本数不足 | 查synchronous_commit、日志副本数 | 调高同步级别、增加副本、故障演练 |
| 恢复时间过长 | 检查点间隔过大/full_page_writes关闭 | 查checkpoint时间线、查看参数 | 加快检查点、开启整页写、并行回放 |
| WAL校验失败 | 单副本介质损坏/极端断电 | 检查日志服务各副本 | 从健康副本/归档恢复,禁止盲目跳过 |
最后说一点我自己的体会。接触He3DB的WAL机制这么久,我最大的感受是:WAL设计的好坏,决定了整个数据库在最坏情况下的下限。单机时代你只要把本地日志刷好就行,到了存储计算分离的云架构里,WAL的生成、传输、持久化、回放、回收是一条完整的数据链路,任何一环没设计好,都会在故障时加倍还给你。我调试这套机制时最依赖的办法,就是反复做突发断电和kill进程模拟,只有真正经历过“日志服务多数派确认返回后主库瞬间消失”的场景,你才会理解同步级别、副本数量和确认策略为什么必须较真。建议正在评估或使用He3DB的朋友,也把故障演练放进日常运维计划,别等到报警电话把你从睡梦中叫醒才开始研究WAL。