上一篇看到 COMMIT 的时候,最后停在了 WAL Flush。
一笔事务产生的 WAL Record 已经进入 WAL Stream,Commit Record 也拿到了自己的 LSN。同步提交下,PostgreSQL 要等:
Flush LSN >= Commit LSN
然后才把 COMMIT 返回给客户端。只要 WAL 持久化到了这里,即使数据库下一秒崩溃,恢复这笔事务需要的信息也已经留下来了。
但一条 INSERT 显然不只改了 WAL。Shared Buffers 里的 Data Page 也变了。
INSERT
|
+--------+--------+
v v
Dirty Page WAL Record
|
v
WAL Flush
|
v
COMMIT
注意左边那条线。COMMIT 返回的时候,Dirty Page 完全可能还待在 Shared Buffers 里。
这不影响事务提交。要是每次 COMMIT 都顺便把事务碰过的 Heap Page、Index Page 一起 fsync(),WAL 也就没那么好用了。PostgreSQL 允许事务持久化和 Data Page 写回走不同的节奏。
于是上一篇的问题还剩下一半:
WAL 已经可以相信了,Data Page 呢?
COMMIT 之后,Page 还可以慢慢来
Page 被修改以后,通常只是 MarkBufferDirty(),然后继续待在 Shared Buffers 里。什么时候真正写回,由后面的系统活动决定:可能是 Checkpointer,可能是 Background Writer,也可能某个 Backend 想复用一个 Buffer Slot,结果挑中的 victim 还是 dirty,只好自己把它写出去。
所以:
transaction commit time
!=
data page writeback time
这一点 WAL 已经替我们兜住了。Data Page 真正写出去之前,PostgreSQL 要保证对应 WAL 已经先持久化,也就是熟悉的 WAL-before-data。FlushBuffer() 里大致就是:
recptr = BufferGetLSN(buf);
if (permanent)
XLogFlush(recptr);
这部分没什么神秘的。比较值得继续追的是源码里的下一句:
Now it's safe to write the buffer to disk.
它说的是现在可以安全地开始写,还没说这个 Data Page 已经 durable 了。
不过在继续跟着 Data Page 往下走之前,先处理一个很容易冒出来的疑问。
WAL Flush 为什么不用再等一次 fsync
前面一直说 Flush LSN >= Commit LSN 以后,WAL 就可以相信了。到了 Data Page 这里,却要强调 write() 只是把数据交给文件系统,后面还得另外 fsync()。
WAL 难道不也经过文件系统和 kernel page cache 吗?
区别在 Flush 这个词本身。PostgreSQL 内部把 WAL 的两个进度分开记录:
XLogwrtResult
|
+-- Write 已经 write 到文件系统的位置
+-- Flush 已经完成持久化同步的位置
所以 XLogFlush() 并不是“把 WAL 写出去”这么简单,它要求目标 LSN 真正推进到 Flush 这个边界。XLogWrite() 负责把 WAL Buffer 写到文件,如果还需要同步,再调用 issue_xlog_fsync()。
具体怎么同步,由 wal_sync_method 决定:
fdatasync 先 write,再 fdatasync()
fsync 先 write,再 fsync()
fsync_writethrough
open_datasync WAL 文件以 O_DSYNC 打开
open_sync WAL 文件以 O_SYNC 打开
区别主要在同步的范围:fsync() 连文件 metadata 一起落盘,fdatasync() 只保证读回文件内容所必需的部分(WAL 是追加写,通常够用,Linux 上默认就是它);O_SYNC 类似 fsync(),O_DSYNC 类似 fdatasync(),fsync_writethrough 则额外要求写穿磁盘缓存。
fsync、fsync_writethrough、fdatasync 会分别走到 pg_fsync_no_writethrough()、pg_fsync_writethrough() 或 pg_fdatasync();open_datasync 和 open_sync 在 open() 时已经把同步语义放进了 write 本身,issue_xlog_fsync() 直接返回。
实现可以不同,对上层的承诺是一样的:
XLogFlush(0/500) 返回
|
v
0/500 以前的 WAL
已经跨过 PostgreSQL 要求的持久化边界
所以 WAL 不是不用管 fsync,而是已经把它算进 Flush 里了。“WAL Flush” 是 PostgreSQL 定义的一层语义,不等于某个固定的系统调用。
把两条路径放在一起看,差别就清楚了:
WAL
write --> sync --> Flush 完成
|
v
当场 durable
Data Page
write --> kernel page cache
|
+-- 登记 sync responsibility
|
v
Checkpointer 以后再结账
同步 COMMIT 的语义本来就是:Commit Record 的 WAL 必须 durable,然后才返回。如果 XLogFlush() 也只写到 kernel page cache 就返回,那刚告诉客户端成功的事务,掉电以后仍然可能消失。
WAL 没有把 durability responsibility 推迟到 Checkpointer,是因为这里没有那个时间窗口:客户端正在等 COMMIT。它能做的优化不是延迟,而是合并——多个并发事务的 Commit LSN,可以由同一次 WAL sync 一起覆盖,也就是上一篇看到的 group commit。
这里默认 fsync = on,且事务走同步提交。fsync = off 时 PostgreSQL 不会强制 WAL 落到稳定存储;synchronous_commit = off 时,事务也不会停下来等自己的 WAL Flush。
write() 返回以后,还差什么
回到 Data Page 这条线。FlushBuffer() 最终会把 Page 交给 Storage Manager。Buffer Manager 操作的是 relation + fork + block number,到了 smgr / md.c,才把它转换成实际文件。
因为一个 relation 在磁盘上并不天然对应一个文件。先有不同的 fork:
main
fsm
vm
init
一个 fork 太大以后还会继续拆 segment,默认每个 relation segment 是 1GB。所以一个 block 最终大概经过:
relation / fork / block
|
v
segment number
segment offset
|
v
actual file
大表在目录里可能看到:
base/16384/24576
base/16384/24576.1
base/16384/24576.2
Page 是按 block 修改的,但真正需要持久化的对象,最后落到了某个 relation segment 文件上。mdwritev() 完成文件写入以后,这些 bytes 已经离开 PostgreSQL 的 Shared Buffers,交给了操作系统。
问题就在这里。在普通 buffered I/O 下:
write() success
并不能直接推出:
data is on stable storage
它很可能还在 kernel page cache。如果现在只是 Backend 正常退出,没什么问题,内核以后还会继续把这些脏数据写出去;如果现在直接掉电,就不是一回事了。
所以 PostgreSQL 在 write() 完成以后,并没有把这个文件当成已经处理完。它又记了一笔账。
写完以后,留下一笔 Sync 责任
md.c 写完 relation segment 后,会走到 register_dirty_segment()。里面会构造一个 FileTag,可以先把它理解成:
FileTag
|
+-- relation
+-- fork
+-- segment
+-- sync handler
然后:
RegisterSyncRequest(&tag, SYNC_REQUEST, false);
这一步没有继续写 Page,也没有立刻 fsync()。它只是告诉 PostgreSQL:这个文件被改过了,还有一笔 sync 责任没有完成。
我觉得这里是整条链里挺关键的一步。平时容易把 Page Flush 想成:
Dirty Page
|
v
write()
|
v
done
PostgreSQL 实际上更接近:
Dirty Page
|
v
write()
|
v
kernel page cache
|
+-- record sync responsibility
数据已经写了,责任还没有结束。
为什么记的是 Segment,不是 Page
假设一个 Backend 连续写了这些 Page:
block 42
block 51
block 78
block 103
而它们都属于 relation A / main fork / segment 0。最终同步时,并不需要做:
block 42 -> fsync
block 51 -> fsync
block 78 -> fsync
block 103 -> fsync
fsync() 面对的是文件。一次对 segment 文件的同步,可以覆盖此前针对这个文件的一批写入。
所以 Page 写回和最终持久化之间,粒度其实变化了一次:
Buffer Manager
| relation / fork / block
v
md.c
| relation / fork / segment
v
sync layer
| FileTag
v
这也解释了为什么 FileTag 里关心的是 segment identity:写的时候,我们关心哪一页;同步的时候,我们关心哪一个文件还欠账。
这笔账为什么不能直接记一个 fd
继续看 fd.c,还有一层挺有意思。
PostgreSQL 代码里常见的 File,并不是 Unix fd。它更像当前 Backend 私有 VfdCache 里的一个索引:
File
|
v
VfdCache[file]
|
+-- fileName
+-- flags
+-- mode
+-- fd
真正的 OS fd 在里面。而且这个 fd 此刻甚至可能是关闭的。
一个 Backend 可能访问很多 relation segment、临时文件以及其他文件,不可能无限占用真实 fd,所以 fd.c 会做 VFD cache。文件暂时不用时,可以关闭 OS fd:
logical file still exists
|
+-- OS fd may be closed
以后再次访问,再根据文件名和 flags 重新 open()。
这意味着 Backend A 手里的 File 或 fd,都不是一个适合跨进程传递的身份。Checkpointer 不能收到 fd = 37,然后就知道 Backend A 到底想让它同步什么——37 只对那个进程有意义。
所以当持久化责任准备从 Backend 转交给 Checkpointer 时,PostgreSQL 又回到了一个稳定的物理 identity:relation + fork + segment,也就是 FileTag。
这几层 identity 放在一起看挺有意思:
Buffer Manager
| relation / fork / block
v
md.c
| relation / fork / segment
v
fd.c
| backend-local File / fd
v
sync request
| cross-process FileTag
v
Checkpointer
看源码时会觉得怎么同一个文件到处都有一种表示,真到了跨模块、跨进程交接责任的时候,它们各自的边界就比较清楚了。
为什么不让写 Page 的 Backend 顺手 fsync
最简单的实现当然是 write() 之后紧跟着 fsync(),谁写的谁负责到底。实现容易理解,前台性能就不好看了。
比如同一个 segment 很短时间内连续发生:
write block 42
write block 51
write block 78
write block 103
如果每一次都跟着一次 fsync(),很多 I/O 等待其实是重复的。而从最终 durability 的角度,只要在合适的时间把这个 segment 统一同步一次,就能覆盖前面这一批写入。
所以正常情况下,Backend 做的是:
Backend
|
+-- write page
|
+-- RegisterSyncRequest()
|
v
Checkpointer
前台把“这个文件还需要同步”这件事交出去,然后继续工作。Checkpointer 收到请求以后,会把它们吸收到自己的 pendingOps。如果多个 Backend 都写了同一个 segment:
Backend A -- block 42 --+
|
Backend B -- block 78 --+--> relation A / main / seg 0
|
Backend C -- block 91 --+
到了最终 sync 阶段,这些修改可以收敛成一条:
relation A / main / seg 0
|
v
needs sync
这里保存的与其说是一堆 I/O task,我觉得更像一组还没有兑现的 durability responsibility。
责任可以转移,但不能凭空消失
有个小细节特别能说明这件事。
md.c 登记普通 dirty segment 时调用:
RegisterSyncRequest(&tag, SYNC_REQUEST, false);
这个请求正常情况下会进入 Checkpointer 的队列。但如果没有成功交出去,PostgreSQL 不能简单地:
queue full
oh well
然后继续。调用方会自己完成同步。
也就是说,这里的正确性规则其实和“必须由 Checkpointer fsync”没太大关系。真正重要的是:
somebody must own the sync responsibility
正常情况:
Backend
|
+-- transfer responsibility
|
v
Checkpointer
转交失败:
Backend
|
+-- still owns responsibility
|
v
fsync by itself
谁来做可以变化,但这笔责任不能没人管。
这种设计我挺喜欢:性能优化没有另外造一套 correctness 规则,只是允许责任在进程之间迁移,但任何时候都得知道现在是谁拿着它。
最后由 Checkpoint 来结账
这些 pending sync 最终会在 Checkpoint 汇合。
Checkpoint 平时经常被简单描述成“定期把 Dirty Page 刷到磁盘”。这个模型干活没有问题,但把 Crash Recovery 放进来后,还需要再补一句:Checkpoint 最终还要建立一个新的恢复起点。
假设最后得到 Redo LSN = 0/1000,未来数据库崩溃以后,可以从这里开始重放 WAL。这句话其实带着一个隐含条件:0/1000 之前那些已经不准备重放的历史,对应的数据文件状态必须足够可靠。
否则会发生一个很直接的问题。假设:
WAL 0/900
|
+-- modified Page A
Checkpoint 最后告诉 Recovery 从 0/1000 开始,但是 Page A 只是 write() 到了 kernel page cache,还没有真正同步下来。这时候机器掉电,重启后磁盘上的 Page A 仍然是旧版本;Recovery 又从 0/1000 开始,0/900 那次修改已经在起点以前。前后就断开了。
因此 Checkpoint 里不仅有写 Dirty Buffer 的阶段,后面还有:
write dirty buffers
|
v
collect sync requests
|
v
ProcessSyncRequests()
|
v
FileSync()
当前 PostgreSQL 的 CheckPointGuts() 在 Buffer 写出以后,会进入 ProcessSyncRequests(),处理这一轮需要完成的文件同步。
所以 Checkpoint 遇到 I/O 压力时,问题也不只是“要写多少 dirty page”,还有“还有多少个 dirty file 需要 sync”。Page Write 和 File Sync 是两个阶段,新的恢复边界要依赖后者真正完成。
Data Page 到底什么时候可以相信
到这里再回答开头的问题,会发现它没有一个像 Commit LSN 那么干净的单点答案。
WAL 有一个很好理解的条件:Flush LSN >= Commit LSN,满足以后,这笔事务需要的恢复信息已经持久化。Data Page 不需要在 COMMIT 时达到同样的状态,它可以继续 dirty,也可以很晚才写回。
从一个 Page 的角度看,更像经过两个边界。第一个很熟悉:
WAL for this page is durable
|
v
page is safe to write
第二个才是这篇真正想看的:
page write
|
v
segment becomes dirty
|
v
sync responsibility recorded
|
v
file sync completed
|
v
checkpoint may rely on it
这里还需要注意一个边界。Checkpoint 也不是要求“到这个瞬间,数据库最新的所有 Data Page 都必须在磁盘上”。Checkpoint 进行过程中,业务还在运行,新的事务还会继续修改 Page、生成新的 WAL,这些发生在 Redo Point 之后的变化,本来就可以留给未来的 WAL Recovery。
Checkpoint 要保证的是另外一件事:
Redo Point
|
old WAL ----------+---------- new WAL
|
data files can safely
continue from here
也就是说:既然未来准备从这个 Redo Point 开始恢复,那么磁盘上的 Data File 必须能够从这里继续接住后面的 WAL。
这时候 WAL 和 Data Page 的两种“可信”就有点不一样了。事务提交关心的是“如果现在崩溃,这笔事务还能不能找回来”;Checkpoint 关心的是“如果以后从这个位置开始恢复,磁盘上的 Data File 能不能接得住”。
最后把整条路径压在一起,大概是:
modify page
|
+----------+----------+
v v
Dirty Page WAL Record
| |
| v
| WAL Flush
| |
| v
| COMMIT
|
| ... maybe much later ...
v
FlushBuffer()
|
v
write
|
v
relation segment dirty
|
v
RegisterSyncRequest()
|
v
Checkpointer pendingOps
|
v
ProcessSyncRequests()
|
v
fsync
|
v
new checkpoint recovery boundary
上一篇追的是:WAL 到什么位置以后,PostgreSQL 才敢告诉客户端 COMMIT 成功?这次顺着 Data Page 往下走,会发现它完全不用着急追上 WAL。WAL 先替它兜着。
Page 可以晚一点写,多个 Page 的写可以汇总到同一个 segment,同步责任也可以从 Backend 转交给 Checkpointer。WAL 和 Data Page 面对的是同一个 write() != durable,区别只在结账时间:同步提交要求这笔 WAL 的持久化责任在 COMMIT 返回前已经结清,它可能由别的 Backend 或 WAL Writer 提前完成,也可能和其他事务合并完成,但不能留到 COMMIT 返回以后;Data Page 则允许先把 sync 责任记下来,等 Checkpoint 把恢复起点往前推时再集中处理。
所以源码里的 Now it's safe to write the buffer to disk. 其实只是这段路的起点。从 safe to write 到 safe to rely on,中间还隔着一次责任交接和一次 fsync()。而 WAL 给 Data Page 留出来的,正是这段可以慢慢走完的距离。