☰
lakeFS Graveler 元数据 KV 化:KV3 执行计划、Key Schema 设计与乐观提交流程
2026/10/13 9:38:09 网站建设 项目流程
  • 数据工程
  • 数据湖
  • 大数据
  • 对象存储
  • 后端

【免费下载链接】lakeFS

lakeFS - Data version control for your data lake | Git for data

项目地址:https://gitcode.com/gh_mirrors/la/lakeFS
点击查看免费下载

本文围绕 lakeFS 已接受的设计文档 kv3-execution-plan.md 展开,讲清 graveler(lakeFS 的元数据存储层)从 SQL(PostgreSQL)迁移到通用 KV 存储的完整执行计划:5 张元数据表如何映射为三类 KV 分区、仓库/分支/提交/暂存对象各实体的键布局、仓库删除在分布式失败场景下如何保持一致性,以及基于SetIf的乐观并发提交算法。读完本文,你可以掌握 lakeFS 在 KV 上设计键空间、处理乐观锁冲突与分区隔离的工程方法,并能对照当前源码验证这套设计已经落地为pkg/graveler的生产实现。

背景:为什么要给 graveler 换存储

lakeFS 的元数据分两类(见总体设计文档 lakeFS on KV):

  1. 不可变元数据:已提交的 entries,以 range / metarange 形式存放在对象存储中;
  2. 可变元数据:IAM 实体、分支指针、未提交(staging)entries 等,当时存放在 PostgreSQL 中,并依赖其事务语义。

KV3 执行计划的目标,就是让第二类元数据改由一个语义窄得多的 Key/Value 接口承载,从而让 lakeFS 能在多种后端数据库上运行、便于多租户托管,甚至无外部依赖地本地运行。该接口(在 index.md 中定义)只有Get、Scan、Set、Delete和带谓词的SetIf等操作,并提供按"分区(partition)"组织的键空间——这直接决定了后文三类分区的布局。

在动手之前,执行计划首先盘点了 graveler 当时使用的5 张 SQL 表,并逐一评估其迁移难度:

SQL 表使用方迁移要点
graveler_repositories、graveler_branches、graveler_commits、graveler_tagspkg/graveler/ref前三张表基本不可变(graveler_commits仅在refs-restore场景下更新 generation,且只发生于 bare 仓库,可不关心),可较直接地在 KV 上实现;graveler_branches原先由锁保护,需要新的无锁提交方案,第一步可先用朴素表锁或基于 branch-key 的锁过渡
graveler_staging_kvpkg/graveler/staging条目以当前分支的staging_token为基准创建,且需防止提交期间继续写入,同样先以朴素加锁起步

文档同时注意到,这些表(连同auth_users)还被pkg/diagnostics以只读方式用于诊断采集,属于后续可选支持项。

实体设计:五种元数据在 KV 中的形态

执行计划为每种实体给出了类型定义,并明确"每个数据访问都隐含一次GetRepository"这一前提——既要验证仓库存在,又要取回构建分区键所需的唯一 ID。

Repository(仓库)

仓库由唯一名称(RepositoryID,string)标识,内容包含存储命名空间(StorageNamespace)、默认分支 ID(BranchID)和创建时间(time.Time)。计划要求所有仓库条目放在同一个通用分区(graveler)中,以支持仓库列表迭代;同时为区分"已删除的同名仓库"与"新建的同名仓库",仓库条目必须包含一个随创建生成的随机UniqueID,与仓库名一起构成该仓库所有实体的分区键——这样aRepo/UniqueID1与aRepo/UniqueID2两个分区天然可辨。计划中的结构定义如下(RepositoryToken为新增字段):

type RepositoryToken string type Repository struct { StorageNamespace StorageNamespace `db:"storage_namespace" json:"storage_namespace"` CreationDate time.Time `db:"creation_date" json:"creation_date"` DefaultBranchID BranchID `db:"default_branch" json:"default_branch"` RepositoryToken RepositoryToken `json:"repository_token"` // new field }

对照当前源码,这一设计已被实现且有所演进:pkg/graveler/graveler.go 中的Repository结构体用InstanceUID字段承载了计划中的UniqueID,由NewRepoInstanceID()基于 xid 生成;此外还增加了State(仓库状态,仅ACTIVE视为有效)与ReadOnly字段,删除流程即依赖IN_DELETION状态(下文详述)。

Branch(分支)

分支由"仓库内唯一名称"标识,因此<REPO_NAME>+<BRANCH_NAME>全局唯一;由于除仓库自身外的所有实体都归属于仓库分区,分支在分区内以名称为键即可。除当前CommitID与StagingToken外,为支持 lakeFS on KV 设计文档中提出的 commit flow,分支新增sealed_tokens字段——一个StagingToken数组:

type Branch struct { CommitID CommitID `json:"commit_id"` StagingToken StagingToken `json:"staging_token"` SealedTokens []StagingToken `json:"sealed_tokens"` // new field }

当前源码中该字段已落地为Branch.SealedTokens(见 pkg/graveler/graveler.go),注释说明"staging token 从数组前部追加(prepend),以便方便地构建 diff 迭代器",后续又扩展了CompactedBaseMetaRangeID与Hidden字段。

Tag(标签)与 Commit(提交)

标签在仓库分区内以TagID唯一标识,内容为一个提交 ID;提交在仓库分区内以 Commit ID(sha256 的十六进制串)唯一标识,包含 version、committer、message、metarange ID、创建时间、父提交列表、metadata 映射与 generation:

type TagRecord struct { TagID TagID `json:"tag_id"` CommitID CommitID `json:"commit_id"` } type Commit struct { Version CommitVersion `db:"version" json:"version"` Committer string `db:"committer" json:"committer"` Message string `db:"message" json:"message"` MetaRangeID MetaRangeID `db:"meta_range_id" json:"meta_range_id"` CreationDate time.Time `db:"creation_date" json:"creation_date"` Parents CommitParents `db:"parents" json:"parents"` Metadata Metadata `db:"metadata" json:"metadata"` Generation int `db:"generation" json:"generation"` }

提交 ID 由其内容哈希派生(addressProvider.ContentAddress(commit)),这使得同一提交重复写入必然等价,天然幂等——当前 pkg/graveler/ref/manager.go 的addCommit正是利用这一点:对"key 已存在"的SetMsgIf谓词失败不报错。

Staged Object(暂存对象)

暂存对象由staging_token+ 对象路径唯一确定,内容是序列化为 protobuf 的对象元数据(DBEntry)及其校验和。为最小化实现瓶颈,每个 staging token 独占一个分区:token 作为分区键,对象路径作为分区内的 key。计划指出staging_token在分支内唯一且本身以仓库名、分支名为前缀,因此是恰当的分区名:

type Value struct { Identity []byte `db:"identity" json:"identity` Data []byte `db:"data" json:"data` }

当前源码中 staging token 的生成与计划一致:GenerateStagingToken返回"<repoID>-<branchID>:<uuid>"(见 pkg/graveler/graveler.go 中的GenerateStagingToken),既保证唯一,又便于按分支识别 token。

迭代器:从 SQL 游标到 KV Get/Scan

执行计划将pkg/graveler用到的迭代器逐一映射到 KV 原语:

  • branch_iterator(按仓库迭代分支):按 ID 排序用标准Scan即可(trivial);按 CommitID 排序则实现为"取回该仓库全部分支后在内存中排序"。
  • commit_iterator(按祖先关系 BFS 迭代提交):它不是 KV 意义上的迭代器——每个提交按 IDGet一次、父提交入队待扫,KV 层无需特殊支持。
  • commit_ordered_iterator(按 commit ID 排序迭代仓库提交):Scan的自然行为即可支持;"只迭代非 first-parent 提交"的选项当时待定(TBD),计划认为可以为其定制一个专门的Next()。
  • repository_iterator、tag_iterator:按 ID 排序的标准Scan足够。
  • staging/iterator:以特定staging_token分区为范围、按 key 顺序扫描,同样由Scan支持。

这些迭代器在仓库中均有对应实现与测试:branch_iterator.go、commit_iterator.go、commit_ordered_iterator.go、repository_iterator.go、tag_iterator.go 以及 staging/iterator.go。其中按提交排序的迭代器(NewOrderedCommitIterator)还被GetCommitByPrefix复用:前缀定位SeekGE(prefix)后顺序扫描,用于支持短提交 ID 前缀解析。

建议的 Key Schema:三类分区

计划提出的键布局使用3 类分区,这是整个 KV3 设计的核心:

* 分区 `graveler`(通用分区) * Repository - `repos/<REPO_NAME>` * 分区 `<REPO_NAME>-<UNIQUE_ID>`(每个仓库一个专属分区) * Branch - `branches/<BRANCH_NAME>` * Commit - `commits/<COMMIT_ID>` * Tag - `tags/<TAG_ID>` * 分区 `<STAGING_TOKEN>`(每个暂存 token 一个专属分区;token 内含仓库名与分支名) * Staged Obj - `key/<OBJ_KEY>`

设计动机("Key Schema 对功能的支持"一节):

  • 通用分区graveler:不掌握任何分区信息即可访问仓库、支持仓库列表迭代;仓库条目在该分区中的存在/缺失单独决定仓库是否有效。
  • 仓库专属分区:仓库相关条目全部聚合在一个以<REPO_NAME>-<UNIQUE_ID>命名的分区下,删除仓库即可按整分区删除;UNIQUE_ID用于区分同名仓库的不同生命周期实例。由于取分区键需要先执行GetRepository拿到UniqueID,这一强制前置访问同时起到了"仓库未被删除"的校验作用。
  • staging token 专属分区:在不同 token 之间天然负载均衡,缓解 staging 与 commit 的热点。

当前源码中该布局已完整实现,见 pkg/graveler/model.go:RepositoriesPartition()返回常量"graveler";RepoPartition(repo)返回"<repoID>-<InstanceUID>";RepoPath/BranchPath/CommitPath/TagPath分别生成repos/…、branches/…、commits/…、tags/…前缀;StagingTokenPartition(token)直接以 token 字符串为分区名。此外该文件的init()通过kv.MustRegisterType为每个前缀注册了 protobuf 类型(RepositoryData、BranchData、CommitData、TagData、StagedEntryData),即所有实体在 KV 中的值均以 protobuf 编解码——这正是计划中"复用现有DBEntry类型、序列化为 protobuf"的落地方式。细节差异在于:当前实现的 staging 分区内以对象路径本身为 key(无key/前缀),见 staging/manager.go 中直接以key调用kv.GetMsg/kv.SetMsg的代码。

删除仓库:没有事务还能保持一致吗

其他实体的删除各自独立,而删除仓库会级联删除其部分支、提交与标签。SQL 下这由一个事务保护,KV 下则必须显式设计失败行为。计划给出的方案是:

  1. 先通过GetRepository构建分区键(同时完成仓库存在性校验);
  2. 删除graveler通用分区中的仓库条目——仓库从此不可访问,等于逻辑上"不存在";
  3. 再用该分区键删除仓库的所有相关条目(整个仓库分区及其 staging 分区)。

由此分析出两种失败边界:若在第 2 步之前失败,仓库原样保留、仍然可用;若在之后失败,仓库分区与相关 staging 分区都只是变得"不可达"。即使之后用同名重建仓库,也会生成新的UniqueID,旧分区的残留依旧不可达。任何情况下 DB 都保持一致,只是可能遗留孤儿分区。计划据此提出实现周期性后台 GC 清理这些分区(通过维护一份to-delete分区列表启动删除流程),但明确该工作超出 KV3 范围(对应 issue #3590)。

对照当前实现,pkg/graveler/ref/manager.go 中的DeleteRepository在顺序上做了等价但更稳健的变形:先将仓库State置为IN_DELETION(此后GetRepository对该仓库返回ErrRepositoryInDeletion,实现"先不可访问、再清理"的语义),随后并发删除所有分支、标签、提交与仓库级 metadata,最后才删除通用分区中的仓库主记录(deleteRepository)。这与计划"先摘除入口、再清理子实体"的一致性论证完全同构:任一步骤失败,要么仓库仍可完整访问,要么仅留下孤儿数据,graveler 本身不会读到半删除的活跃仓库。

更新提交、staging 与无锁(lock-free)

这是执行计划中最关键的并发设计。SQL 方案靠分支行锁串行化提交;KV 下计划采用乐观并发:

  • 利用 KV 的SetIf(条件写)更新分支条目,保证没有另一个并发提交"抢先完成"而被本提交覆盖;
  • 若SetIf失败(谓词不成立,说明有并发提交抢先更新了分支),则本次提交失败并重试;
  • 该算法要求分支支持多个 staging token(即sealed_tokens):提交开始时把当前staging_token封存进sealed_tokens并换发新 token(同样经SetIf原子完成),从而提交过程中新的 staging 写入落到新 token,不会被本次提交误读;最终迭代所有sealed_tokens构建提交。

上图(来自同目录的 committer flow 设计图)完整展示了这条链路:读分支 → 封存旧 token、换新 token(SetIf)→ 迭代全部sealed_tokens应用到新提交 → 重读分支 → 以SetIf写入分支(替换 commit ID、清除已共享前缀的sealed_tokens)→ 条件失败则重试或上报调用方。

计划同时划定了范围边界:完全无锁(lock-free)的提交(对应设计 PR "Change KV storage proposal to guarantee progress")不在 KV3 里程碑内——KV3 只做"防覆盖 + 重试"的乐观方案,无锁化留待后续。

从当前源码可以验证这条链路:Graveler.Commit(pkg/graveler/graveler.go)先在BranchUpdate中把branch.StagingToken前插进SealedTokens并调用GenerateStagingToken换发新 token,随后在retryBranchUpdate内计算 parents、执行 hook、落 metarange 并更新CommitID;而RefManager.BranchUpdate(pkg/graveler/ref/manager.go)正是"读分支 + 谓词 → 回调更新 →kv.SetMsgIf条件写"的乐观协议。在其之上,当前实现又叠加了两层后续演进:一是batch.Batcher对同键读请求做批合并(getRepositoryBatch、getBranchWithPredicate等,带maxBatchDelay),摊平读放大;二是可选的"近似分支属主"机制(distributed.MostlyCorrectOwner,见 pkg/distributed/mc_owner.go)——通过周期性刷新属主记录让并发写者大多自动退让,降低SetIf冲突重试率,注释明确这"只影响性能、不影响正确性,任何时刻只有一个更新能赢得竞争"。

staging 侧同样体现了乐观协议与分区化思想:pkg/graveler/staging/manager.go 中Set(requireExists=true)用PrecondConditionalExists谓词、Update用读时谓词做条件写;删除整个 staging token 时,DropAsync只在cleanup-tokens分区登记一个条目,由后台asyncLoop周期性取出 token 列表(还会 shuffle 以降低多服务器重复处理概率)再逐前缀清理——这是"分区删除异步化、限流执行"的实际形态。

执行计划:分步落地路线

文档的 Execution Plan 一节把迁移拆成可独立验收的步骤(issue 编号为原文引用,此处仅作事项标识):

  1. 敲定 Key Schema(即上文的三类分区布局,#3567)。
  2. KV 与 SQL 双轨运行(与之前模块迁移的做法相同):
    • Step 1:构建初始KV graveler与DB graveler,KV graveler将所有调用委托给DB graveler(此步骤完全不用 KV),以便按块逐块实现 KV 版本(#3568)——这是典型的 Facade/委托式渐进替换;
    • Step 2:在KV graveler中实现基础 KV 使用:Repository(不含删除,#3591)、Branch(#3592)、Tag(#3593)、Commit(不含乐观锁提交流程,#3594)、Staging(#3595);
    • Step 3:乐观锁提交——用SetIf设置分支的 commit,SetIf失败即失败并重试(#3569);
    • Step 4:确定并实现删除仓库的工作方案(#3570)。Step 3 与 Step 4 相互独立。
  3. 常用操作基准测试(#3571):手工在 SQL 与 KV 两套实现上执行,验证性能令人满意。
  4. 既有 SQL 数据向 KV 的迁移(#3572):大部分表迁移平凡;graveler_branches预计也无需对sealed_tokens做特殊处理(需验证);KV 迁移测试自然包含在内。
  5. 将 graveler 迁移纳入 Esti 迁移测试(#3573):lakeFS 的端到端测试框架 Esti(位于 esti/,含大量.golden期望输出文件)承担"迁移前后行为一致"的回归验证。
  6. 可选项:Dump/Restore Refs 的 SQL 与 KV 双实现测试(#3574);pkg/diagnostics的 KV 支持(#3575,预计平凡)。

其中 Dump/Restore Refs 本身值得单列:它使用 graveler 的 ref-store 对整个仓库(分支、提交、标签)做导出与向 bare 仓库的恢复。由于不直接访问数据库,理论上可无缝工作,但计划强调"必须验证";又因其覆盖面广,还可兼作性能验证手段。

测试与基准:先想清楚测什么

计划对测试的判断很坦率:pkg/graveler/ref与pkg/graveler/staging当时测试覆盖度极高(当前仓库中这两个目录下几乎每个实现文件都配有同名_test.go,如 manager_test.go、staging/manager_test.go),这让迁移 KV 更令人放心;但缺少 benchmark。而 graveler 为支持无锁提交将引入新算法,存在性能退化风险,必须尽早识别。

计划进而讨论了"哪些场景值得测",并给出取舍:

  • 反面示例——"多个并行提交互相失败重试直到全部成功":这是一个穷举式极端场景,能把非无锁算法的性能逼到边缘;可以预见它必然表现糟糕,但结论没有信息量,"不感兴趣";
  • 正面示例——"一次长提交进行的同时,多个 staging 操作并发发生":这是真实使用中更常见的形态,如果此场景下 KV 相对 SQL 出现性能回归,越早发现越好。

因此基准工作的起点被定为Long-Commit-Multiple-Staging场景,且可能仅此一个场景即已足够。此外,KV 与 SQL 双轨期本身就提供了 A/B 对比的载体:同一套功能测试、同一套 Esti 黄金文件,两种存储实现各跑一遍,差异即风险。

小结:从计划到现状

回到仓库当前状态,可以确认 KV3 计划已完整落地:pkg/graveler/ref与pkg/graveler/staging的全部管理器直接以kv.Store为依赖(不再持有 SQL 句柄),三类分区布局、protobuf 值编码、SetIf乐观协议、staging token 分区化与异步清理都见诸 pkg/graveler/model.go、pkg/graveler/ref/manager.go、pkg/graveler/staging/manager.go;KV 后端的表结构迁移由 pkg/kv/migration.go 的DatabaseMigrator负责。计划中留给未来的两块——失败删除分区的后台 GC 与完全无锁提交——前者以cleanup-tokens异步机制覆盖了 staging 侧的清理需求,后者则以"近似分支属主 + 批合并 + 重试"的组合在实现中逐步逼近。这份执行计划的价值不仅在于完成了 graveler 的存储解耦,更在于它示范了一套可复用的工程方法:先盘点表与实体、再定键空间、用委托层双轨并行、以乐观协议替代行锁、以端到端黄金文件守护迁移正确性,最后用贴近真实负载的基准锁定性能底线。

  • 数据工程
  • 数据湖
  • 大数据
  • 对象存储
  • 后端

【免费下载链接】lakeFS

lakeFS - Data version control for your data lake | Git for data

项目地址:https://gitcode.com/gh_mirrors/la/lakeFS
点击查看免费下载

相关推荐

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询