Dolt 2.0 深度拆解:给数据库装上 Git 基因——Prolly Tree 内容寻址存储、行级版本控制与自动 GC 架构全解析
一、背景:数据库为什么也需要版本控制
做后端的人几乎都被这几件事折磨过:
- 线上执行了一条
UPDATE忘了带WHERE,全表数据被改,只能从备份恢复,恢复期间业务停摆,恢复完还丢了几小时的新数据; - 产品经理说"这个功能的数据模型再改一版",你不得不写一堆
ALTER TABLE迁移脚本,还要在测试、预发、生产三套环境里重复执行,稍有顺序差异就报错; - 审计要求"给我看上周五下午这条记录被谁改成了什么",MySQL 默认配置下你只能摊手;
- 算法团队要训练数据,要求"给我 7 月 20 号那一版商品快照",而你的库里只有当前数据;
- 数据同步管道出 bug,把线上库的表结构弄乱了,你要找"昨天还能用的那一版表结构"。
这些问题有一个共同的根因:传统数据库只保存"当前状态",不保存"历史轨迹"。你看到的表,永远是无数次修改之后的最终结果;中间过程、分支尝试、回滚点,全都蒸发在 redo log 里。
业界不是没有解决方案,但都差点意思:
- 定时备份/快照:粒度粗(小时/天级),恢复慢,而且备份之间依然是黑盒;
- binlog/CDC 回放:能做审计和回放,但要重建"某一时刻的完整库"非常痛苦,更不用说支持"开个分支改数据试试";
- 临时表/影子库:手工维护,容易漂移;
- MySQL 的 temporal table:能查历史行,但只支持一个时间维度,不支持分支合并,语义也跟业务查询混在一起。
Git 给了我们一个优雅的答案:把数据库当成一个可以被 clone、branch、commit、merge、diff 的仓库。代码能版本控制,为什么数据不能?这就是 Dolt 的出发点。
2026 年 7 月 28 日,Dolt 发布 2.0 大版本(据 InfoQ 与多家媒体同日报道),带来了默认开启的自动垃圾回收与归档压缩、自适应存储、测试阶段的向量类型支持以及性能提升。这篇文章不打算写成"2.0 更新日志",而是从存储引擎讲起,把 Dolt"为什么能行"的底层原理一次拆透:Prolly Tree 内容寻址存储、提交 DAG、三路合并与 cell 级冲突、SQL 兼容层,最后落到 2.0 的自动 GC 架构和实战工作流上。
二、Dolt 是什么:Git 语义的数据库映射
Dolt 是一个用 Go 编写的、MySQL 协议兼容的关系型数据库,同时内置了完整的 Git 式版本控制语义。它脱胎于内容寻址数据库 Noms(DoltHub 团队在 2019 年基于 Noms 的 chunk store 思想重构而来),开源协议 Apache 2.0。
一句话概括:Dolt = Git 的数据结构 + MySQL 的 SQL 层。
Git 用户对 Dolt 的命令行会感到极度舒适,因为语义几乎一一对应:
| Git 概念 | Dolt 对应 | 说明 |
|---|---|---|
git init | dolt init | 初始化仓库(含一个空数据库) |
git clone | dolt clone | 从远程克隆整库(含全部历史) |
git branch | dolt branch | 分支,每个分支指向一个 commit |
git checkout | dolt checkout | 切换分支 / 工作集 |
git add | dolt add | 暂存表的变更 |
git commit | dolt commit | 提交,生成不可变快照 |
git log | dolt log / dolt_log 表 | 提交历史 |
git diff | dolt diff | 表结构/行级差异(可输出 SQL 语句) |
git merge | dolt merge | 三路合并,冲突按 cell 记录 |
git push/pull | dolt push/pull | 与远程(DoltHub 或自建 remote)同步 |
git reset | dolt reset | 撤销暂存 |
git revert | dolt revert | 生成反向提交 |
在 Git 里版本控制的基本单位是"文件",在 Dolt 里是**"表"**(更准确说是表的行集合与结构)。你可以对一个表做 dolt add、对另一个表不 add,然后只提交被 add 的表——粒度比文件还细。
Dolt 还提供 dolt sql-server:一个监听 3306 端口的 MySQL 协议服务器。这意味着任何 MySQL 客户端(JDBC、pymysql、Navicat、ORM)都能直接连上来,把版本控制能力"长"在标准 SQL 之上:你可以在 SQL 里执行 CALL DOLT_COMMIT('-m', '...')、CALL DOLT_MERGE('feature'),也可以写 SELECT * FROM t AS OF 'HEAD~2' 做时间旅行。
三、存储引擎:Prolly Tree(概率 B 树)深度拆解
Dolt 全部版本控制能力的根基,是它的存储引擎:Prolly Tree(probabilistic B-tree,概率 B 树)。不理解 Prolly Tree,就不理解 Dolt。
3.1 从 B+Tree 说起
传统关系型数据库(MySQL InnoDB、PostgreSQL)的主索引是 B+Tree:一个有序的多叉树,叶子节点存数据,内部节点存"键 + 子节点指针",指针是内存/磁盘地址。B+Tree 的优点是局部性好、范围扫描高效;缺点是树的结构与插入历史强相关——同样的数据,按不同顺序插入,树的形态完全不同,节点的物理地址也完全不同。因此你没法用"树的根节点地址"来唯一标识一份数据,更没法在两次修改之间高效复用未变化的子树。
3.2 内容寻址(Content Addressing)
Prolly Tree 把 B+Tree 的"地址指针"换成了内容哈希:每个节点的地址就是它自身内容的 SHA-256 哈希值。父节点里存的不是"子节点在磁盘第几页",而是"子节点的哈希"。要读某个子节点,拿哈希去 chunk store 里取。
这一换,换出了三个了不起的性质:
- 不可变性:节点内容变了,哈希就变,等于产生了一个新节点,旧节点原封不动;
- 确定性:同一份数据,无论以什么顺序写入,最终树的根哈希都一样(前提是分块边界确定,见 3.3)——"根哈希 = 整张表内容的指纹";
- 结构共享:一次只改一行,从叶子到根只有
O(log n)个节点需要重写,其余节点全部复用,新旧版本共享同一棵树的绝大部分。
这就是 Dolt 能对"每一行"做版本控制的物理基础:每次提交,本质上只是记录了一个新的根哈希,加上被改写的那条从根到叶的节点路径。
3.3 内容定义分块(Content-Defined Chunking)
B+Tree 按"满了就分裂"组织节点,节点边界由插入顺序决定。Prolly Tree 必须做到节点边界只由内容决定,否则插入一行数据就会导致后续大量节点边界漂移,结构共享和 diff 就全废了。
Dolt 的做法是内容定义分块(Content-Defined Chunking,CDChunking,与 rsync、restic 等去重工具同源的思想):对有序键序列,逐个计算每个键的哈希,当哈希值落进一个极小概率区间时,就在这里切一刀,形成 chunk 边界。由于哈希只依赖键本身的内容,无论你往中间插入多少数据,某个键"是不是边界"这件事永远不变——边界是内容的内在属性,与写入顺序无关。
下面用 Python 模拟这个分块逻辑:
import hashlib
def key_hash01(key: str) -> float:
"""把 key 映射到 [0, 1) 区间,均匀分布"""
digest = hashlib.sha256(key.encode()).digest()[:8]
return int.from_bytes(digest, "big") / (1 << 64)
def should_split(key: str, target_size: int) -> bool:
"""哈希值落在 [0, 1/target_size) 就切分,期望 chunk 大小 ≈ target_size"""
return key_hash01(key) < 1.0 / target_size
def chunk_keys(keys, target_size=4):
chunks, cur = [], []
for k in keys:
cur.append(k)
# 边界由"当前键的哈希"决定,与前面的插入历史无关
if should_split(k, target_size) and cur:
chunks.append(cur)
cur = []
if cur:
chunks.append(cur)
return chunks
keys = ["a", "b", "c", "d", "e", "f", "g", "h", "i", "j"]
print(chunk_keys(keys))
# 例如: [['a'], ['b', 'c', 'd'], ['e'], ['f', 'g', 'h', 'i'], ['j']]
# 每次运行结果一致 —— 边界只由 key 内容决定
现在模拟"在中间插入新键"的场景,观察边界如何保持稳定:
keys_v2 = ["a", "b", "x1", "x2", "c", "d", "e", "f", "g", "h", "i", "j"]
print(chunk_keys(keys_v2))
# 期望输出: [['a'], ['b', 'x1', 'x2'], ['c', 'd'], ...]
# 关键: 插入点之后的 'c','d','e'... 边界位置与 v1 完全一致,
# 只有插入点所在 chunk 被"顶开",其余 chunk 无需重写
对比 B+Tree:插入导致节点分裂后,后续节点的填充率、分裂位置全都会连锁变化;而 Prolly Tree 中,一次插入最多影响一个 chunk(以及从该 chunk 到根路径上的父节点)。这正是结构共享成立的前提。
实际 Dolt 实现中,目标分块大小默认为 4KB 量级(可调),并会结合节点大小上限做二次约束,保证既不过碎也不过胖。需要说明的是,这里我用了简化模型(直接对键哈希切分),Dolt 的 chunker 还会考虑键值总字节数等细节,但核心思想就是内容定义分块。
3.4 节点结构与哈希传播
Prolly Tree 的节点分两类:
- 叶子节点:有序的
(key, value)列表,value 是行的序列化数据(或指向行存储的引用); - 内部节点:有序的
(key, child_hash)列表,key 是子树中的最小键(或最大键,取决于实现),child_hash 是子节点的内容哈希。
节点自身也被序列化成一个 chunk,其地址 = 序列化内容的哈希。因此整棵树的根哈希递归地依赖于每一个叶子:任一行数据变化,都会沿路径传播,最终改变根哈希。
用 Go 描述这个结构非常直观:
// Hash 是 32 字节的 SHA-256
type Hash [32]byte
type Node struct {
Keys []string
Children []Hash // 内部节点:子节点地址
Values [][]byte // 叶子节点:行数据
IsLeaf bool
}
// ChunkStore 内容寻址存储:hash -> chunk bytes
type ChunkStore struct {
mu sync.RWMutex
data map[Hash][]byte
}
func (cs *ChunkStore) Put(b []byte) Hash {
h := sha256.Sum256(b)
cs.mu.Lock()
defer cs.mu.Unlock()
cs.data[h] = b
return h
}
// PutNode 递归写入:先写子节点,再写父节点,返回父节点地址
func (cs *ChunkStore) PutNode(n *Node) (Hash, error) {
if !n.IsLeaf {
for i, ch := range n.Children {
// 子节点必须先落盘,父节点才能引用其哈希
n.Children[i] = cs.PutNode(mustLoad(ch))
}
}
raw, _ := serialize(n) // flatbuffers / 自描述格式
return cs.Put(raw), nil
}
注意 PutNode 的递归顺序:子节点先写,父节点后写,父节点里存的是子节点的哈希而非指针。这一条规则同时带来了去重能力——如果两个不同 commit 里某棵子树内容完全相同,它们在 chunk store 里就是同一个 chunk,物理上只存一份。很多仓库里大量提交共享同一份表数据,存储开销因此被压到极低。
3.5 结构共享与去重
假设一张 100 万行的表,Prolly Tree 高度约 3~4 层(4KB chunk、每节点约 100+ 键)。你 UPDATE 了一行:
- 该行所在叶子 chunk 重写(约 4KB);
- 该叶子到根的路径上约 3~4 个内部节点重写(每个 4KB 左右);
- 其余 ~99.99% 的节点原样复用。
一次提交产生的新数据量只有几十 KB,而不是整张表。这就是"行级版本控制"在存储层面不爆炸的原因:历史版本不是复制品,而是对同一棵树的增量引用。
去重还体现在跨表、跨库场景:两张表有相同的前缀数据、两个分支有相同的基座数据,chunk store 自动合并存储。这与 Git 的对象库(objects 目录)是同构的设计——Dolt 就是"数据库界的 Git 对象库"。
3.6 为什么 Dolt 的 diff 这么快
有了内容寻址,diff 变成了一件几乎免费的事:
- 根哈希相同 ⇒ 内容完全相同。比较两个 commit 的某张表,先比根哈希,不同才继续;
- 递归比较:根节点不同,比较子节点哈希列表,哈希相同的子树直接跳过,只下钻哈希不同的路径;
- 最终定位到极少数发生变化的叶子 chunk,逐行对比即可输出行级 diff。
复杂度从"全表扫描"降到"与变更量成正比"。dolt diff 能秒出结果,dolt_log、dolt_diff 系统表能当普通 SQL 表查询,靠的都是这套机制。你在别的数据库里做"两版数据对比",要么全表导出后写脚本 diff,要么建临时表 join,而 Dolt 把 diff 做成了内建的一等公民。
四、提交模型:从 Working Set 到 Commit DAG
存储引擎解决"数据怎么存",提交模型解决"历史怎么记"。
Dolt 的仓库状态分三层:
- Working Set(工作集):当前未提交的所有变更,等价于 Git 的工作区 + 暂存区;
- Staged(暂存区):
dolt add <table>把某张表的变更标记为待提交; - Commit(提交):
dolt commit把暂存的变更固化为不可变快照,产生一个新 commit。
每个 commit 在概念上长这样:
type Commit struct {
Hash Hash // commit 自身的哈希
Parents []Hash // 父提交(merge commit 有多个)
Root Hash // 根值哈希:整个数据库的快照指纹
Message string
Author string
Time time.Time
}
Root 是一个特殊的 Prolly Tree:键是表名,值指向各表的 Prolly Tree 根哈希,外加数据库元数据(schema 等)。"整个数据库在某一时刻的完整状态" = 一个 32 字节的根哈希,这大概是版本控制数据库最优雅的地方。
commit 之间通过 Parents 组成有向无环图(DAG)。普通提交一个父节点,merge 提交两个父节点。分支只是指向某个 commit 的"移动标签",dolt branch feature 就是在 DAG 上插一个标签,零拷贝。
日常操作流:
dolt init
dolt sql -q "CREATE TABLE users (id INT PRIMARY KEY, name VARCHAR(50), email VARCHAR(100))"
dolt sql -q "INSERT INTO users VALUES (1, 'alice', 'alice@example.com'), (2, 'bob', 'bob@example.com')"
dolt add users # 暂存 users 表的变更
dolt commit -m "init: users table"
# 开分支改数据
dolt branch feature-alice
dolt checkout feature-alice
dolt sql -q "UPDATE users SET name = 'alice2' WHERE id = 1"
dolt commit -am "rename alice to alice2"
# 回主分支,数据原样
dolt checkout main
dolt sql -q "SELECT * FROM users"
# +----+-------+------------------+
# | id | name | email |
# +----+-------+------------------+
# | 1 | alice | alice@example.com|
# | 2 | bob | bob@example.com |
# +----+-------+------------------+
注意最后一个查询:切回 main 后,feature-alice 分支上的修改"消失"了——不是被删了,而是工作集被重置到 main 指向的根值。分支上的数据安然无恙地躺在 commit DAG 里,随时可以 dolt checkout feature-alice 再切回去。
这就是"数据库分支"的体验:数据像代码一样可以并行演进、随时切换、按需合并。测试环境的脏数据、实验性迁移、A/B 数据模型,都可以开分支隔离,互不污染。
时间旅行同样是提交模型的直接产物。因为每个 commit 都持有完整根值,查询任意历史时刻就是"把工作集临时指向那个 commit 的根值":
-- 用提交哈希
SELECT * FROM users AS OF 's0hbhj7c9k0b5e6bq9q7n7v5v1d5m9q8';
-- 用相对引用(Git 风格)
SELECT * FROM users AS OF 'HEAD~2';
-- 用分支名
SELECT * FROM users AS OF 'feature-alice';
-- 用时间戳
SELECT * FROM users AS OF '2026-07-28 12:00:00';
AS OF 可以出现在任意查询里,还可以跟 dolt_diff 系统表配合做"任意两版之间"的对比分析。审计需求从"做不到"变成"一条 SQL"。
五、合并与冲突:三路合并与 cell 级冲突
版本控制数据库最容易被质疑的就是:分支合并时数据冲突怎么办?
Dolt 的合并算法是经典三路合并(three-way merge):
- 找两个分支的**最近公共祖先(merge base)**提交;
- 以 base 为基准,分别算出
ours和theirs两侧的差异; - 两侧没动过的部分直接保留 base 版本;只有一侧动过的,采用动的那侧;两侧都动过且改动不一致的,标记为冲突。
由于 Prolly Tree 能给出行级(甚至 cell 级)diff,Dolt 的冲突粒度可以精细到单元格:同一行的不同列被两个分支分别修改,不算冲突,自动合并两列结果;只有同一单元格被两侧改成不同值,才产生冲突。
冲突行会被写进一张特殊的 dolt_conflicts_<表名> 表,而不是直接污染业务表,冲突解决也是显式操作:
-- 在 feature 分支上
CALL DOLT_CHECKOUT('-b', 'fix-price');
CALL DOLT_COMMIT('-am', 'before merge');
-- 尝试合并 main
CALL DOLT_MERGE('main');
-- 查看冲突
SELECT * FROM dolt_conflicts_products;
-- 逐表解决:保留我方 / 保留对方 / 手动改
CALL DOLT_CONFLICTS_RESOLVE('--ours', 'products');
-- 或者手工 UPDATE dolt_conflicts_products 后:
CALL DOLT_CONFLICTS_RESOLVE('--theirs', 'products');
CALL DOLT_COMMIT('-m', 'merge main, resolve conflicts');
注意 DOLT_* 是一组存储过程,可以在标准 SQL 客户端里直接调用——这意味着合并/分支/提交不再是运维的专属命令,业务代码和 SQL 脚本也能编排它们。CI 里可以写"merge 分支 → 跑集成测试 → 有冲突则自动 resolve --ours → commit"这样的数据流水线。
merge 的另一个贴心点是 --squash 与 --no-ff:前者把对方分支的改动压成一个提交合进来(适合实验分支回收),后者强制生成显式 merge commit(适合保留合并拓扑)。语义和 Git 完全一致,Git 用户零学习成本。
六、SQL 兼容层:go-mysql-server 与 DOLT_* 存储过程
Dolt 的 SQL 层基于自研的 go-mysql-server(Go 实现的 MySQL 协议与查询引擎),这也是它能"冒充" MySQL 的原因:
- 协议兼容:监听 3306,完整的 MySQL 握手、认证、预处理语句、二进制协议;
- 语法兼容:覆盖绝大多数 MySQL DDL/DML,包括
JOIN、子查询、窗口函数、GROUP BY、事务(START TRANSACTION/COMMIT/ROLLBACK); - 客户端兼容:JDBC、pymysql、mysql2、Navicat、DBeaver、各类 ORM 直接连。
典型的接入方式:
dolt sql-server --host 0.0.0.0 --port 3306 --user root --database mydb
# 业务代码零改造,pymysql 直连
import pymysql
conn = pymysql.connect(host="127.0.0.1", port=3306, user="root", database="mydb")
cur = conn.cursor()
cur.execute("SELECT * FROM users AS OF 'HEAD~2'") # 时间旅行
rows = cur.fetchall()
cur.execute("CALL DOLT_COMMIT('-m', 'app: nightly sync')") # 程序化提交
conn.close()
在 SQL 里做版本控制的完整能力矩阵:DOLT_ADD、DOLT_COMMIT、DOLT_CHECKOUT、DOLT_BRANCH、DOLT_MERGE、DOLT_RESET、DOLT_REVERT、DOLT_CONFLICTS_RESOLVE,以及 dolt_log、dolt_diff、dolt_status 等只读系统表。
与 MySQL 的差异也需要心里有数(都是明牌):
- 存储引擎只有一个(Dolt 自己的),没有 InnoDB/MyISAM 之分;
- 部分 MySQL 高级特性(如全文索引的部分用法、
CREATE TRIGGER的事件类型)支持面较窄; LOAD DATA、INSERT ... ON DUPLICATE KEY UPDATE等常用语法没问题,但冷门语法建议先在文档确认;- 没有真正的多主复制,
dolt push/pull的同步模型是 Git 式(显式拉取/推送)而非 MySQL 主从复制。
对大多数 CRUD 应用来说,Dolt 是可以当作 MySQL 的替代品来开发联调的——开发环境用 Dolt 享受版本控制,生产环境继续用 MySQL,两者之间用 dolt dump/dolt sql 迁移数据。这也是 Dolt 最常见的落地姿势。
七、实战:一个完整的 Git 化数据库工作流
把前面所有概念串起来,跑一个接近真实的生产场景:用 Dolt 管理一个"商品库",演练 建库 → 发版 → 热修分支 → 合并 → 时间旅行审计 的完整闭环。
# 1. 初始化仓库
mkdir -p shop && cd shop
dolt init
# 2. 建表 + 写入种子数据 + 首次提交
dolt sql <<'SQL'
CREATE TABLE products (
id INT PRIMARY KEY,
name VARCHAR(100) NOT NULL,
price DECIMAL(10,2) NOT NULL,
stock INT NOT NULL DEFAULT 0
);
INSERT INTO products VALUES
(1, '机械键盘', 399.00, 100),
(2, '电竞鼠标', 199.00, 200),
(3, '显示器', 1299.00, 50);
SQL
dolt add -A
dolt commit -m "v1.0: 商品表初始化"
# 3. 发布 v1.1:涨价(在 main 上直接改)
dolt sql -q "UPDATE products SET price = price * 1.1 WHERE id = 1"
dolt commit -am "v1.1: 键盘涨价10%"
# 4. 线上事故:键盘标价错了,开热修分支,从 v1.0 修起
dolt checkout -b hotfix-price -t main~1 # 基于 v1.0 开分支
dolt sql -q "UPDATE products SET price = 429.00 WHERE id = 1"
dolt commit -am "hotfix: 键盘价格修正为429"
# 5. 热修合并回 main(v1.1 的涨价和 hotfix 改的是同一行同一列 → 冲突)
dolt checkout main
dolt merge hotfix-price
# 冲突出现,查看
dolt sql -q "SELECT * FROM dolt_conflicts_products"
# 决定:以热修为准
dolt conflicts resolve --theirs products
dolt commit -m "merge hotfix-price: 采用热修价格"
# 6. 时间旅行审计:查 v1.0 发布时的价格
dolt sql -q "SELECT id, name, price FROM products AS OF 'main~3'"
# 7. 与远程协作:推送到 DoltHub 或自建 remote
dolt remote add origin https://doltremoteapi.dolthub.com/yourname/shop
dolt push origin main
第 5 步的冲突非常典型:v1.1 把价格改成了 438.90(399×1.1),hotfix 把价格改成了 429.00,两侧改同一 cell,Dolt 如实报告冲突,由人拍板。如果 hotfix 改的是 stock 而 v1.1 改的是 price,同行的不同列会自动合并,不会产生冲突——这个"cell 级合并"粒度,是很多号称支持版本控制的数据库做不到的。
再叠加 CI 场景,版本控制数据库的价值会进一步放大:每次 PR 合并后,CI 可以自动对数据仓库执行 dolt merge,跑数据完整性校验,再把结果推送到共享 remote。数据变更从此有了和代码变更同等的评审、追溯、回滚能力。
八、Dolt 2.0 新特性:自动 GC、归档压缩、自适应存储与向量类型
前面讲到,chunk store 是"只增不改"的:每个新 commit 写入新 chunk,旧 chunk 只要还被某个 commit 引用就继续保留。这带来一个经典问题——垃圾回收。
8.1 为什么 GC 是内容寻址存储的生死线
什么时候会产生垃圾 chunk?
dolt branch -d删除分支:该分支独享的提交变成不可达,其 chunk 成为垃圾;dolt reset --hard回退:被回退的提交不可达;- 反复改同一行:每次提交都产生新版本叶子,旧版本叶子在"最近的提交"视角下是历史,但只要还有 commit 引用它就不能删。
在 2.0 之前,Dolt 提供手动 dolt gc:从所有 ref(分支、tag、远程跟踪)出发,沿 commit DAG 做标记-清扫(mark-and-sweep)——能从一个 ref 到达的 chunk 标记为存活,其余物理删除。问题是很多用户根本想不起来跑 gc,仓库越用越胖;而 gc 期间需要独占仓库,大仓库一次 gc 可能耗时很长,运维上很尴尬。
8.2 2.0 的自动 GC 与归档压缩
Dolt 2.0 把 GC 做成了默认开启的自动行为(据官方发布说明与 InfoQ 报道),核心思路是"按需触发 + 后台执行 + 与读写并发安全",不再要求运维手动介入:
- 自动垃圾回收:系统在合适的时机自动执行 mark-and-sweep,清理不可达 chunk,控制仓库膨胀;
- 归档压缩(archival compression):对"冷"的旧 chunk(被历史提交引用、近期不会被修改的节点)做高压缩比编码(类似 zstd 级别的压缩),热数据保持原样以便快速访问。因为历史数据是只读的,压缩后可以安全地长期保存,"保留全部历史"的存储成本被显著压低;
- 自适应存储(adaptive storage):根据仓库的实际访问模式与数据特征动态调整存储策略(比如热表走未压缩、冷历史走归档),在访问速度与磁盘占用之间自动取平衡。
这三件事组合起来,回答了 Dolt 落地时最常被问的一个问题:"保留所有历史,磁盘会不会爆?"——2.0 之前答案是"记得手动 gc";2.0 的答案是"系统自动管,冷数据压缩存放"。
8.3 向量类型(beta):给 AI 场景留的接口
Dolt 2.0 还加入了测试阶段的 VECTOR 列类型支持(对标 MySQL 9 引入的 VECTOR 类型),可以直接存储嵌入向量(embedding)并在 SQL 层面做相似度检索的准备工作。对 Dolt 的典型用户(数据工程师、AI 应用开发者)来说,这意味着带版本控制的向量数据成为可能:训练数据集的每次快照都是一个 commit,向量表可以分支、回滚、diff——"数据可复现"正是 AI 工程最硬的诉求之一。
需要注意,向量检索目前处于 beta 阶段,生产级 ANN 索引能力尚未完全成熟,如果要做大规模向量检索,现阶段仍建议以专业向量库为主,Dolt 负责"版本化 + 可追溯"这一层。
8.4 性能提升
2.0 发布说明中官方声称在多个场景下获得了明显的性能提升(具体数字以官方 benchmark 为准,这里不替官方编数字)。结合架构看,提速主要来自三个方向:压缩后的冷数据读取路径优化、GC 不再阻塞日常读写、以及 chunk store 内部读写路径的改进。对使用者最直接的感觉是:同样的仓库,2.0 占的磁盘更少,日常读写更稳。
九、性能与适用边界:Dolt 适合什么、不适合什么
任何技术都有代价,Dolt 也不例外。把它当成"万能 MySQL"用,会踩坑;用对场景,它是利器。
9.1 代价在哪里
- 写放大:每次提交都要沿树路径写新 chunk,频繁小提交的写放大高于 InnoDB 的 page 写入;不过 2.0 的归档压缩在一定程度上对冲了历史数据的存储成本;
- 单机架构:Dolt 是单写者模型(一个 sql-server 实例一个仓库),没有 MySQL 那种成熟的主从/组复制生态;高可用要靠 remote 仓库 + 重新 clone,而不是数据库原生复制;
- 内存敏感:查询引擎与 Prolly Tree 的缓存设计决定了内存越大越舒服,超大库(TB 级)目前不是它的主场;
- 生态成熟度:相比 MySQL 二十多年的生态,Dolt 在运维工具、监控、云托管方面的积累还在追赶(DoltHub 提供了托管服务,但自建仍需自己搭)。
9.2 适合的场景
| 场景 | 为什么 Dolt 合适 |
|---|---|
| 数据库迁移/重构 | 开分支做 schema 变更,验证通过再合并,迁移脚本可版本化、可回滚 |
| 测试环境数据 | 每个测试用例一个分支,脏数据随便造,用完删分支,不污染主库 |
| 数据审计与合规 | AS OF 时间旅行 + dolt_log 全量审计,一条 SQL 出报告 |
| 数据管道/ETL | 每次抽取结果一次 commit,管道出问题能精确回滚到上一版 |
| AI 训练数据管理 | 数据集版本化、可复现,2.0 的向量类型为 embedding 数据铺路 |
| 数据共享/协作 | clone/push/pull 让数据像代码一样在团队间流转 |
| 应用开发联调 | 开发库用 Dolt(MySQL 兼容),生产库用 MySQL,dolt dump 无缝迁移 |
9.3 和相邻方案的对比
- vs MySQL temporal table:temporal table 只解决"行历史",Dolt 还提供分支、合并、diff、clone,是完整的版本控制系统,而不只是一个特性开关;
- vs binlog/CDC 回放:CDC 是"事后记录",回放到任意时刻需要完整重放日志;Dolt 是"事前快照",任意时刻的查询都是 O(1) 定位到根值,随查随到;
- vs 传统备份:备份是"整个库的副本",Dolt 是"增量 + 共享存储 + 行级追溯",存储效率高一个量级;
- vs Git + 数据文件:把 CSV 塞进 Git 仓库也能"版本控制",但 diff 是整文件级别的,冲突解决靠人肉,Dolt 把粒度做到了 cell 级,且保留了 SQL 查询能力。
一句话总结适用边界:Dolt 不是 MySQL 的替代品,而是"需要版本控制语义的数据库工作负载"的最优解。高频 OLTP、海量数据、强主从复制需求的系统,继续用 MySQL/PostgreSQL;数据研发、AI 工程、合规审计、测试数据管理这些"数据本身是资产"的场景,Dolt 的价值无可替代。
十、总结与展望
回顾全文,Dolt 的技术栈其实只有三个关键决策,却环环相扣:
- 内容定义分块(Prolly Tree)——让树的边界只由内容决定,换来了结构共享、去重与 O(变更量) 的 diff;
- 提交 DAG + 根值哈希——让"整个数据库的任意历史时刻"变成一个 32 字节的哈希,时间旅行、分支、三路合并全部建立其上;
- MySQL 协议兼容 + DOLT_ 存储过程*——让版本控制能力长在标准 SQL 上,业务代码零改造接入。
而 2.0 的自动 GC 与归档压缩,补齐了内容寻址存储最后一块短板——存储成本。至此,"带完整版本控制的数据库"从实验室玩具变成了可以认真考虑的生产选项。
展望未来,有两条线值得关注:一是向量类型从 beta 走向 GA,让"版本化的 AI 数据资产"成为现实;二是 Dolt 这类"数据库即代码仓库"的理念,正在和 GitOps、DataOps、AI 数据治理这些运动合流。当数据工程师开始像软件工程师管理代码一样管理数据,Dolt 就是那个把 Git 基因移植进数据库的作品。
对于还在观望的开发者,我的建议很务实:别拿它替换生产 MySQL,把它用在测试、审计、数据研发、AI 数据集管理这些"数据要折腾"的地方。装一个试试,跑一遍本文第七章的工作流,你会直观感受到"数据可以随便折腾、随时反悔"是一种多么奢侈的体验——而这正是代码开发者早已习以为常、数据开发者刚刚开始拥有的能力。