检查点记录一部分已经完成的工作,使数据库恢复不必把整个日志历史都视为待处理内容。具体操作取决于引擎:PostgreSQL 同步检查点要求的数据变更,建立重做起点;SQLite 的 WAL 模式则把符合条件的已提交页面帧从日志转移到主数据库文件。两者都不能简单当作事务提交的另一个名字。
坏掉的样子
已经请求检查点与检查点完整完成,是两个不同观察。SQLite 读者可能持有旧快照,阻止较新的日志帧写入主文件,或者阻止日志重置。被动检查点可以只做当前能够完成的部分,然后正常返回。若只看成功标志,不看日志总帧数与已完成帧数,就会漏掉尚未处理的工作,以及那个仍然阻止完成的读事务。
日志大小也要放在具体上下文中理解。文件可以继续复用原有空间,因此已分配大小不变时,检查点进度仍可能推进。PostgreSQL 完成检查点之后,也可能继续为归档或复制槽保留旧日志。为了让磁盘曲线变小而手动删除日志,会破坏恢复材料。应先核对引擎进度和保留义务,再判断文件大小究竟代表什么。
检查点间隔不是恢复时间的硬上限。重放速度、存储吞吐、写入形态以及实际崩溃位置,都会影响恢复耗时。更频繁的检查点可能减少未来重做,也可能增加平时写流量。把日志放到另一块设备,不会把所有数据页写入也搬过去,更不会使检查点的写盘成本自动消失。参数选择需要对应具体工作负载。
快速验证
把下面内容保存为 checkpoint_lab.py,使用普通的 python3 checkpoint_lab.py 运行,保持断言开启。脚本只使用标准库 SQLite 模块与私有临时文件。本次记录来自 Python 3.14.4 搭配 SQLite 3.53.0。比较结果时应同时查看运行时的引擎版本,因为 Python 版本与实际链接的 SQLite 版本并不是一回事。
import sqlite3
import tempfile
from pathlib import Path
with tempfile.TemporaryDirectory(prefix="xbcatm-checkpoint-") as directory:
path = Path(directory) / "test.db"
writer = sqlite3.connect(path, isolation_level=None, timeout=0.1)
reader = sqlite3.connect(path, isolation_level=None, timeout=0.1)
try:
assert writer.execute("PRAGMA journal_mode=WAL").fetchone() == ("wal",)
writer.execute("PRAGMA synchronous=FULL")
writer.execute("PRAGMA wal_autocheckpoint=0")
writer.execute("CREATE TABLE marker (id INTEGER PRIMARY KEY, value INTEGER)")
writer.execute("INSERT INTO marker VALUES (1,10)")
assert writer.execute("PRAGMA wal_checkpoint(TRUNCATE)").fetchone() == (0, 0, 0)
reader.execute("BEGIN")
assert reader.execute("SELECT value FROM marker").fetchone() == (10,)
writer.execute("UPDATE marker SET value=20 WHERE id=1")
assert writer.execute("SELECT value FROM marker").fetchone() == (20,)
passive = writer.execute("PRAGMA wal_checkpoint(PASSIVE)").fetchone()
blocked = writer.execute("PRAGMA wal_checkpoint(TRUNCATE)").fetchone()
print("passive", passive)
print("truncate_with_reader", blocked)
assert passive[0] == 0 and passive[1] > passive[2] >= 0
assert blocked[0] == 1 and blocked[1] > blocked[2]
assert Path(str(path) + "-wal").stat().st_size > 0
assert reader.execute("SELECT value FROM marker").fetchone() == (10,)
reader.execute("COMMIT")
completed = writer.execute("PRAGMA wal_checkpoint(TRUNCATE)").fetchone()
print("truncate_after_reader", completed)
assert completed == (0, 0, 0)
assert Path(str(path) + "-wal").stat().st_size == 0
assert reader.execute("SELECT value FROM marker").fetchone() == (20,)
print("fresh_reader", 20)
finally:
reader.close()
writer.close()
初始截断检查点把值十写入主文件,并重置日志。随后一个读者显式开启事务,读取十;另一个连接在关闭自动检查点之后,把值二十提交。写者立刻就能读到二十,而旧读者仍然需要自己的原始快照。这个顺序用真实连接建立约束,不依赖线程恰好在某个微小时间窗口发生竞争。
本次被动检查点得到三个数字零、一、零:没有报告阻塞失败,日志里存在一帧,尚未完成任何一帧。断言刻意检查帧数之间的关系,而不是要求所有引擎构建都产生完全相同的数字。这里关键的是被动调用成功返回,却仍然留下待处理工作,不能把第一个零直接当成“全部检查点工作已完成”。
接下来的截断请求在短暂忙等待后,返回一、一、零。第一个字段表示请求的完成动作受阻。日志仍然非空,旧读者仍然看到十。因此,Python 调用没有抛出异常、而是返回了一个元组,并不能证明截断已经成功;调用方必须检查元组中代表状态和进度的各个字段,才能正确解释结果。
旧读者提交之后,再次截断得到零、零、零。脚本不仅检查返回值,还确认实际日志文件长度为零,并确认新读取看到二十。值二十在最后这次检查点之前就已经提交,最后一步改变的是日志与主文件之间的状态。即使断言失败,两个连接仍会关闭,临时目录也会清理。
诊断尚未完成的工作
对于 SQLite,如果检查点不能完成,应查看长期持有的读事务与尚未结束的游标。然后决定只推进被动检查点是否足够,还是需要在受控维护窗口执行会等待的检查点。增加忙等待超时,只是对同一约束多等一会儿,不会替应用释放事务。每次写入后都强制截断,还可能增加竞争,抵消日志模式带来的并发收益。
对于 PostgreSQL,应使用与实际部署版本匹配的检查点和日志保留统计。检查点已经完成、旧日志仍被保留、业务事务还在继续,可以同时成立。调整频率时应比较前台请求延迟与实际重启演练结果,不要只看某个文件终于缩小。这个夹具验证的是 SQLite 的完成边界,没有测量生产恢复时间目标,也不表示所有数据库都提供相同的检查点接口。
来源
- SQLite 预写日志,读者快照、检查点进度与日志复用,已核验 2026-09-12。
- SQLite 检查点接口,被动、重启与截断模式的完成语义,已核验 2026-09-12。
- SQLite 检查点指令,返回的忙标志与帧数,已核验 2026-09-12。
- PostgreSQL 18 WAL 与检查点,检查点代价及独立的日志保留要求,已核验 2026-09-12。