MatrixOne Git4Data架构原理详解

2026-06-16阅读 0热度 0
人工智能

前两篇我们把“为什么数据需要 Git”和“怎么动手操作”都捋了一遍。特别是动手那篇,最后留下一张数据表,说实话,第一眼看上去挺反直觉的:

表规模CREATE SNAPSHOTCLONEDATA BRANCH DIFF(改 1000 行)
100 万行6 ms6 ms13 ms
1000 万行8 ms8 ms21 ms
1 亿行5 ms25 ms23 ms

数据量从100万涨到1个亿,整整100倍,可打快照的耗时几乎纹丝不动,克隆也只是从6毫秒爬到25毫秒,diff的耗时只跟你改了多少行有关。从直觉上讲,这不太合理——复制1亿行数据,怎么可能眨眼之间就完成?

这篇咱们就把引擎盖掀开,看看底层到底发生了什么。先给个结论:

在 MatrixOne 里,版本控制不是后加上去的一个功能模块,而是存储引擎天生自带的产物。 一旦你理解了数据是怎么存起来的,"毫秒级版本"这件事就不再是魔法,而几乎是唯一合理的结局。

先认识 MatrixOne:存算分离的三类节点

讲版本控制之前,得先搞明白 MatrixOne 的架构长什么样——正是这个架构,决定了后面所有这些操作为什么能这么"便宜"。

MatrixOne 是一个云原生、存算分离的 HTAP 数据库。所谓“存算分离”,就是把计算存储彻底拆开:计算节点自己不长期持有数据,所有数据统一放到底层的对象存储里。整个系统由三类节点加一层对象存储组成(看下图):

  • CN(Compute Node,计算节点):负责执行SQL的地方。它是无状态的——本地不存数据,需要的时候从对象存储里读,本地只当个缓存用。正因为无状态,CN 能随着压力横向扩展,加机器就是加算力。图上最上面那一排就是 CN。
  • TN(Transaction Node,事务节点):事务的"决策者"。它决定一个事务能不能提交,把已经提交的日志串成一条有序的流,然后把这个 WAL(预写日志)推送给那些订阅了相关表的 CN。
  • LogService(日志服务):通过 Raft 协议组成一组(通常是 3 个节点),可靠地保存 WAL。它是整个系统的"真相来源"——只要日志在,崩溃之后就能据此恢复一切。
  • 对象存储(S3):所有表数据的最终归宿,廉价、容量近乎无限、自带多副本。

一次完整的写入流程是这样的:事务在 CN 上执行,所有的改动先暂存在 CN 自己的私有工作区里;提交的时候,TN 审核通过,把 WAL 写入 LogService 落盘,然后再把这条 WAL 推送给所有订阅了这张表的 CN,让它们各自更新对该表的视图。如果是大批量数据,则由 CN 直接写入对象存储,只把“写了哪些对象”这个元数据告诉 TN 就行。

这个架构有两点跟后面要讲的内容密切相关:

  1. 数据与计算是解耦的——数据独立存在于对象存储里,CN 只是一个读取者。所以,"给数据派生一个分支让另一拨人去计算"这件事,本身就不需要复制数据。
  2. 底层是不可变对象加日志——这恰好是做版本控制最理想的地基。咱们下一节深入存储层来看。

数据怎么存:不可变对象 + 一份目录

要想搞明白快照为什么便宜,还得再往深挖一层:CN 把一张表写进对象存储的时候,它在物理上到底是什么样子?记住下面几条规则:

  • 表数据被切成一个个对象(object),每个对象内部以列存的方式保存一批行。
  • 对象一旦写入就是不可变的(immutable)——这是整个机制的基石。想新增数据,就写一个新对象。
  • 对象的组织方式是 LSM 风格的:不可变对象 + 后台的 compaction。有主键或排序键(sort key / cluster by)的表,数据按键有序,扫描的时候可以利用 zone map 这类元信息做裁剪;没有主键的表则走内部的 fake-PK / 全行比对路径。
  • 删除一行并不是真的去对象里把它擦掉(对象不可变,做不到),而是在一个单独的墓碑对象(tombstone) 里写入一条记录,标记一下"这一行已经被删了"。
  • 最关键的一点:一张表当前由哪些对象组成,记录在一份元数据目录(metadata directory) 里。可以把这份目录想象成一张"清单"——它列出了此刻这张表都指向哪些数据对象、哪些墓碑对象。

这几条串起来,一张表此刻"长什么样",完完全全由这份目录决定。真实的数据字节静静地躺在不可变对象里,它们本身不变;变的只是"目录指向谁"


一张表 = 一份指向不可变对象的目录;删除是写一条墓碑记录,而不是去修改对象本身。

再叠加一层 MVCC(多版本并发控制,跟 PostgreSQL 类似):每行带一个事务时间戳,读取时按时间戳过滤,就能还原出"某一时刻的表"长什么样。

记住这个模型:不可变的数据对象 + 一份会变的目录。 接下来所有让人感觉“像魔法”的操作,本质上都是在这份目录上做文章,几乎不碰真实数据。


快照与克隆:不搬数据,只动元数据

到这里,"快照为什么便宜"几乎就是明摆着的事了。不过快照和克隆虽然都不搬数据,实现机制并不一样,值得分开来说一说:

  • 快照CREATE SNAPSHOT v1 FOR TABLE …)的核心动作就是记录一个时间戳,然后通知 GC:"这个时刻可见的对象版本,都给保护起来。"它并没有去复制什么目录——跟表里存了100万行还是1亿行没有任何关系,所以快照永远都是几毫秒。(有个小细节:创建命名快照前,系统会先把还驻留在内存里、还没落盘的数据 flush 成对象——这就是上一篇提到的"第一次快照略慢"的原因,属于一次性开销。)
  • 基于时间戳的版本就更直接了:根本不需要提前保存任何东西——读取的时候按 MVCC 时间戳过滤对象,就能还原出任意时刻的表。这其实就是 PITR(任意时间点恢复)的基础,相当于系统自动帮你打了一连串的 Git commit。
  • 克隆CLONE / DATA BRANCH CREATE)复制的是对象的元数据引用——新表记下"我指向哪些数据对象、哪些墓碑对象"(以及它们的统计信息),对象文件本身一个都不复制。克隆出来的新表和原表从此各自演化、互不影响,但刚开始的时候它们共享同一批底层数据对象。这正是"克隆 6 亿行只要 0.2 秒、只多占 314 KB"的全部原因:那 314 KB 是新表的引用元数据,6 亿行数据一个字节都没动。


快照记录时刻并保护对象,克隆复制对象引用——都指向同一批不可变对象,所以 6 亿行也只要 0.2 秒。

这里还有一个容易忽略、但很关键的设计——垃圾回收(GC)是能感知到快照的。MatrixOne 平时会在后台做 compaction,回收那些不再需要的旧对象;但被命名快照或分支保护起来的对象版本,不会被回收。这也给“成本”加了一个诚实的注脚:创建分支或快照的成本是近似常数级的、零数据拷贝;但长期持有它并不是完全免费的——被钉住的历史对象会一直占着存储空间,直到快照或分支被删除,GC 才能把它们回收。短期存在的分支几乎感觉不到成本,但长期保留的快照,就要把这笔存储成本算进去了。

到这一步,前两篇说的"毫秒级快照""秒级分支"就都落到了实处——因为创建它们根本就不移动数据。


Diff:只扫描"发生了变更的那部分对象"

快照和克隆便宜,好理解,因为它们只动目录、不读数据。那 diff 呢?比对两个版本的差异,总得真的去读数据吧?

是得读,但需要读取的范围小得惊人——这是整套机制里最精巧的一环。

关键的观察是:因为对象只增不改,一条分支从 sn1 到现在的全部修改,都体现在"它比共同祖先多出了哪些对象"——插入会产生新的数据对象,删除会产生新的墓碑对象,更新就是"删除加插入"。我们把"sn2 相对 sn1 多出来的那批对象"记作 Δ_sn2(delta,也就是增量),同理也有 Δ_sn3。

所以——只要两张表之间有血缘关系、能找到共同祖先(LCA)——diff sn2sn3 就只需要读取各自的增量 Δ_sn2 和 Δ_sn3,完全不用扫描两张全表。(如果血缘关系断了,会退化到另一条慢路径,"两方合并"那节我们会细说。)

这就是为什么:表里有1亿行,你只改了1000行的时候,diff 也只需要二十几毫秒——它只读了那1000行所在的几个增量对象。


diff 只读两个分支各自的增量 Δ,给删除/插入标上 ± 号,相同的改动两两抵消,剩下的就是差异。

具体分两步:

  1. 扫描并折叠:扫描 Δ_sn2,把同一主键上的多次物理操作折叠成一个逻辑操作(删除 / 插入 / 更新),然后给删除标上 "−"、插入标上 "+"。这一步跟普通的 LSM 树扫描差不多,区别只在于:不是把被删的行屏蔽掉,而是把删除操作本身也扫描出来。
  2. diff 聚合:把 Δ_sn2 和 Δ_sn3 中完全相同的改动两两抵消(比如两边都删了同一行,或者都插入了一模一样的行)。抵消之后剩下的,就是两条分支之间真正的差异。
这里有个省 IO 的小细节:墓碑记录里只有主键,没有其它列的值。所以扫描出来的被删行先用 NULL 占位,只有确实需要展示完整行的时候,才回到共同祖先 sn1 那里把原值取回来。

你在上一篇看到的 DATA BRANCH DIFF … OUTPUT SUMMARY(给出 INSERTED / DELETED / UPDATED 各自的行数),就是这套聚合算出来的结果。


Merge:三方合并,以及如何自动区分真假冲突

合并比 diff 多干了一件事:遇到冲突要能判定、能裁决。

DATA BRANCH MERGE TClone INTO T 执行的是三方合并——以共同祖先 sn1 为基准,结合 T(sn2)TClone(sn3) 各自相对于 sn1 的修改(也就是前面算出来的 Δ 和 ± 号),逐行进行判定。

核心规则只有一句话:

只有"两条分支都独立修改了同一行,而且改法不一样",才算真冲突。

有主键的情况下,会比较该主键在三个版本(共同祖先、目标、源)里的情况:

  • 只有一边修改了这一行,另一边没动它 → 假冲突,直接采用改动了的那一方的结果,不需要人工介入。
  • 两边都修改了同一个主键,而且结果不同 → 真冲突,按 WHEN CONFLICT 里的规则来裁决:FAIL 就中止、SKIP 就保留目标的结果、ACCEPT 就采用源的结果。

正因为"只有真冲突才需要裁决",哪怕一次合并涉及到上百万行的改动,真正需要人工来定的,往往也就是少数几行真正撞到一起的那些,其余的都由数据库自动合并掉了。

这里有一个特别精巧的细节:后台的 compaction 在整理存储时,有可能把一行移动到不同的物理位置,但它的值完全没有变。这在底层表现出来的形式就是"删除 + 插入",看起来很像是一次修改,容易被误判为冲突。MatrixOne 能识别出"值是没变的,只是位置变了",把它判为假冲突,这样一来,就不会因为一次存储重组,就把另一分支对该行的合法更新给挡掉了。这是整个合并过程中,唯一需要回读完整行来比对值的情形——好在 compaction 之后才会发生,相当罕见。

没有主键的情况下,行无法被唯一标识,MatrixOne 改用多重集计数的方法:统计"完全相同的若干行"在三个版本里各有多少条,通过计数差来判断是哪边改了,或者是不是两边都改了。原理是一样的,只是把"按主键比对"换成了"按数量比对"。


两方合并:为什么你从不需要指定"共同祖先"

细心的朋友可能会注意到:上面的三方合并要用到共同祖先 sn1,但我们之前在 DATA BRANCH MERGE 的时候从来没有指定过它

原因就在于 MatrixOne 自己记录了血缘关系DATA BRANCH CREATE 在派生分支的时候,就已经记下了"它从哪张快照分出来的",所以合并的时候能自动回溯到共同祖先——你看到的所谓"两方合并",底层其实是一次"自动补全了共同祖先的三方合并"。

万一血缘断了怎么办(比如原表和它的快照都被删了,LCA找不到了)?MatrixOne 会退化为全历史比对——从最早可见的时间点开始,把两张表的所有变化都收集起来再做聚合。正确性依然有保证,但性能就不再是有 LCA 时的增量快路径了:本质上跟直接比对两张表的完整历史差不多。所以一个实用的建议是:想长期享受快路径,就用 DATA BRANCH CREATE 建分支,并且保留好血缘上游的快照,别让 LCA 失联。

这一点,也恰好点出了 git4data 跟"自己写 SQL 比对两张表"的本质差距:后者每次都需要扫描两张全表;而 git4data 在血缘可用的情况下,只读取从 LCA 到两侧端点之间的那一小部分变更


数字说话

把原理跟数字对照起来看,一切就都说得通了。

在一台 64 核 / 256GB 的机器上,用大约 6 亿行的大表(TPC-H 100GB 的 lineitem)实测的结果是:

操作git4data 内置等价的纯 SQL 实现
克隆0.2 秒 / 多占 314 KBINSERT … SELECT *:114.6 秒 / 多占 34 GB
Diff(改 100 万行)3.3 秒431 秒
Merge(改 100 万行)16 秒471 秒

内置的 diff/merge 比纯 SQL 实现快了 100–500 倍——原因正如前面所说:纯 SQL 实现每次都得扫描两张全表,不管你改了多少行,它都恒定地慢;而内置实现只扫描增量 Δ。

我们自己在一台单机 Docker(4.0.0-rc1)上做的测试也复现了同样的规律:快照 5–8 毫秒恒定,克隆从 6 到 25 毫秒(会随着对象数量轻微上升,但始终只是在复制几 MB 的目录),diff/merge 只跟改动行数有关。大机器、小机器,道理都一样。


边界:它现在还做不到什么

引擎盖掀开了,也得如实说说它目前还不支持什么(都经过实测确认):

  • 目前只支持两方 diff。 通用的三方 diff 在技术上可行(± 号里已经包含了所需信息),但日常"查看改动"的场景用两方就足够了,所以暂时还没对外暴露。
  • 冲突裁决是行级的,不是单元格级的。 只要两边都改了同一行,哪怕改的是不同列,也算冲突。单元格级的自动合并是未来的工作方向。
  • diff/merge 要求 schema 一致。 一旦你用了 ALTER 改了某张表的结构,它就没法再跟另一张表做行级的 diff/merge 了——所以如果要使用版本控制,应该先改 schema,再做克隆
  • 它管理的是结构化的行,而不是海量的非结构化字节。 对于后者(比如原始图像、视频这种内容级别的版本控制),仍然是 lakeFS 这类工具的主场——git4data 通过 STAGE/datalink 版本化的是文件的"引用",而不是字节本身。

把这些边界讲清楚,反而能把"将版本控制做进数据库内核"这条路勾勒得更清晰。


结语

引擎盖掀开之后,开头那张"反直觉"的表就一点也不反直觉了:

打快照,本质上就是记录一个时刻并保护当时的对象版本,所以跟数据量无关;克隆,只是复制对象元数据引用,大家共享同一批底层对象,所以 6 亿行也只要 0.2 秒;diff/merge,在血缘可用的时候只读取增量对象 Δ,用带 ± 号的聚合来判定真假冲突,所以只随改动行数变化。不可变对象 + 元数据引用 + 沿血缘只读增量——版本控制就是在这三件事的基础上自然生长出来的。

这也解释了一件更大的事:为什么最终是数据库,而不是文件版本工具,扛起了"海量数据的版本控制"这面大旗。因为只有数据库这种"既理解每一行的语义、又能把改动表达成不可变增量"的系统,才能让分支、diff、合并这些操作在 TB 级的数据上变得如此廉价,廉价到可以随手就用,毫无心理负担。

接下来,本系列将转入实践篇:从数据运维(误操作急救、团队协作开发、发布门禁),到 AI 训练(持续学习、SFT 策展、标注协作、RLHF、多模态数据处理),一路走到开篇就埋下的那条线——让那些会自我进化的 AI agent,能够在版本化的数据上安全地探索、评估、合并、回滚。 那才是 git4data 真正想去的地方。

免责声明

本网站新闻资讯均来自公开渠道,力求准确但不保证绝对无误,内容观点仅代表作者本人,与本站无关。若涉及侵权,请联系我们处理。本站保留对声明的修改权,最终解释权归本站所有。

相关阅读

更多
欢迎回来 登录或注册后,可保存提示词和历史记录
登录后可同步收藏、历史记录和常用模板
注册即表示同意服务条款与隐私政策