Eidos
← 返回博客

Graft · A visual explanation

让 SQLite 像代码一样
拥有历史

Graft 的核心不是把 Git 搬进数据库,而是把一件复杂的事拆成两件简单的事:用页面保存变化,用行和表解释变化。

一个洞察,四个动作
  1. 01捕获此刻得到安全的一致副本
  2. 02保存改过的页不重复复制整库
  3. 03放进历史add 暂存,commit 命名
  4. 04解释成行告诉人真正发生了什么

软件有两条时间线。一条是代码怎样变化,Git 记录得很好;另一条是应用里的数据怎样变化,通常只剩下一堆日期不同的备份。

00 / Why

代码有 Git,数据为什么只有备份?

当然可以把 app.sqlite 直接提交给 Git。Git 会忠实保存它,但只会告诉你:“这个二进制文件变了。”它不知道你新增了一条笔记、修改了一个标题,还是重建了一个索引。

原因并不神秘。代码天然由一行行文字组成;SQLite 则把表、行和索引组织成不断变化的页面。一条很小的 SQL,可能让文件里的多个位置一起改变。更麻烦的是,应用正在写入时,直接复制主文件还未必能得到一个完整时刻。

代码 变化天然表现为文本行 Git 可以直接保存,也可以直接解释。
SQLite 变化先表现为文件页面 页面适合恢复,却不等于人理解的一行数据。
Graft 保存页面,再解释成行 让存储效率和可理解性各做各擅长的事。

Graft 想补上的,不是另一种备份,而是 SQLite-backed 应用缺失的时间维度。

01 / The idea

第一次保存整本书,以后只保存改过的页

可以把 SQLite 想象成一本活页书。第一次 add 时,Graft 保存整本书。以后数据库发生变化,Graft 不再复制整本书,而是找出被改写的页,只保存这些新页。

第一次:A = [ P1  P2  P3  P4 ]

修改后:B = A + [ P2′  P4′ ]
                 └─ 只保存替换页

读取 B:旧页来自 A,新页优先使用 P2′、P4′

因此 B 虽然只新增了两页的数据,却仍然代表一份完整数据库。它知道未改变的页继续从 A 读取,改变的页使用新版本。历史越长,新增的通常只是少量替换页,而不是一份又一份整库副本。

保存 哪些页面能够还原这个 SQLite?

页面是机器存储和恢复的单位。

解释 哪些表和行真正发生了变化?

行和表才是人理解变化的单位。

这是整套系统最重要的分工:保存时看页面,解释时看数据库。后面的所有机制,都只是为了让这句话在真实 SQLite 上成立。

02 / A real history

让一份 8 KiB 数据库,真的走完两次提交

下面不是抽象示意。我们创建一张 notes 表,先写入 “first note”,完成第一次 add 和 commit;随后修改这行,再插入 “second note”,完成第二次 add 和 commit。

播放时只需要看四个位置:当前文件、已经保存的页面、暂存的版本、正式历史。每一步只会改变其中一部分。

演示 01 · 一条真实时间线

一份 8 KiB SQLite,怎样变成两次提交

步骤 1 / 7
初始化仓库 graft init
先建立历史的容器

Graft 创建自己的仓库目录,但不会读取、复制或修改任何 SQLite 文件。

01当前文件应用正在使用

app.sqlite —

还没有数据库
init 不改变工作目录
02保存的页面用于还原 SQLite

还没有保存版本

第一次 add 才会写入
03暂存版本下一次 commit 的内容

没有待提交版本

app.sqlite
add 之后才会冻结
04正式历史commit 后对用户可见

还没有提交

main · 空
add 不会移动历史

这是用当前 Graft CLI 实跑的最小样例。P2 是这组数据的真实结果;不同数据库可能改变更多页面。

graft add 决定“这一次”究竟是什么

捕获数据库,找出不同,保存变化,并冻结待提交版本。

graft commit 把“这一次”放进正式历史

不再比较数据库,只给已经冻结的版本建立一次提交。

所以真正寻找 SQLite 差异的是 add,不是 commit。如果 add 之后应用又写入数据库,那些新变化不会偷偷进入当前提交;它们要等下一次 add。

03 / What add does

graft add 只做两步:取一个时刻,找出改过的页

第一步:让 SQLite 自己交出一个安全时刻

正在运行的 SQLite 不是一张静止照片。最新的已提交数据可能还在旁边的 WAL 文件里,另一个事务也可能只写了一半。直接复制文件,可能错过已经完成的变化,也可能撞上正在进行的变化。

Graft 不猜。它请 SQLite 自己生成一份一致副本:已经提交的变化全部包含,尚未提交的变化全部排除。之后 Graft 比较和保存的,都是这个确定时刻。

演示 02 · 捕获一个时刻

数据库正在写,Graft 怎样得到一张完整照片

1 / 4
SQLite 主文件 还没有合入最近提交
P1A₁ P2B₁ P3C₁ P4D₁
WAL · 最近写入 有已提交,也有未提交
P2B₂事务 41 · 已提交 P4D₂事务 41 · 已提交 P3C₂?事务 42 · 未提交
Graft 得到的一致副本 只包含已经完成的事务
P1 P2 P3 P4

第二步:和上一次逐页比较,只保存不同

Graft 把一致副本按固定的 4 KiB 切成页。第一次 add 没有旧版本可以复用,所以保存全部页面;第二次开始,相同页面继续引用旧版本,只有不同页面进入本次增量。

上一次:P1  P2  P3  P4
这一次:P1  P2′ P3  P4
                 ↓
本次只保存:P2′

为了不用每次仔细检查整库的每一页,Graft 会先快速排除完全相同的区域,再对可能变化的区域逐页确认。快速检查负责省时间,最终的逐页比较负责保证正确。

演示 03 · 找出改变的页

先排除完全相同的区域,再逐页确认

检查 64 页 → 保存 3 页
上一次 最近暂存或提交的版本
这一次 刚刚捕获的一致副本
当前区域 P1–P64 · 共 64 页
试试看不同变化
1 · 快速排除 这一组需要继续检查 整组内容不同,64 页都先标为候选

P1row-majorP64

2 · 逐页确认 真正改变的页 变化页 = {6, 19, 42}

P1membershipP64

3 · 保存 本次新增的数据 只包含确认改变的页面
P6P19P42

只保存 3 页,其余 61 页继续复用旧版本。

没有选中 需要检查或保存

位图只是一个选择表:哪一位是 1,就处理哪一页。

在我们的真实样例里,两次快照都只有两个 4 KiB 页。P1 完全相同,P2 有 43 个字节不同。因此第二次 add 只保存 P2,而不是再次保存整个 8 KiB 数据库。

继续放大 一条 UPDATE 和一条 INSERT,怎样落进 P2?

行与页面并非一一对应。这个例子里,两条 SQL 一起改写了 P2 的页头、位置索引和行内容。下面可以从 SQL 一路下钻到具体字节;理解 Graft 的主流程并不要求掌握这些结构。

技术放大镜 · SQLite P2 内部

一条 UPDATE + 一条 INSERT,在页内真正改了什么

IMAGE A · 1 CELL
P1 sqlite_schema · table leaf 1 cell · 两个镜像完全相同
P2 notes · table leaf 1 cell · 本轮基线
P2 · 4096 bytes offset 0
offset 4096
SQL rows1 rowphysical pageP2 baseline

这段交互参考了 SQLite Internal 从 page 到 cell、offset 和 hex 的逐层下钻方式。

04 / Reading history

保存的是替换页,读到的仍是一份完整数据库

还原某个版本时,Graft 从最新的替换页开始找。如果这一版保存了目标页,就使用它;如果没有,就回到更早的版本。最终,每个位置都能找到自己的最新页面。

这和在旧书上覆盖透明修订页很像:读者看见的是完整内容,不需要关心某一页来自初版还是第五次修改。

演示 04 · 还原完整版本

先找最新替换页,找不到就回到旧版本

新页优先
选择要读取哪一页 从最新替换页开始,找不到就往回找
S3 本次替换页
P3
S2 上次替换页
P2 P5 P7
S1 最初完整版本
P1 P2 P3 P4 P5 P6

页面一旦写进历史就不再改变。新版本只是增加新的替换页。因此旧版本始终能读到自己当时的内容,新版本也不会破坏它。

05 / The diff

P2 变了,怎样得出“一行修改、一行新增”?

到这里,Graft 只解决了历史的保存问题。它知道 C1 使用 P2、C2 使用 P2′,却还不知道这 43 个变化字节代表什么。页面本身不会告诉我们“一条笔记被修改了”。

Graft 的 diff 有两层。第一层先比较 repository 中的路径:C1 和 C2 指向不同的 SQLite 快照,因此 app.sqlite 被标记为 modified。只有继续请求 --rows,才会进入第二层的数据库语义比较。

行级 diff 是另一遍独立的读取。Graft 先让 C1 和 C2 各自重新成为一份完整、可读的 SQLite,再从两边读出数据库结构和表记录。随后,它不再关心某行落在哪个页面,而是按行的稳定身份把两边对齐。

演示 05 · 从页面到行

P2 变了,怎样得出“一行修改、一行新增”

步骤 1 / 4
选择比较对象 graft diff --rows C1 C2 app.sqlite
先得到两个完整、不可变的 SQLite 版本

C1 指向版本 A;C2 指向版本 B。虽然 B 只新增保存了 P2′,读取时两边都会呈现为完整数据库。

旧版本 C1 · SQLite A A = P1 + P2
sqlite_schemanotes(id, body)
rowidbody
1first note
2
新版本 C2 · SQLite B B = A + P2′
sqlite_schemanotes(id, body)
rowidbody
1first note, revised
2second note
比较轴 按 rowid 对齐,不按页面位置对齐 P2 只是候选范围;rowid 才定义“是不是同一行”
rowid 1 A first note 两边都有,值不同 B first note, revised ~ update
rowid 2 A 只在新版出现 B second note + insert
最终解释 Table notes
~ rowid 1  first note → first note, revised
+ rowid 2  second note
完整结果还会分别报告
  • Rows 行变化
  • Schema 表结构变化
  • Opaque 无法安全解释的变化
  • Limits 本次分析的边界

这里最重要的动作不是“解析 P2”,而是按身份连接两个版本:普通表用 rowidWITHOUT ROWID 表用声明的主键。身份只在旧版出现是删除,只在新版出现是新增,两边都有但列值不同才是修改。

变化页仍然有价值,但它只是加速器。当表的页面结构足够稳定时,Graft 可以只解码发生变化的 B-tree 叶子页;如果这个捷径不安全,它就按身份流式读取整张表,必要时在隔离的临时位置还原两份数据库再比较。无论走哪条路,得到的行级事实必须相同。

页面 diff P2 与上一次不同

回答“哪些页面值得继续检查”。

SQLite diff 一行被修改,一行被新增

回答“数据库里真正发生了什么”。

完整的 SQLite diff 也不只有 rows。表、索引或触发器的定义变化会进入 schema;虚拟表、FTS 或当前无法安全解释的区域会明确进入 opaque 和 limitations。行结果为空,不等于数据库没有变化。

捕获一个真实时刻,只保存改过的页,再把页面变化解释成人理解的数据库变化。

这就是 Graft 的全部核心。工作区里仍是普通 SQLite 文件;Graft 只在旁边为它维护时间。页面让历史足够轻,SQLite 让历史仍然有意义。

← 阅读更多 Eidos Notes

本文聚焦 Graft 的核心心智模型。存储协议、同步与合并边界将在后续文章中单独展开。