- 版本控制
- 后端
【免费下载链接】lore
Lore is a next-generation, open source version control system
Lore 是一个开源的下一代版本控制系统,其 AWS 后端(lore-aws)使用 S3 作为不可变数据的主要持久化存储。本文以架构决策记录 ADR-00006: Reconsider S3 storage model 为主线,完整剖析"分片(fragment)元数据到底应该存在哪里"这一核心问题的来龙去脉:为什么最初方案会导致元数据复制、三个候选方案各自的取舍、最终选定的"元数据单份存储、与负载同处一个 S3 对象"方案,以及后续被 ADR-00020 取代、演化为"以 S3 对象元数据承载分片"的完整技术路径。读完本文,你将理解 Lore 不可变存储在去重、并发正确性、查询性能与 AWS 成本之间的权衡逻辑,并能结合lore-aws源码看清每一处决策的落地实现。
一、决策背景:从 ADR-00004 的原始 S3 存储模型说起
在理解 ADR-00006 之前,需要先回到它的前身 ADR-00004: S3 storage options(2024-04-24,deciders:Mattias Jansson、Paul Sharpe)。
ADR-00004 要回答的问题是:Lore 计划实现一个以 S3 为主的后端存储,承载可变数据(mutable)与不可变数据(immutable),那么 Lore 的存储数据应该如何映射到 S3 的 bucket 与 object?
当时有两个候选方案:
- 每个分片一个对象(Object per fragment):每次存储写入(
store_mutable、put_immutable)都会向 S3 写一个独立对象。不可变分片的元数据以<hash>/<repo id>/<context>为 key(分片哈希放在最前面,以保证 S3 分区所需的足够熵),对象内容是序列化后的lore_fragment_t;分片负载本体则另存一个以<hash>为 key 的对象。这样既能针对 repo/context 检查分片是否存在,又能跨文件、跨仓库对负载去重。可变写入以<hash>/<repo id>为 key。 - 把对象打包成 packfile(Bundle objects into packfiles):类似 LoreStore 的实现,维护一个索引文件以便高效定位分片在 packfile 中的位置。
最终 ADR-00004 选择了"每个分片一个对象(实际算上元数据是每分片两个对象)",并暂时放弃智能分层存储(Intelligent Tiering)带来的成本收益,把打包留给了未来可能的附加进程。理由很直接:实现简单、容易推理,为不可变数据提供了立即可行的持久化路径,且保留了未来启用智能分层的可能性。
二、问题陈述:元数据为何被复制、又为何需要重新考虑
ADR-00004 的模型下,每个引用同一份分片数据的"仓库 + 上下文(repository/context)"组合,都会产生一份独立的元数据对象(key 为<hash>/<repository id>/<context>)。
于是问题浮出水面:在内部 Pull Request 讨论中,有人提出——我们是否真的愿意以这种方式复制分片元数据?
这就是 ADR-00006(2024-05-19,decider:Mattias Jansson)要重新审视的存储模型。它的核心语境可以概括为:
- 同一份分片负载,可能被多个仓库/上下文组合引用;
- 若元数据随引用复制,同一负载将对应多份元数据;
- 分片元数据并非一成不变——例如分片在加入内容寻址存储(CAS)后被压缩,其 flags 会发生变化,此时需要重写元数据。
三、决策驱动因素:四条硬约束
ADR-00006 列出了四条决策驱动因素,它们是后续一切取舍的标尺:
- 最小化 S3 存储的实现复杂度;
- 最小化由 S3 存储布局与查询模式带来的 AWS 成本;
- 确保在必要时可以合理地重写分片元数据(例如分片被加入 CAS 后被压缩,导致其 flags 改变);
- 确保正确性:如果不同客户端对同一分片做出不同的压缩决策,存储模型仍必须正确。
注意第 4 条——它直接排除了"元数据与负载各自独立、依赖外部协议保持一致"的思路,也为后来 ADR-00020 的彻底解决埋下伏笔。
四、三个候选方案与完整取舍分析
ADR-00006 评估了三个方案,其取舍逻辑值得逐条展开(以下 pros/cons 均出自原 ADR):
方案 A:继续为每个 repository/context 组合复制分片元数据
即维持 ADR-00004 的<hash>/<repository id>/<context>布局不变。
- 优点:实现简单,成本效率好。
- 缺点:
- 更新所有引用同一分片的 repository/context 组合的元数据非常复杂;
- 存在"某一 repository/context 组合的元数据与分片负载中实际存储的数据失同步"的风险——如果元数据 flags 与负载对象中的实际内容不匹配,会引发客户端错误。
方案 B:分片元数据只存一份,与负载分离(独立 S3 对象)
此方案创建独立的 S3 对象承载元数据,key 为<hash>/metadata。
- 优点:元数据只存一份,不存在与负载失同步的担忧;需要更新时只需查一处。
- 缺点:在三个选项中实现复杂度最高、S3 成本最高(原 ADR 也特别注明:这些选项的 S3 成本放在整体大盘中很可能微不足道,这里只是相对比较)。
方案 C:分片元数据只存一份,且与负载存放在同一个 S3 对象中(16 字节前导)
此方案把元数据作为16 字节前导(preamble)写入分片负载所在的同一个 S3 对象。
- 优点:元数据只存一份,不存在失同步担忧;需要更新时只需查一处。
- 中性:必须额外存储一个空哨兵对象(empty sentinel object)来表示"某个分片与某 repository/context 组合之间的关联"。
五、决策结果与直接后果
选定方案 C:"分片元数据只存一份,且与负载存放在同一个 S3 对象中"。理由正如原 ADR 所述:它在最小化实现复杂度与成本的同时,保证了跨客户端的正确性。
决策的直接后果(Consequences):
- 优点:分片元数据只存一次,不存在与已存负载失同步的顾虑;
- 优点:元数据只需在一处查找即可更新;
- 中性:必须存储一个空的哨兵对象来表示分片与 repository/context 组合之间的关联。
值得注意的是:这个"空哨兵对象"是方案 C 在当时语境下的代价,而到了 ADR-00020 阶段,由于关联关系已经迁移进 DynamoDB,这一代价随之消失——这正是架构决策持续演进的一个典型例证。
六、后续演进一:ADR-00008 把关联关系迁移到 DynamoDB
ADR-00006 的方案把"分片-仓库-上下文"关联也放在 S3 中。但 ADR-00008: Optimize AWS store fragment association lookups(2024-10-21)揭示了负载下的性能问题:
- 原实现依赖 S3
ListObjects查询分片关联,而Store::query_immutable是服务端最频繁访问的代码路径——客户端在 put 分片前要 query 一次,服务端处理 put 时还要再 query 一次; - 由于总是沿"具体性层级"向上走(
MatchFull→MatchRepository→MatchHash),在最坏情况下(分片根本不存在),每次 put 分片要发起 6 次 ListObjects 调用,在推送大 commit 这类大量 put 分片的场景下尤其致命。
ADR-00008 最终选择把分片/仓库/文件关联迁移到 DynamoDB:hash作为分区键,repository_context作为复合排序键(仓库字节与上下文字节拼接),从而既可按 hash 单独查询,也可用begins_with按 hash+repo 或 hash+repo+context 高效查询。代价是:不可变存储不再只依赖 S3;迁移前已存在的所有关联失效。
七、后续演进二:ADR-00020 把分片元数据搬上 S3 对象元数据
7.1 新的问题:负载与描述它的分片可能永久失配
ADR-00008 之后,分片本体也被移入 DynamoDB(关联已在其中,查询无需触碰 S3 即可判断分片是否存在;但读取分片内容仍要一次 S3 请求,移入后连这次请求也省了)。这一变化没有被单独记录为 ADR,ADR-00006 也从未被正式取代。
随之而来的后果是:S3 对象持有某内容的一种表示,而独立的 DynamoDB 记录描述"那是哪种表示"。S3 key 是内容哈希,但两个写入者可能对同一内容合法地持有不同表示——例如同一字节的 LZ4 与 Zstd 两种压缩——并且都寻址到同一个 key。对象与记录是独立写入的、必须保持一致,却可能失败,产生两种永久性损坏:
- 两个写入者向同一 key 上传不同表示并发布不同分片,交错结果可能让一个写入者的分片落在另一个写入者的负载旁边;
- 写入者替换对象后未能发布其分片,已存分片描述的内容已不存在,且受影响分区无法自愈(其自身的 re-put 看到完全匹配就什么都不做)。
无论哪种情况,负载都无法解压,表现为内部 size mismatch,重试无效,且直到读取失败才会被发现。
7.2 选择:分片作为 S3 对象元数据,而非 16 字节前导
ADR-00020: Store fragment metadata as S3 object metadata(2026-08-03,decider:Mattias Jansson)正式取代 ADR-00006,并明确表示:"它认同该决策的原则,差异在于机制:分片随负载一起走,但作为对象元数据而非正文前导。"
关键洞察是 S3 对象元数据的原子性:对象元数据属于对象版本的一部分,不能在不重写对象的情况下更改;GetObject从同一版本返回头部与正文;整对象 PUT 是原子的——读者要么看到完整的旧对象,要么看到完整的新对象,绝不可能是混合体。因此两个携带不同表示并发写入的写入者,都会写出完整、自描述的对象,后落地者被整体读回。Last-writer-wins 变得安全,这正是"不需要协议"的原因——正确性来自存储层自身的保证,而不是多步骤协议在崩溃、竞态与部分失败下恰好执行正确。
配套地:
- DynamoDB 分片状态表取代分片元数据表:行存在即代表该哈希存在,行上还携带 obliteration(清除/抹除)状态。存在性探测仍是单次
GetItem、零 S3 请求,发布则成为幂等的 set-a-bit,两个写入者无需协调; - 该表与关联表保持分离(即便合并能让 put 探测从两次
GetItem变成一次BatchGetItem)——因为"是否配置了分片元数据表"恰恰是选择改动前旧对象的读取路径的判别信号,合并会抹掉这个信号,并让状态行流量与流行哈希的关联流量挤到同一分区键上。
7.3 对象元数据格式:一个 key,三个字段
分片在对象上的编码格式(详见 object_metadata.rs)为:
x-amz-meta-lore-fragment: <flags>:<size_payload>:<size_content>例如8:4096:16384。格式选择背后的工程考量:
- 一个 key 而非每字段一个 key:key 名称在每个请求与响应中都会被完整拼出,拆成三个 key 的成本是值的数倍;
- flags 用十六进制:因为它是位域;两个 size 用十进制:因为它们是量级;
- 纯文本而非紧凑编码:使对象形状无需任何 lore 工具、直接
aws s3api head-object即可读。
实现上,编码/解码严格按固定三元组处理:
- 编码时先把 flags 与
PAYLOAD_FLAGS掩码相与(encode中fragment.flags & PAYLOAD_FLAGS); - 解码时(
decode)按:切分为恰好三段,第四段即报Malformed(field count);flags 以u32::from_str_radix(flags, 16)解析并再次掩码,两个 size 分别解析为 u32/u64; - 无元数据返回
Absent(这是改动前旧对象的判别标志,也是迁移程序的读取依据);解析失败则Malformed并点名出错字段。
7.4 哪些 flags 随对象走,哪些必须留在 DynamoDB
PAYLOAD_FLAGS掩码(object_metadata.rs)只允许描述负载本身的 flags 上对象:
| 去向 | 标志位 | 理由 |
|---|---|---|
| 随对象走 | PayloadFragmented、压缩编解码(LZ4/Oodle/Zstd)、PayloadRevisionState | 它们描述"这些字节是什么",对所有读者答案一致 |
| 留在 DynamoDB | PayloadObliterating/PayloadObliterated | 生命周期状态:字节不变而状态会变,对象元数据不可编辑 |
| 留在 DynamoDB | PayloadStoredDurable/PayloadStoredLocal | 描述"哪个存储持有负载",每个存储应各自在读取时推导 |
| 留在 DynamoDB | PayloadLocalCachePriority | 单机缓存提示,不是内容属性,对每个读者答案不同 |
| 存储前剥离 | PayloadDoNotReplicate | 仅针对本次传输的请求,由sanitise_fragment_behavior_flags在落盘前剥离 |
object_metadata.rs的测试矩阵恰好印证了这一划分:keeps_every_flag_that_describes_the_payload验证五个负载描述 flags 完整往返;drops_state_store_location_and_per_machine_flags验证六个非负载属性 flags 在往返后被掩掉。
八、源码级验证:lore-aws 中每一条路径的落地形态
8.1 put:探测、去重与关联写入
immutable_store.rs 的put先sanitise_fragment_behavior_flags清理行为 flags 并校验负载,然后并发执行exists(关联探测)与load_state(状态探测),按结果分支:
- 状态为
Obliterating:返回SlowDown,礼貌拒绝正在被清除的哈希; - 状态
Stored且已关联:直接Ok(()),零写入完成去重; - 状态
Stored但未关联:只写关联(associate_fragment)——这就是跨分区、跨上下文去重:内容已在别的分区持久化,无需再上传; - 其他情况:先
write_payload_and_state(负载与对象元数据、状态行一次写入),再associate_fragment。
一个关键细节:关联总是最后写入(put 在负载与状态就位后才加关联;obliterate 则先删关联)。这保证了"可见的关联即意味着可检索的内容",也是 query 逻辑 能只用两次批量读、完全不打 S3 就给出答案的前提。
8.2 get / get_metadata:对象即分片
get并发执行exists与load两个 future(tokio::select!先到先断),exists失败优先返回;负载读回后经lore_storage::validate_fragment_payload校验。分片直接来自GetObject响应的对象元数据,因此读取天然少了一次 DynamoDB 读(ADR-00020 的明确收益之一)。get_metadata是唯一"纯粹为元数据花一次 S3 请求"的路径:它并发发起do_query(状态解析)与HeadObject,命中时从 head 响应中取回分片,不传输任何正文——这正是"对象元数据而非 16 字节前导"的实惠之处:前导方案下仅读元数据也必须取字节区间、白白传输正文。
8.3 query:状态行 + 关联行,两读并行,零 S3
query 的判别表非常清晰:
| 状态 | 关联 | 上报结果 |
|---|---|---|
非Stored | 任意 | MatchNone |
Stored | 存在 | MatchFull |
Stored | 不存在 | MatchNone |
query_immutable在入库路径上每个分片调用一次,是 ADR-00008 点名的最热路径——在这里省掉 S3 请求,正是整条演进路线的核心目的。作为中性取舍,本存储从不报告MatchPartition(区分"未关联"与"不存在"需要每个地址一次Query,这里拒绝花这笔钱),在通常部署中由存储前的缓存层补齐分区匹配。
8.4 obliterate:先删引用、再取标记、后清负载
obliterate 的顺序是负载相关的正确性关键:
- 读状态,无状态行则无事可做(改动前写入的旧内容);
- 先删本分区的关联(这是义务:此后本分区不再点名该内容);
- 把状态
Stored → Obliterating取得清除标记(条件写由attribute_not_exists守护,唯一职责是避免抹掉已存在的 obliteration 标记); - 再次删除关联——处理与清除竞态的写入者重新建立的关联;
- 短暂 drain 等待后复查是否还有其他关联;若有则释放标记(
Obliterating → Stored)保留负载; - 否则递归清除子分片、删除 S3 对象、状态推进到
Obliterated(墓碑,区分"从未存储"与"被故意销毁")。
8.5 迁移:metadata_migrator 与旧对象的回退读取
由于改动前写入的 S3 对象没有对象元数据,读取路径保留了以fragment_metadata_table_name(immutable_store.rs、with_fragment_metadata_table配置项)为开关的旧表回退:对象无元数据且配置了旧表时,从旧行取分片;无配置则拒绝。get/get_metadata对"对象无元数据"回退、对"元数据损坏"则坚决不回退(测试get_does_not_fall_back_for_an_object_with_damaged_metadata验证了这一边界)。
配套的可选回填由 metadata_migrator.rs 承担,其RewriteStats展示了完整的迁移分诊:codec 准确且非 Oodle 的直接重传设置头部(maintained)、Oodle 重压成 Zstd(recompressed_oodle)、声明的 codec 与实际字节不符的也重压(recompressed_mismatch)、压缩低效的转存未压缩(converted_compressed_to_uncompressed)、无法推断负载的放弃(could_not_deduce_payload)、已迁移的跳过、被 obliterate 的跳过、Oversized的恶意负载跳过(skipped_malicious)等。迁移可以在不依赖 DynamoDB 的情况下仅凭 bucket 重建状态表——对象自描述使然。
九、被否决的关键备选方案及其教训
ADR-00020 还详细评估了另外三个方案,其否决理由对理解本主题极有价值:
- 分片留在 DynamoDB + 写入协议:该方案被完整实现并经过多轮对抗性评审,约 2850 行新增、96 个测试,能修复损坏,但正确性依赖多步骤协议在崩溃、竞态与部分失败下长期正确;"判断未发布对象是否被遗弃"只能靠
Last-Modified年龄启发式(无可靠答案);Oodle 以原始大小为输入、decompress_into校验结果,导致"用 codec 探测重建分片"被实验证伪。这是所有备选中最有信息量的一条——它的困难是被实测而非被预言的。 - DynamoDB + 独立 claim 记录:用事实取代启发式,但每次新内容 put 都要先写后删一条记录,永久压在服务端最热写路径上。
- 按表示哈希而非内容哈希做 key:对象天然不可变,但读取必须先查 DynamoDB 才知道 key,把热读路径上的两次并行往返变成两次串行;未被引用的表示会累积成数十亿对象的 GC 问题;obliterate 要删除一个哈希的所有表示。
- 每哈希归一为单一规范表示:重压缩进入入库路径,违背"接受客户端压缩负载"的 CPU 初衷,且冻结 codec 选择、日后更换等于重写全量数据。
十、总结:一条"以存储层保证替代协议"的演进主线
回顾三条 ADR,Lore 在"分片元数据放哪"这个问题上的演进逻辑清晰可循:
- ADR-00004:每分片一对象,元数据按
<hash>/<repo id>/<context>复制——简单、可去重,但引出元数据一致性隐患; - ADR-00006:元数据单份、以 16 字节前导与负载同对象——消除失同步,代价是空哨兵对象与仅读元数据时的正文传输;
- ADR-00008:关联迁入 DynamoDB——解决
ListObjects的查询性能瓶颈(query_immutable是最热路径),也让前导方案的空哨兵代价作废; - ADR-00020:分片迁入 DynamoDB 引发负载/描述永久失配问题后,最终把分片搬上S3 对象元数据——同时获得负载级原子性(正确性来自 S3 自身保证而非协议)、零正文的元数据读取(
HeadObject)、免 S3 的存在性查询(状态表GetItem)与跨分区去重(put 已付的两次探测读复用)。
对希望深入研究的读者,建议的阅读路径是:先读 ADR-00004 与 ADR-00006 建立问题框架,再读 ADR-00008 与 ADR-00020 看到完整演进,配合配套提案 Carry fragment metadata on the S3 object 中的调用计数对比与中断/清除行为分析;源码层面则从 object_metadata.rs 的编解码与测试矩阵入手,再通读 immutable_store.rs 的 put/get/query/obliterate 主路径,最后看 metadata_migrator.rs 的迁移分诊逻辑。Lore 的 ADR 采用 append-only、按序号递增的编号约定(见 ADR 目录说明),这套决策链本身即是"可追溯架构演进"的极佳范本。
- 版本控制
- 后端
【免费下载链接】lore
Lore is a next-generation, open source version control system
相关推荐
Lore 的 S3 存储方案演进:从逐片段对象到对象元数据携带 Fragment 的 ADR 决策全解析
Lore 的 S3 存储方案演进:从逐片段对象到对象元数据携带 Fragment 的 ADR 决策全解析 导读 本文围绕 Lore(一个开源的下一代版本控制系统
版本控制后端Lore 0.8.7+ AWS 不可变存储迁移:用 lore-aws-migrate 将片段元数据从 DynamoDB 迁移到 S3 对象头
Lore 0.8.7+ AWS 不可变存储迁移:用 lore aws migrate 将片段元数据从 DynamoDB 迁移到 S3 对象头 Lore 从 v0
版本控制后端Lore 存储设计:把 Fragment 元数据写进 S3 对象——ADR-00020 的决策、实现与迁移
Lore 存储设计:把 Fragment 元数据写进 S3 对象——ADR 00020 的决策、实现与迁移 导读 本文解读 Lore(一个开源的下一代版本控制系
版本控制后端
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考