MiniDB 的事务机制改过三版:先是“写日志 → 写数据文件 → 删日志”的重做日志,然后是 SQLite 式的 WAL,最后加上了读者标记。这篇按这个顺序讲,重点是第三版解决的问题:一直有读者的时候,日志怎么不无限变长。

第一版:重做日志#

提交时把所有脏页写进 <db>-wal,末尾一条带 CRC 的提交记录,fsync;再把页写回数据库文件,fsync;最后删掉日志。打开数据库时如果日志还在且提交记录完整,就重放一遍(幂等,重放中途崩溃下次再放)。简单可靠,但读者和写者互斥,每次提交两次 fsync。

第二版:WAL 与快照#

改成 SQLite 的 WAL 模式之后,提交只向日志追加页帧:每帧带页号、generation、链式 CRC32,最后一帧标为提交帧,fsync 日志就算提交完成,数据库文件根本不碰。

读事务开始时取“日志里最后一个提交帧”作为快照:读一页时先找快照之内该页最新的帧,没有才读数据库文件。于是读者不挡写者,写者也不等读者,同一个读事务始终看到一致的状态。链式校验和让任何半截尾巴或翻转的字节都使“该点之后”全部失效,崩溃后看到的永远是某个提交之后的完整状态。

日志要定期拷回数据库文件(checkpoint)再清空。第二版的 checkpoint 需要“没有任何读者”,只尝试、不等待。已知问题:只要一直有读者,checkpoint 就做不成,日志一直长下去。

第三版:读者标记#

SQLite 的办法是在共享内存(-shm)里放几个“读者标记”:每个读者登记自己快照的位置,checkpoint 只拷贝到所有在用标记里最小的那个为止——拷进数据库文件的帧,不会比任何读者的快照新,所以读者从文件里读到的页仍然属于它的快照。MiniDB 照做:

  • <db>-shm 里有已回填帧数(backfill)和 8 个读槽的标记。读者拿一个槽:优先共享一个标记恰好等于自己快照的槽,否则独占一个空槽写上标记再降为共享,否则共享标记最低的那个(标记 ≤ 快照即可,只是让 checkpoint 少拷一点)。
  • 拿到槽之后要再检查一次有没有更新的提交:拿槽之前,某个 checkpoint 可能已经按旧标记拷过了我们快照之后的帧。有就放掉重来。这是整个协议里最容易漏的一步。
  • 日志全部拷完时,新读者共享 0 号槽、只读数据库文件;0 号槽有人时 checkpoint 一帧都不拷。

光有这些还不够:checkpoint 能部分完成了,可日志文件只有在“全部拷完且没人用”时才能从头重用。SQLite 要求所有读者都在 0 号槽;读者首尾相接的时候这个条件永远不满足。MiniDB 多走了一步:快照里 ≤ backfill 的帧直接从数据库文件读(那一页在文件里恰好就是这一帧)。于是日志全部拷完之后,没有任何读者还需要它,写者可以开一个新 generation 从头覆盖,不必等持有旧标记的读者结束。读日志帧和扫描日志时持有一个共享的 WAL_READ 锁,重启时独占它,防止读到一半被覆盖。

最后一个坑是 checkpoint starvation:如果每个读事务都跨越提交(读得比两次提交的间隔还久),最小标记永远落后,日志永远拷不完。用 4 个读进程(每个读事务 2 ms 以上)和一个每秒约 4700 次提交的写者实测:日志涨到 48224 帧。解决办法:日志超过阈值、又拷不完时,写者(持有写锁,期间没有新提交,新读者的标记都是最新)最多等 0.1 秒让旧读者结束;等不到就继续写,并且在日志再增长一段之前不再等,免得一个长事务拖慢每一次写。结果是最长 1007 帧,吞吐只降约 5%。

代价也如实记下:每个自动提交的读语句多约 3 µs(拿放读槽和 WAL_READ 共 4 次 lockf,再读一次 -shm)。

锁:从 flock 到字节锁#

读槽要 9 把锁,加上写锁、WAL_READ,用 flock(只能锁整个文件)就得有十几个旁路文件。改成 POSIX 记录锁锁 -shm文件里的单个字节之后,旁路文件只剩 -wal 和 -shm。但记录锁属于进程而不是打开的文件:同一进程的两个连接不会互斥,关闭任何一个描述符还会丢掉整个进程在该文件上的所有锁。解决办法和 SQLite 的 unixInodeInfo 一样:每个进程按(device, inode) 只打开一次 -shm,进程内的连接之间用一张登记表仲裁。Windows 上最初用只有排他锁的 msvcrt.locking,照 SQLite 的老办法用“锁区间里任意一个字节 = 共享,锁整个区间 = 排他”来模拟;后来支持 SQLite 文件格式时发现它和sqlite3 的共享读锁互相冲突,改用 ctypes 调 LockFileEx——和 SQLite 自己在 Windows 上一样。

崩溃:比“进程死掉”更狠的模型#

原来的崩溃测试是在提交的每一步让进程死掉,此前的写入全部生效。断电不是这样:没 fsync 的写入可能丢,可能乱序落盘,一次 4 KB 的写可能只落下几个扇区。tests/test_crash_model.py 记录写者对数据库文件和日志的每次写入与截断,在随机的第 N 次操作处“断电”,每个文件 = 最后一次 fsync 时的内容 + 之后操作的随机子集(每次写入要么丢、要么完整、要么按512 字节扇区撕裂)。800 次随机崩溃、3075 个未同步操作被丢弃或撕裂之后,全部恢复到“最后确认的提交”或“正在进行的提交”。

它也发现了一处误报:checkpoint 扩展文件的写入被撕裂后,文件大小不是页大小的整数倍,原来直接报“文件损坏”——其实那些页都还在日志里,下次 checkpoint 会重写并截断。

对应的决策#

D34(重做日志)、D60–D63(WAL、快照、steal、checkpoint)、D92(读者标记)、D93(字节锁)、D94(崩溃模型)、D95(Windows)。


代码、测试与文中引用的设计决策(D 编号,见 DECISIONS.md):github.com/ayaya114514/MiniDB ↗;浏览器里直接试:MiniDB Playground ↗。