凌晨三点,一条数据处理流水线(ETL)因为上游数据格式悄悄变了,把生产数据湖里几万个数据文件(parquet)写脏了。数据科学家第二天发现训练集对不上,想做一件在写代码时再自然不过的事——

回滚到昨天那个版本。

然后他发现,自己面对的是一个 S3 桶。没有 git revert,没有 commit,没有分支。桶里只有一堆对象,被原地覆盖过的就永远找不回来了。他唯一的「版本控制」,是上个月手动 aws s3 cp 复制出去的一整份快照——几个 TB,早就过期了。

这就是数据湖时代一个被长期忽视的窟窿:我们给代码做了三十年版本控制,却让喂给模型、撑起决策的数据,裸奔在没有时间轴的存储里。

lakeFS 想补上这个窟窿。它的目标听起来很简单:给数据湖一套 Git——分支、提交、合并、回滚。但真正难的不是这套语义,而是让它在「十亿个文件、跑在对象存储上」的规模下不崩。支撑这一切的,是一个叫 Graveler 的存储引擎。这篇文章就来拆开它。


一、先搞清楚:什么是「真正意义」的数据湖

要理解 Graveler 为什么长这样,得先理解它脚下的土壤——数据湖到底是什么,以及它为什么这么难管。

传统的数据仓库讲究「先规整再入库」——数据进来之前就得定好结构(有哪些列、每列什么类型),干净、规整,但也贵、也死板。数据湖反过来:原始数据原封不动先堆进来,日志、图片、parquet、模型权重,什么格式都行,等用的时候再决定怎么解读。

它能这么「随便」,全靠物理载体是对象存储(S3、GCS 这类,可以把它想象成一个给程序用的超级网盘):容量近乎无限、单位成本极低。先把几十 PB 原始数据扔进去,反正存着不贵,将来想查、想训练模型,到时候再说。

但「先存后用、什么都往里塞」的另一面,就是数据沼泽(data swamp)

  • 这份数据是哪个版本?上游什么时候改过?
  • 上周那次训练用的是哪一版?现在还能复现吗?
  • ETL 把数据写坏了,能回滚吗?
  • 我想试一个新的清洗逻辑,能不能先在「一个分支」上跑,验证没问题再合并进生产,而不影响别人?

这些问题,对写代码的人来说有 Git 就全解决了。但在数据湖里,默认一个都答不上来。数据湖给了你低成本存海量数据的自由,却没给你管理它的工具。lakeFS 的全部意义,就是把 Git 的那套「版本控制心智」搬到这片土壤上。

而真正的难点,藏在土壤本身的物理性质里。


二、海量数据存储的物理约束:为什么 Git 那套直接搬过来会死

对象存储和你笔记本上的文件系统,是两种完全不同的物理世界。把 Git 直接搬过来之所以行不通,原因全在这几条约束上:

1. 它是扁平的 KV,没有真正的「目录」。 s3://bucket/data/2025/01/part-000.parquet 看着像有层级,其实 data/2025/01/ 只是 key 的前缀——一种字符串幻觉。底层就是一张巨大的、扁平的「key → 对象」映射表,没有目录节点这种东西。

2. 对象基本是不可变的。 你不能「修改对象的第 100 个字节」,只能把整个对象重新 PUT 一遍覆盖掉。改一个 bit,等于重写整个文件。

3. 写入很贵。 每次 PUT 都是一次网络往返,还有最小计费单位。lakeFS 团队给过一个很具体的量级:在远程对象存储上,写一个新对象的代价,相当于写 4 MiB 数据(出自 lakeFS 工程博客 Concrete Graveler: Splitting for Reuse;这是他们量化远程 PUT 固定开销的经验性类比,不是 S3 的真实计费规则)。也就是说,写 100 个小文件,远不是写 1 个文件成本的 100 分之一那么便宜——固定开销才是大头。

4. 规模是十亿级。 一个数据工程团队的仓库轻松上十亿个文件。哪怕每天只动 5%,那也是 5000 万个文件在变。

现在把 Git 的模型套上去看看会怎样。

Git 管理目录靠的是 Tree Object:每个目录对应一个 tree,里面列出子项的哈希。改一个文件,它所在目录的 tree 要重写,父目录的 tree 也要重写,一路重写到根。这在本地磁盘上完全没问题——写一个文件和写一百个文件,成本差别不大(约束 3 不存在)。而且本地有真实的目录树(约束 1 不存在),文件系统也支持原地修改(约束 2 不存在)。

但在对象存储上,Git 的每一条前提都不成立。十亿文件的目录树,改动一处就要沿途重写一长串 tree 对象,每一次重写都是一次昂贵的 PUT。更要命的是 diff:要找出两个相邻版本的差异,Git 式做法得扫描整棵目录树,代价是 O(总文件数) 而不是 O(差异大小)。在十亿文件规模下,这直接出局。

所以 lakeFS 不能照抄 Git 的数据结构。它得重新设计一套——既保留 Git「内容寻址」的灵魂,又能在对象存储的物理约束下活下来。

要讲清楚这套设计,得先借一个你大概率更熟悉的系统打个样——它跟数据湖看起来八竿子打不着,骨子里用的却是同一套数学。


三、核心思想:哈希即身份

那个系统是区块链。一个管钱、一个管文件,凭什么算同一套数学?因为它们要解决的底层问题完全一样:在一个没人完全可信的环境里,怎么让任何人都能独立验证「这份数据没被动过手脚」。 区块链给出的答案,叫内容寻址(content-addressing)。

把一个比特币区块拆到最小,本质是三样东西:一批交易、前一个区块的哈希、以及这批交易的哈希摘要(Merkle Root)。这里的灵魂是哈希的一个性质——内容决定身份。把「Alice 转给 Bob 1 BTC」改成「0.001 BTC」,哈希值立刻面目全非。

由此带来一个关键能力:

只要你拿到区块的哈希,就能验证里面任何一笔交易有没有被人动过——不用看全部数据,只需沿着 Merkle Tree 验证一条路径。

Merkle Tree 的构造很朴素:所有交易两两哈希、结果再两两哈希,一路向上汇成一个 Merkle Root。一个区块里有一万笔交易,要验证其中某一笔是否存在,只需 log₂(10000) ≈ 14 次哈希。这就是把「数据完整性校验」从 O(N) 压到 O(log N) 的核心魔法。

区块之间又用哈希串成链:每个区块都嵌着前一块的哈希。想偷改一千块以前的某笔交易?那个区块的哈希会变,导致下一块的 prevHash 失效,再下一块也失效……整条链从那里开始级联断裂。所谓「不可篡改」,不是什么魔法,就是这种级联失效。

到这里,请记住内容寻址给我们的两件礼物:

  1. 完整性可验证——哈希就是数据的不可伪造指纹。
  2. 去重与复用——内容相同则哈希相同,相同的东西天然只存一份、可以共享。

但也请记住一个尖锐的矛盾,它会贯穿这篇文章的后半段:内容寻址的全部威力,建立在「不可变」之上。可版本控制的本质,偏偏是「东西在不停地变」——分支在移动,HEAD 在前进,每次提交都在改写「当前状态」。

一个把「不可变」刻进基因的思想,怎么用来管理一个「随时在变」的系统?

带着这个问题,回到数据湖。


四、Graveler 的数据单元:ValueRecord

在拆零件之前,先把整机说清楚,免得迷路。Graveler 的核心思路一句话就能讲:把整个数据湖的文件清单切成一批批小块,每块按内容算一个哈希指纹;提交时只重写内容变了的那几块,其余的直接拿旧指纹指过去、原样复用。 这样哪怕仓库有十亿文件、每次只改 1%,真正要写进对象存储的也只有那 1%。下面的 ValueRecord、Range、Meta Range,都是为了兑现这一句话而设计的零件。

lakeFS 把元数据编码成一种格式,叫 Graveler——一套专为对象存储设计的、内容寻址的键值存储。最基本的单元是 ValueRecord,可以把它想象成图书馆的一张书目卡,三栏分开写:

ValueRecord {
  key:      文件路径,如 "data/2025/01/part-000.parquet"(书名)
  identity: 文件内容的指纹,如 sha256(内容摘要码)
  value:    文件在对象存储上的真实地址 + 元数据(书架位置)
}

精妙之处在于「内容摘要码」(identity)和「书架位置」(value)是分开的。Graveler 判断两条记录是否「相同」,只看书名 + 摘要码,不看书架位置——就像你光对比两张卡片的摘要码,就知道是不是同一本书,根本不用跑到书架前把书搬下来翻。这让比较操作极其廉价:不拉一个字节的数据,比指纹就行。

而一个 Graveler 文件(一个 Range 文件)的 ID,是这么算出来的:

valueRecordID = h( h(key) || h(identity) )
fileID        = h( valueRecordID_1 || valueRecordID_2 || ... || valueRecordID_N )

|| 表示把字节序列按序拼接;lakeFS 原始公式外层写作 +,含义相同。)看清楚这个结构——一个 Range 文件的 ID,由它包含的所有记录共同决定。改动其中任意一条记录,整个文件的 ID 就变了。这和区块链的 Merkle Root「内容决定身份」是同一套逻辑,只不过这里汇聚的不是交易,而是文件元数据。


五、两层 Merkle 树:扁平,所以高效

单个 Range 文件一般控制在 1–10 MiB,存的是一段连续键空间里的所有 ValueRecord——所谓键空间,就是把所有文件路径按字母顺序排好后,切出来的一段连续区间(比如从 a 开头的路径到 e 开头的路径)。

但一个仓库有十亿文件,不可能塞进一个 Range。于是 lakeFS 在 Range 之上再汇一层,叫 Meta Range

Meta Range
├── Range_A(键空间  aaa... ~ eee...)
├── Range_B(键空间  eee... ~ kkk...)
├── Range_C(键空间  kkk... ~ ppp...)
└── Range_D(键空间  ppp... ~ zzz...)

Meta Range 里存的是每个 Range 的「最大键」和它的「Range 文件 ID(哈希)」。一个 commit,本质上就是一个指向某个 Meta Range 的哈希指针。

这是一棵高度恒为 2 的 Merkle 树,结构上等价于一棵两层的 B+ 树:Meta Range 是根,Range 文件是叶子,每一层都是内容寻址的。给我一个 commit 的 Meta Range 哈希,我只需固定两跳——先在 Meta Range 里定位到对应的 Range,再进那个 Range 内部查找——就能验证任意一个文件的状态。这里和比特币 Merkle 树有个微妙却关键的差别:比特币的树会随交易数增长而加深(log₂N 层),而 Graveler 刻意把树压成永远两层、不随文件数加深。凭什么敢这么设计?下一段就是答案。

为什么两层就够了,不怕树太「胖」? 这是个漂亮的工程判断。一个 10 MiB 的 Meta Range 能引用足够多的 Range,让整棵树轻松覆盖数亿个文件。lakeFS 团队算过:就算 Meta Range 文件变得很大也没关系——因为任何 B+ 树里,几乎所有节点都集中在叶子层。再加一层中间节点,缓存和去重效率的提升微乎其微,访问却要从 2 跳变成 3 跳。所以他们干脆固定用两层,简单且够用。

Range 的边界到底怎么切?

这里藏着 Graveler 最容易被忽略、却最关键的一个设计。

把十亿文件切成一个个 Range,边界画在哪?最直觉的做法是按固定大小切:写满 10 MiB 就断一刀。听起来没毛病,但它会引发一场灾难——

假设你在键空间的最前面插入了一条新记录。按固定大小切的话,第一个 Range 满了就往后顺延,于是后面每一个 Range 的边界全部往后挪一位。结果是:哪怕你只加了一条记录,几乎每个 Range 的内容都变了,哈希全变,全部要重写。复用率瞬间归零。这就像在一篇排好版的书最前面加一个字,后面每一页的分页全乱了。

lakeFS 的解法是 基于 key 哈希的断点(hash-based breaks):要不要在某条记录后面断开成一个新 Range,不取决于「写到第几字节了」,而取决于这条记录的 key 的哈希值(配合 min/max 大小做边界保护)。

这个改动看似微小,效果却是质变:断点只由 key 的内容决定,和它在序列里的位置无关。 于是同一个 key,无论在哪个分支、哪个 commit 里,都会在同一个地方触发断点。两棵不同的 Merkle 树,只要某段键空间的内容一样,断点就会对齐,那段 Range 文件就能被两边共享同一份

这正是 rsync、restic 这类去重系统里 content-defined chunking(内容定义分块) 的思想。它带来一个尤其重要的好处:当两个分支各自提交了大量「文件相同、只是顺序不同」的改动后再 merge——如果按固定大小切,断点对不齐,几乎所有 Range 都得重新生成;而 hash-based breaks 让两个分支的 Range 端点天然落在相同的 key 上,大量 Range 直接复用。(lakeFS 的实际参数是 min_size=0max_size=20MiB,断点疏密由一个叫 raggedness 的参数控制,倾向于平均每约 5 万条记录断一次;但大多数 Range 会在记录数堆到那么多之前,先撞上 20MiB 上限被切开,所以单个 Range 实际装几千到几万条不等。)

记住这个机制,因为它正是下一节那个「奇迹」的地基。


六、写入放大的奇迹:99% 复用,以及和区块链分道扬镳的那一刻

现在把前面所有零件拼起来,看一次真实的提交。

假设某次 commit 只改了 data/2025/10/ 下的 500 个文件,这些路径全部落在 Range_C 的键空间里。Graveler 的写入过程是这样的:

  1. 重写 Range_C,生成新的 Range_C'(哈希变了);
  2. Range_ARange_BRange_D 一个字节都不碰,直接复用原文件——因为它们的内容没变,哈希没变,hash-based breaks 又保证了边界没漂移;
  3. 重写 Meta Range,把指向 Range_C 的条目换成 Range_C',生成新的 Meta Range’。

整个提交,只写了 2 个新文件Range_C'Meta Range'),其余全部通过哈希引用复用。

这个复用率有多高?lakeFS 用两个真实设计伙伴的 S3 inventory 做过测量:一个仓库每天的变化率大约在 5–20% 之间。按每天 20 次提交估算,单次提交平均改动不到整个仓库的 1%,对应的 Range 复用率 ≥ 99%(这两个数字都逐字出自 lakeFS 官方文档 Versioning Internals)。

一个更具体的例子能让你看到它在生产里有多顺滑:假设数据按 input/YYYY/MM/DD/hh:mm/ 这样的时间前缀组织,每小时提交一次。那么在 03:00 这次提交里,所有「最大键早于 02:00」的 Range 根本不会变,全部复用;只有 02:00–03:00 这一小段新数据所在的一两个 Range 需要重写。十亿文件的仓库,每小时的提交实际只动一两个 Range——这就是为什么它在真实负载下扛得住。

到这里,是时候点破一个反转了。

读到第三节,你可能以为:既然区块链早就把内容寻址玩明白了,那 Graveler 不就是「把区块链搬到数据湖」吗?

恰恰相反。这两者在最关键的地方完全对立

  • 区块链刻意不复用。 每个区块自成一体,交易绝不跨区块共享——因为它的目标是防双花、防篡改,独立和冗余正是安全的来源。
  • Graveler 的命根子就是复用。 99% 的效率全靠 Range 跨 commit、跨分支共享。它的目标不是防谁作恶,而是在昂贵的对象存储上把写入放大压到最低。

同一套「内容寻址 + Merkle 树」的数学,因为约束和目标不同,长出了截然相反的形态。这恰恰说明:Graveler 不是抄区块链,而是和区块链共享了同一个更古老的祖先——内容寻址。 把它俩摆在一起看,看到的不是「谁模仿谁」,而是一个好思想在不同土壤里各自生长出的不同样子。


七、Merkle 树的第二件礼物:把「找不同」也变成 O(差异)

复用省的是。但 Merkle 树还顺手解决了另一个数据湖老大难——diff

回想第二节那个痛点:Git 式做法要找出两个版本的差异,得扫描整棵目录树,代价 O(总文件数)。在十亿文件下不可接受。

Graveler 怎么做?因为每个 Range 的哈希就是它内容的指纹,diff 两个 commit 时:

只需读取两个 Meta Range,逐一比对它们引用的 Range 哈希。哈希相同的 Range,整段直接跳过——连读都不用读。 只有哈希不同的那几个 Range 才需要拉下来逐条比对。

于是 diff 的代价从 O(总文件数) 降到了大致 O(差异大小)——严格说,是「差异大小,加上一次对 Meta Range 里 Range 哈希列表的线性扫描」,但后者相比文件总数小到可以忽略。

举个夸张点的例子:两个各含十亿文件的版本,相隔三小时、改了 1 万个文件。Git 式做法要把十亿条记录扫一遍才能告诉你差在哪;Graveler 只需翻开两份「目录」(Meta Range),看看哪几个 Range 的「封面哈希」换了——大概率就两三个——再只打开这两三个 Range 逐条比。十亿条的扫描,缩成几千条。 用时从分钟级掉到毫秒级。

merge 也一样(lakeFS 内部按三方 diff 实现:拿两个分支和它们的共同祖先放在一起比,找出各自改了什么再智能合并):只有真正变了的 Range 需要读写,没动的全程旁路。分支、提交、合并、回滚——Git 的整套语义,就这样在十亿文件规模上跑了起来,每个操作的代价都只和「改了多少」成正比,与「总共有多少」无关。


八、不可变 vs 可变:双轨制如何收束那个矛盾

还记得第三节埋下的矛盾吗?内容寻址的威力建立在「不可变」上,可版本控制的本质是「不停地变」。Graveler 怎么同时要这两样?

答案是把数据按可变性分成两个世界,各用最适合的存储:

不可变的世界——已提交数据(committed metadata)。 Meta Range 和 Range 文件一旦写下就永不更改,只靠哈希引用,没有「原地修改」的概念。这部分直接存在对象存储里(S3/GCS)。因为不可变,它们极易缓存——一旦缓存,除非腾空间否则永不失效,完全不需要复杂的缓存失效逻辑。这和区块链「已确认的历史区块不可篡改」是同源的。

可变的世界——引用与暂存(refs & staged metadata)。 分支本质上是一个指向某个 commit 的指针。每次提交、每次合并,这个指针都要更新——这是高频的随机写,和「不可变」水火不容。还有暂存区里那些「写了但还没 commit」的改动,同样是高度易变的。这部分 lakeFS 单独放进 KV Store(PostgreSQL、DynamoDB),用一种「条件写」机制——compare-and-set,写之前先确认这个值没被别人动过,类似乐观锁——来保证并发写入不打架。毕竟,如果连 main 分支当前指向哪个 commit 都读不到,半个系统就瘫了,这部分必须强一致且高可用。

于是那个矛盾被漂亮地化解了:所谓 commit,就是把一批改动从「可变的暂存世界」凝固进「不可变的已提交世界」的那一瞬间。 易变的指针负责「现在指向哪段历史」,不可变的 Merkle 树负责「每段历史是什么、且永不被篡改」。两个世界各司其职,谁也不必为对方妥协。

这套分层,和区块链其实也暗合:已确认的历史区块是不可变的,而内存池(mempool)里待确认的交易是高度可变的——两者用的是完全不同的存储与一致性策略。(这是个松散类比:mempool 是待打包的交易池,lakeFS 的 staging 是分支级的未提交改动,两者一致性模型并不相同,但「历史区不可变、待定区可变」的分层直觉是相通的。)好的系统设计,往往都懂得「该不变的地方坚决不变,该灵活的地方单独隔离」。


九、回到那个凌晨三点

现在回到开头那个被 ETL 写脏数据、却无从回滚的数据科学家。如果这座数据湖跑在 lakeFS 上,他的故事会完全不同:

  • 他可以 git revert 那次提交——分支指针挪回上一个 commit,几秒钟完成,因为根本不用搬数据,只是改了一个指向不可变 Merkle 树的哈希指针。
  • ETL 流水线本可以先在一条 staging 分支上跑,验证产出无误,再 merge 进 main——而开一条分支是零拷贝的,不需要复制那几个 TB,只是新建一个指向同一个 Meta Range 的指针。
  • 那次训练用了哪一版数据?记下 commit 哈希就行,它对应的整棵 Merkle 树永不改变,随时可以精确复现。

这些能力,没有一个是凭空变出来的。它们全都长在 Graveler 那两层内容寻址树之上——是「哈希即身份」「内容相同则可复用」这两个朴素性质,在对象存储的物理约束下被推到极致的结果。

最后值得一提的是这套思想的来路。Ralph Merkle 在 1979 年提出 Merkle Tree,证明可以用 O(log N) 验证一个元素是否属于某个集合;2005 年的 Git 把内容寻址用在代码版本控制上;2008 年中本聪把它装进比特币,配上区块间的哈希链;2021 年前后成型的 lakeFS Graveler,则把它推到了对象存储规模——保留内容寻址和 Merkle 树的内核,重新设计树的形状(两层 B+ 树)和分块策略(hash-based breaks),让 Range 能在海量 commit 之间高效复用。

每一代都不是简单照搬,而是在吃透底层数学之后,针对新的约束——规模、网络延迟、写入代价——做出新的工程取舍。

中本聪从没想过要替谁省对象存储的账单;Ralph Merkle 1979 年证明那个定理的时候,世上还没有 S3,也没有 parquet。但他们奠定的数学,今天正运行在你每一次 lakectl commit 里。这大概就是好思想的命运:它不在乎你最初拿它来证明什么,只等着被搬到对的土壤里,长出一副你认得出血脉、却从没见过的新模样。