你写了一个笔记应用,用户在一篇 20KB 的文档上改了 1000 次,你想保留每一次修改,于是每保存一次就往库里插一行完整副本。等用户回头翻历史,数据库里已经躺着 20MB 几乎一模一样的文本。
这是版本历史存储的老难题。最容易写的方案就是每一版全文直接落库,代价你心里有数:绝大多数字节都是重复的。
Simon Willison 在 2026 年 8 月遛狗时想了个偷懒的办法。
一个偷懒到有点蠢的想法
遛狗路上他琢磨:与其每行存一版,不如把所有历史版本拼成一个 JSON 字符串数组,然后对整个数组做一次 zstd 压缩。版本之间那么多重复文字,压缩器总能压掉吧?
他没自己写。他打开 ChatGPT 的语音模式,把想法口述了一遍,再让模型用 Python 把原型做出来。模型跑了 38 分钟,交出了能跑的代码和一套基准测试。
结果超出预期。同一篇 20KB 文档、1000 次修订,原始快照 20.4MB,压成单个 zstd JSON 数组后只有 80.3KB。
你不需要自己写 diff
这个方案最妙的地方在于:你完全没有实现版本差分。
传统做法要设计 delta 格式,记录第 5 版相对第 4 版改了哪几个字符,读取时再一层层 patch 回去。Simon 的方案里,每一版全文都老老实实留在数组里,去重这件事全交给 zstd。压缩器发现相邻字符串有大量公共子串,自然会把它们合并掉。
等于用现成的压缩算法,免费替你实现了 diff/patch。代码简单到只有两列:history_blob 存压缩后的字符串数组,history_timestamps 存一组整数时间戳(不压缩,因为时间戳本身没什么可压的)。
两种写法
原型给了两个实现。
WholeBlobHistoryStore 最直白。 一张 documents 表,current_text 放当前原文,history_blob 放压缩历史。每次 replace,先 BEGIN IMMEDIATE 抢到写锁,把旧当前文本追加进历史数组,重新压缩整个 BLOB,再写新当前文本。当前版本不重复进历史,内容没变的保存直接跳过,事务保证不丢修订。
ChunkedHistoryStore 多一张 history_chunks 表。 思路一样,但历史被切成一块一块。每块攒够(比如 128 个版本,或解压后 JSON 到 2MB)就封口成不可变块,新版本写进下一块。旧块从此不再动。
代价藏在二次方里
直白版有个刺眼的问题。它每次编辑都要把越来越大的数组解压、追加、重新压缩。累计工作量接近二次方。
数据不会骗人。1000 次编辑,直白版总共花了 26.8 秒,最后一次编辑的延迟高达 49.9 毫秒。文件确实只有 80KB,但把它造出来的过程,是在反复重写一个不断膨胀的历史。
分块版把这条曲线拉平了。分块 128 同样存下 1000 版,总耗时 1.53 秒,晚期延迟 2.2 毫秒,存储只从 80.3KB 涨到 109.9KB。这一个架构改动,几乎吃掉了全部存储优势,同时换回了线性写入。
完整对照
| 策略 |
历史存储 |
1000次总耗时 |
晚期编辑延迟 |
| 逐行快照 |
20.4 MB |
70 ms |
0.061 ms |
| 逐行 + Zstd |
7.5 MB |
218 ms |
0.241 ms |
| 单个压缩 JSON BLOB |
80.3 KB |
26.8 s |
49.9 ms |
| 分块 64 |
154.9 KB |
0.93 s |
0.96 ms |
| 分块 128 |
109.9 KB |
1.53 s |
2.20 ms |
时间戳本身再占 10KB 出头。
它没在骗你
有人会怀疑:是不是因为英文文本好压,结论才成立?Simon 专门测了高熵随机内容。
每次只替换 20 个随机字节,1000 版历史压到 45.8KB。替换 10%,涨到 1.6MB。各版本完全不相关,直接 16MB,几乎没跨版本收益。
这说明方案是诚实的。省多少,取决于每版到底引入了多少新信息。连续版本越像,越省;毫无关联,正确退化成不省。
你能拿去用在哪
这个用通用压缩器当 delta 引擎的思路,不只对文本有效。配置项、JSON 文档、小体积结构化数据,只要版本间有大量重复,都能这么玩。如果你经常跟 SQLite 打交道,或者需要为应用内置版本管理能力,不妨在云栈社区的数据库/中间件/技术栈板块看看更多实操讨论。
真正的取舍很清楚:逐行快照读快写快但占空间;单个压缩 BLOB 占空间极小,但写入是二次方;分块在两者之间取了平衡点。选哪个,看你读得多还是写得多。
zstd 在长历史下又小又快,代价是多一个依赖。只要零依赖,退回 zlib 同样成立,收益还在。
一句话
把一个朴素到有点蠢的想法做出来,认真测一遍,结果证明它在存储上极其有效,代价是写入膨胀,而分块用一个很小的改动同时保住了两边。
这大概就是 Simon 一直坚持的那套:先原型,再测,最后让数据说话。
参考来源:Simon Willison 博客《Research: SQLite compressed text-history prototypes》及其开源原型仓库。