- 数据工程
- 数据湖
- 大数据
- 对象存储
- 后端
【免费下载链接】lakeFS
lakeFS - Data version control for your data lake | Git for data
本文围绕 lakeFS 已接受的设计文档 kv3-execution-plan.md 展开,讲清 graveler(lakeFS 的元数据存储层)从 SQL(PostgreSQL)迁移到通用 KV 存储的完整执行计划:5 张元数据表如何映射为三类 KV 分区、仓库/分支/提交/暂存对象各实体的键布局、仓库删除在分布式失败场景下如何保持一致性,以及基于SetIf的乐观并发提交算法。读完本文,你可以掌握 lakeFS 在 KV 上设计键空间、处理乐观锁冲突与分区隔离的工程方法,并能对照当前源码验证这套设计已经落地为pkg/graveler的生产实现。
背景:为什么要给 graveler 换存储
lakeFS 的元数据分两类(见总体设计文档 lakeFS on KV):
- 不可变元数据:已提交的 entries,以 range / metarange 形式存放在对象存储中;
- 可变元数据: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_tags | pkg/graveler/ref | 前三张表基本不可变(graveler_commits仅在refs-restore场景下更新 generation,且只发生于 bare 仓库,可不关心),可较直接地在 KV 上实现;graveler_branches原先由锁保护,需要新的无锁提交方案,第一步可先用朴素表锁或基于 branch-key 的锁过渡 |
graveler_staging_kv | pkg/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 下则必须显式设计失败行为。计划给出的方案是:
- 先通过
GetRepository构建分区键(同时完成仓库存在性校验); - 删除
graveler通用分区中的仓库条目——仓库从此不可访问,等于逻辑上"不存在"; - 再用该分区键删除仓库的所有相关条目(整个仓库分区及其 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 编号为原文引用,此处仅作事项标识):
- 敲定 Key Schema(即上文的三类分区布局,#3567)。
- 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 相互独立。
- Step 1:构建初始
- 常用操作基准测试(#3571):手工在 SQL 与 KV 两套实现上执行,验证性能令人满意。
- 既有 SQL 数据向 KV 的迁移(#3572):大部分表迁移平凡;
graveler_branches预计也无需对sealed_tokens做特殊处理(需验证);KV 迁移测试自然包含在内。 - 将 graveler 迁移纳入 Esti 迁移测试(#3573):lakeFS 的端到端测试框架 Esti(位于 esti/,含大量
.golden期望输出文件)承担"迁移前后行为一致"的回归验证。 - 可选项: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
相关推荐
lakeFS 元数据存储从 Postgres 到 KV 的迁移执行计划:接口设计、包级改造与无锁提交落地
lakeFS 元数据存储从 Postgres 到 KV 的迁移执行计划:接口设计、包级改造与无锁提交落地 导读 lakeFS 将「仓库/分支/提交/暂存区/认证
数据工程数据湖大数据对象存储后端lakeFS 元数据存储的 Key/Value 化重构:从 PostgreSQL 到可插拔 KV 抽象与无锁提交设计
lakeFS 元数据存储的 Key/Value 化重构:从 PostgreSQL 到可插拔 KV 抽象与无锁提交设计 导读 :本文深入解析 lakeFS 官方设
数据工程数据湖大数据对象存储后端lakeFS 元数据迁移(Table DB → KV Store)测试计划全解析:从迁移验证到逐包测试与执行里程碑
lakeFS 元数据迁移(Table DB → KV Store)测试计划全解析:从迁移验证到逐包测试与执行里程碑 导读 本文以 lakeFS 仓库中的 lak
数据工程数据湖大数据对象存储后端
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考