Graft · A visual explanation
让 SQLite 像代码一样
拥有历史
Graft 的核心不是把 Git 搬进数据库,而是把一件复杂的事拆成两件简单的事:用页面保存变化,用行和表解释变化。
- 01捕获此刻得到安全的一致副本
- 02保存改过的页不重复复制整库
- 03放进历史add 暂存,commit 命名
- 04解释成行告诉人真正发生了什么
软件有两条时间线。一条是代码怎样变化,Git 记录得很好;另一条是应用里的数据怎样变化,通常只剩下一堆日期不同的备份。
00 / Why
代码有 Git,数据为什么只有备份?
当然可以把 app.sqlite 直接提交给 Git。Git 会忠实保存它,但只会告诉你:“这个二进制文件变了。”它不知道你新增了一条笔记、修改了一个标题,还是重建了一个索引。
原因并不神秘。代码天然由一行行文字组成;SQLite 则把表、行和索引组织成不断变化的页面。一条很小的 SQL,可能让文件里的多个位置一起改变。更麻烦的是,应用正在写入时,直接复制主文件还未必能得到一个完整时刻。
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 上成立。
02 / A real history
让一份 8 KiB 数据库,真的走完两次提交
下面不是抽象示意。我们创建一张 notes 表,先写入 “first note”,完成第一次 add 和 commit;随后修改这行,再插入 “second note”,完成第二次 add 和 commit。
播放时只需要看四个位置:当前文件、已经保存的页面、暂存的版本、正式历史。每一步只会改变其中一部分。
一份 8 KiB SQLite,怎样变成两次提交
步骤 1 / 7graft init Graft 创建自己的仓库目录,但不会读取、复制或修改任何 SQLite 文件。
app.sqlite —
还没有保存版本
没有待提交版本
还没有提交
这是用当前 Graft CLI 实跑的最小样例。P2 是这组数据的真实结果;不同数据库可能改变更多页面。
捕获数据库,找出不同,保存变化,并冻结待提交版本。
不再比较数据库,只给已经冻结的版本建立一次提交。
所以真正寻找 SQLite 差异的是 add,不是 commit。如果 add 之后应用又写入数据库,那些新变化不会偷偷进入当前提交;它们要等下一次 add。
03 / What add does
graft add 只做两步:取一个时刻,找出改过的页
第一步:让 SQLite 自己交出一个安全时刻
正在运行的 SQLite 不是一张静止照片。最新的已提交数据可能还在旁边的 WAL 文件里,另一个事务也可能只写了一半。直接复制文件,可能错过已经完成的变化,也可能撞上正在进行的变化。
Graft 不猜。它请 SQLite 自己生成一份一致副本:已经提交的变化全部包含,尚未提交的变化全部排除。之后 Graft 比较和保存的,都是这个确定时刻。
数据库正在写,Graft 怎样得到一张完整照片
1 / 4第二步:和上一次逐页比较,只保存不同
Graft 把一致副本按固定的 4 KiB 切成页。第一次 add 没有旧版本可以复用,所以保存全部页面;第二次开始,相同页面继续引用旧版本,只有不同页面进入本次增量。
上一次:P1 P2 P3 P4
这一次:P1 P2′ P3 P4
↓
本次只保存:P2′ 为了不用每次仔细检查整库的每一页,Graft 会先快速排除完全相同的区域,再对可能变化的区域逐页确认。快速检查负责省时间,最终的逐页比较负责保证正确。
先排除完全相同的区域,再逐页确认
检查 64 页 → 保存 3 页P1row-majorP64
P1membershipP64
只保存 3 页,其余 61 页继续复用旧版本。
位图只是一个选择表:哪一位是 1,就处理哪一页。
在我们的真实样例里,两次快照都只有两个 4 KiB 页。P1 完全相同,P2 有 43 个字节不同。因此第二次 add 只保存 P2,而不是再次保存整个 8 KiB 数据库。
继续放大 一条 UPDATE 和一条 INSERT,怎样落进 P2?
行与页面并非一一对应。这个例子里,两条 SQL 一起改写了 P2 的页头、位置索引和行内容。下面可以从 SQL 一路下钻到具体字节;理解 Graft 的主流程并不要求掌握这些结构。
一条 UPDATE + 一条 INSERT,在页内真正改了什么
IMAGE A · 1 CELL这段交互参考了 SQLite Internal 从 page 到 cell、offset 和 hex 的逐层下钻方式。
04 / Reading history
保存的是替换页,读到的仍是一份完整数据库
还原某个版本时,Graft 从最新的替换页开始找。如果这一版保存了目标页,就使用它;如果没有,就回到更早的版本。最终,每个位置都能找到自己的最新页面。
这和在旧书上覆盖透明修订页很像:读者看见的是完整内容,不需要关心某一页来自初版还是第五次修改。
先找最新替换页,找不到就回到旧版本
新页优先页面一旦写进历史就不再改变。新版本只是增加新的替换页。因此旧版本始终能读到自己当时的内容,新版本也不会破坏它。
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,再从两边读出数据库结构和表记录。随后,它不再关心某行落在哪个页面,而是按行的稳定身份把两边对齐。
P2 变了,怎样得出“一行修改、一行新增”
步骤 1 / 4graft diff --rows C1 C2 app.sqlite C1 指向版本 A;C2 指向版本 B。虽然 B 只新增保存了 P2′,读取时两边都会呈现为完整数据库。
| rowid | body |
|---|---|
| 1 | first note |
| 2 | — |
| rowid | body |
|---|---|
| 1 | first note, revised |
| 2 | second note |
~ rowid 1 first note → first note, revised
+ rowid 2 second note - Rows 行变化
- Schema 表结构变化
- Opaque 无法安全解释的变化
- Limits 本次分析的边界
这里最重要的动作不是“解析 P2”,而是按身份连接两个版本:普通表用 rowid;WITHOUT ROWID 表用声明的主键。身份只在旧版出现是删除,只在新版出现是新增,两边都有但列值不同才是修改。
变化页仍然有价值,但它只是加速器。当表的页面结构足够稳定时,Graft 可以只解码发生变化的 B-tree 叶子页;如果这个捷径不安全,它就按身份流式读取整张表,必要时在隔离的临时位置还原两份数据库再比较。无论走哪条路,得到的行级事实必须相同。
回答“哪些页面值得继续检查”。
回答“数据库里真正发生了什么”。
完整的 SQLite diff 也不只有 rows。表、索引或触发器的定义变化会进入 schema;虚拟表、FTS 或当前无法安全解释的区域会明确进入 opaque 和 limitations。行结果为空,不等于数据库没有变化。
捕获一个真实时刻,只保存改过的页,再把页面变化解释成人理解的数据库变化。
这就是 Graft 的全部核心。工作区里仍是普通 SQLite 文件;Graft 只在旁边为它维护时间。页面让历史足够轻,SQLite 让历史仍然有意义。