Git 2.56 架构原理与实战-第 2 章 对象存储实现:松散对象与 packfile

1 阅读20分钟

Git 2.56 架构原理与实战-第 2 章 对象存储实现:松散对象与 packfile

源码基线:Git v2.56.0(git-2.56.0)。 行号引用均指向该版本的源码;代码链接统一为 https://github.com/git/git/blob/v2.56.0/<path>#L<start>-L<end>。

§1 本章概览与问题地图

1.1 一个对象要放在哪里

第 1 章我们把 Git 的对象模型讲清楚了:任何 blob、tree、commit、tag 都是「一段字节 + 一个类型标签 + 一个长度」,其身份(object ID,下称 OID)是该字节串做哈希后的结果。但模型只回答「对象是什么」,不回答「对象存在磁盘的哪里、怎么被找到、怎么被压缩、怎么在几百 G 的仓库里保持 O(log N) 的查询」。

这一章回答的就是存储实现这一层的问题。它由两套彼此独立、又能互相回退的物理表示组成:

  • 松散对象(loose object):每个对象一个文件,路径为 $GIT_DIR/objects/<oid[0:2]>/<oid[2:]>,内容是把 <type> <size>\0<data> 整体做 zlib 压缩。它简单、写起来原子(先写临时文件再 rename),但对象一多就制造海量 inode,且完全不做跨对象压缩。
  • 打包对象(packed object):把成千上万个对象塞进一个 pack-*.pack 文件,凡是不被逐个随机访问的对象一律走这条路。跨对象冗余靠 delta 压缩(把 A 表示成「B 加上一串编辑指令」)消除,索引靠 pack-*.idx 完成二分查找,反向映射靠 pack-*.rev,多包统一视图靠 multi-pack-index。

整个 ODB(object database)的查找入口,在 v2.56 已被重构为可插拔的 odb_source 抽象。松散对象由 odb/source-loose.c 承担,打包对象由 odb/source-packed.c 承担,二者之上再由 odb/source-files.c 组合成「文件系统 ODB」。本章重点在 packed 一侧,但会把 loose 的字节布局也讲透,因为它们是理解「对象如何被写出来」的起点。

1.2 本章要解决的关键问题

按「问题 → 本章对应小节」列出来,形成阅读地图:

问题关键机制对应小节
松散对象文件里到底是什么字节<type> <size>\0 + zlib + 目录分片§3.1、§5.1
.pack 文件头、对象头、delta 头怎么编码12 字节头 + varint 头 + ofs/ref delta§3.2、§3.3
怎么在巨大 pack 里找到某个 OID.idx 的 256 项 fanout + 二分§3.4、§4.1
怎么从 pack 内偏移反查 OID / pack 位置.rev 反向索引 + 基数排序§3.5、§4.2
多个 pack 怎么统一查询、去重MIDX chunk 格式 + 二分§3.6、§4.3
delta 到底怎么编码、怎么选 basecopy/add 指令、窗口、深度、rabin 哈希§3.7、§4.4
这些文件是谁、在什么时候写出来的pack-objects / index-pack / repack / gc§6
损坏了会怎样、有哪些坑校验和、CRC、bad object、unlink 竞态§7
为什么快、为什么有时慢mmap、窗口 LRU、delta base cache§8
和 commit-graph / bitmap / 传输如何交互只引用关系§9
格式如何演进idx v1→v2、rev、midx、packfile-uri§10、§11

1.3 三个文件族与命名约定

一个 pack 在磁盘上不是一个文件,而是一个族(family),共享同一段 pack-<hash> 基名:

$GIT_DIR/objects/pack/
├── pack-<hash>.pack          # 对象载荷
├── pack-<hash>.idx           # 索引(v2 或 v3)
├── pack-<hash>.rev           # 反向索引(可选,加速 offset↔pos)
├── pack-<hash>.mtimes        # 每个对象的 mtime(cruft pack 专用)
├── pack-<hash>.bitmap        # 可达性位图(可选)
├── pack-<hash>.keep          # 存在即禁止 repack 删除该 pack
├── pack-<hash>.promisor      # promisor(partial clone)标记
└── multi-pack-index[.rev|.bitmap]   # 跨 pack 的统一索引

<hash> 是 pack 内容的哈希(SHA-1 或 SHA-256,随仓库 extensions.objectFormat 而定)。命名规则由 odb_pack_name() 生成(packfile.h 中声明,实现在 object-file.c)。删除时 unlink_pack_path()(packfile.c#L368-L394)会遍历扩展名数组 {".idx", ".pack", ".rev", ".keep", ".bitmap", ".promisor", ".mtimes"} 一并清理,且当 force_delete == 0 时若存在 .keep 就整体跳过——这就是 git repack 不敢动受保护 pack 的底层机制。

1.4 本章在整体架构中的位置

对象存储是「一切上层功能的物理基础」。用一句话概括依赖方向:

blob/tree/commit/tag  (第 1 章:对象模型)
        │ 写入
        ▼
loose object / packfile  ← 本章
        │ 被读取
        ├─→ 索引 diff(第 6 章 read-cache)
        ├─→ 历史遍历 / reachability(第 5 章 commit-graph、bitmap)
        ├─→ 传输协议(第 7 章 fetch-pack / upload-pack)
        └─→ plumbing 命令(hash-object、cat-file、fsck、gc)

值得强调的是,v2.56 中「对象从哪读」已经被抽象为 struct odb_source 的 vtable(见 odb/source-packed.c#L1005-L1035 的 odb_source_packed_new()):read_object_info、read_object_stream、for_each_object、count_objects、find_abbrev_len、freshen_object、write_object、fsck 等函数指针被逐项填充。这为第 8 章「ODB 可插拔」埋下伏笔,也解释了一个现象:v2.56 的 builtin/fsck.c 不再自己遍历 pack,而是调用每个 source 的 fsck 回调(odb_source_packed_fsck(),odb/source-packed.c#L984-L1003)。


§2 核心数据结构(逐字段)

本章涉及的结构体分布在 packfile.h、pack.h、pack-revindex.h、midx.h、pack-objects.h 以及 packfile.c 的静态区域。我们逐个拆字段,并说明它们的生命周期(何时分配、何时失效)。

2.1 struct packed_git(packfile.h#L17-L56)

这是「一个已打开的 pack 及其索引」的内存手。字段如下:

字段类型含义生命周期
windowsstruct pack_window *该 pack 上所有 mmap 窗口的链表use_pack() 建窗,close_pack_windows() 销毁
pack_sizeoff_t.pack 文件字节数open_packed_git() 时 fstat 得到
index_dataconst void *mmap 后的 .idx 首地址open_pack_index() 建立,close_pack_index() 释放
index_sizesize_t.idx 文件长度同上
num_objectsuint32_tpack 内对象个数(来自 fanout[255])load_idx() 解析后填入
crc_offsetsize_tv2 idx 中 CRC 表相对文件头的偏移仅 v2 设置,用于校验
bad_objectsstruct oidset已知损坏对象的集合mark_bad_packed_object() 插入
index_versionint1 或 2(v3 也按 2 处理)load_idx() 判定
mtimetime_tpack 文件修改时间用于 MRU 排序与 prune
pack_fdint打开的 pack 文件描述符open_packed_git() 打开,close_pack_fd() 关闭
indexintpack-objects 内部用的序号仅 packing 阶段使用
位域 pack_local:1是否本地(非 alternate)add_packed_git() 传入
位域 pack_keep:1磁盘上存在 .keep加载时探测
位域 pack_keep_in_core:1内存中标记为 keep(如 repack 期间)调用方设置
位域 pack_keep_in_core_open:1上述 keep 是否已打开同上
位域 freshened:1是否已 touch 过 mtimefreshen_object()
位域 do_not_close:1禁止关闭(mmap 用途)调用方设置
位域 pack_promisor:1是否 promisor pack加载时探测 .promisor
位域 multi_pack_index:1该 pack 是否由 MIDX 覆盖MIDX 装载时设置
位域 is_cruft:1是否 cruft pack探测 .mtimes 推断
hashunsigned char[GIT_MAX_RAWSZ]pack 内容校验和解析 .idx trailer 得到
revindexstruct revindex_entry *内存版反向索引数组create_pack_revindex() 分配
revindex_dataconst uint32_t *磁盘版 .rev 的位置表指针load_pack_revindex_from_disk() 建立
revindex_mapconst uint32_t *.rev mmap 首地址同上,close_pack_revindex() 释放
revindex_sizesize_t.rev 文件长度同上
mtimes_mapconst uint32_t *.mtimes mmap 首地址cruft 场景
mtimes_sizesize_t.mtimes 长度同上
repostruct repository *所属仓库创建时给出
pack_namechar[FLEX_ARRAY]形如 .git/objects/pack/pack-xxx.pack 的路径柔性数组,随结构体一次分配

注意一个细节:revindex(内存版)与 revindex_data(磁盘版)互斥——load_pack_revindex() 会先尝试磁盘版,失败才在内存构建(pack-revindex.c#L304-L317)。这就是 core.packReadReverseIndex(内部设置 pack_read_reverse_index)的作用:磁盘 .rev 存在时直接 mmap,省掉 O(N log N) 的基数排序。

2.2 struct pack_window(packfile.h#L147-L154)

mmap 窗口。pack 文件不做一次性全量 mmap,而是按需分窗:

字段含义
next同 pack 内窗口链表
basemmap 起始地址
offset该窗口在 pack 文件中的起始偏移
len窗口长度
last_used上次使用序号,用于 LRU
inuse_cnt正在引用该窗口的计数,>0 时不可 munmap

unuse_one_window()(packfile.c#L274-L299)会遍历所有 source 的所有 pack,用 scan_windows() 找出 inuse_cnt == 0 且 last_used 最小的窗口予以释放——这正是「全局 mmap 预算」的实现。

2.3 struct pack_header 与 struct pack_idx_header(pack.h#L19-L23、L72-L75)

struct pack_header {
	uint32_t hdr_signature;  /* 网络字节序 "PACK" = 0x5041434b */
	uint32_t hdr_version;    /* 网络字节序,2 或 3;Git 只写 2 */
	uint32_t hdr_entries;    /* 网络字节序,对象个数 */
};

struct pack_idx_header {
	uint32_t idx_signature;  /* 0xff744f63 = "\377tOc" */
	uint32_t idx_version;    /* 2 或 3 */
};

PACK_IDX_SIGNATURE 的设计意图写在 pack.h#L25-L41 的注释里:它被刻意选成一个「不可能的 fanout[0]」(v1 的 fanout[0] 是首字节为 0 的对象计数,远小于 0xff744f63),因此旧 Git 读到它会判定为非法并报 non-monotonic index 而中止——向前兼容失败时宁可旧版报错,也不要旧版静默误读。

2.4 struct pack_idx_entry(pack.h#L80-L84)

写索引时的通用条目,pack-objects 与 index-pack 都用它:

字段类型含义
oidstruct object_id对象身份
crc32uint32_t该对象在 pack 中原始字节(含头)的 zlib CRC32,仅 v2 落盘
offsetoff_t对象在 pack 中的字节偏移

pack_object(pack-objects)中的 struct object_entry 内嵌了它(见 2.6),因此在写索引时可直接把对象数组当 pack_idx_entry ** 用。

2.5 struct multi_pack_index(midx.h#L39-L78)

MIDX 的内存表示。其数据段来自 mmap,各 chunk 指针指向文件内偏移:

字段含义
source所属 odb_source_packed
data / data_lenMIDX 文件 mmap 首地址与长度
revindex_data / revindex_map / revindex_lenRIDX chunk 或独立 .rev 的映射
signature魔数,应为 MIDX_SIGNATURE
version文件版本(1 或 2)
hash_lenOID 原始字节数(SHA-1 为 20,SHA-256 为 32)
num_chunkschunk 个数
num_packs本层 pack 数
num_objects本层对象数
preferred_pack_idx首选 pack(用于 pack 复用),无则 -1
has_chain是否属于增量链
chunk_pack_names / chunk_pack_names_lenPNAM:NUL 结尾的 pack 名序列
chunk_bitmapped_packs / chunk_bitmapped_packs_lenBTMP:每个 pack 的 bitmap_pos、bitmap_nr
chunk_oid_fanoutOIDF:256 项 fanout
chunk_oid_lookupOIDL:排序后的 OID 表
chunk_object_offsetsOOFF:每对象 2×u32(pack id + offset)
chunk_large_offsets / chunk_large_offsets_lenLOFF:大偏移表
chunk_revindex / chunk_revindex_lenRIDX:伪 pack 顺序表
base_midx增量链中的下一层(更早的一层)
num_objects_in_base / num_packs_in_base链上累加基数
pack_names解析出的 pack 名数组
pack_names_sorted排序辅助
packs惰性打开的 packed_git * 数组

「链」的语义对理解 v2.56 的增量 MIDX 至关重要:num_objects_in_base 是所有更早层之和,因此 nth_midxed_object_oid() 里的 n 是「全局拼接位置」,函数内部先调 midx_for_object() 把全局位置折算到具体某一层(midx.c#L547-L559)。

2.6 struct object_entry 与 struct packing_data(pack-objects.h#L89-L121、L132+)

object_entry 是打包过程的核心记账单元,字段紧凑到用位域:

字段含义
idx内嵌的 pack_idx_entry(oid/crc32/offset)
delta_data缓存的未压缩 delta 字节(可被压缩后由 z_delta_size 描述)
in_pack_offset若对象已在某个 pack 中,其偏移
hash用于 delta 搜索的「名称提示哈希」
size_ / size_valid对象大小(未压缩)
delta_idxdelta base 在数组中的下标(或外部 pack 序号)
delta_child_idx「以我为 base」的对象链
delta_sibling_idx「同 base 的兄弟」链
delta_size_ / delta_size_validdelta 未压缩长度
in_pack_header_size源 pack 中对象头长度,用于快速跳到 zlib 流
in_pack_idx所处源 pack 序号
z_delta_sizedelta 压缩后长度(缓存优化)
type_valid / type_规范类型
in_pack_type源 pack 中的实际类型(可能是 delta 类型)
no_try_delta显式禁用对该对象尝试 delta(如已压缩媒体)
preferred_base不打包但可供作 base(thin pack / 复用场景)
tagged靠近 ref 尖端,优先写
filled是否已分配写序
dfs_stateDFS 遍历状态(用于写序拓扑)
depth该对象为 delta 时其链深
ext_basedelta base 指向 packlist 之外

packing_data 则管理整个对象数组:objects、nr_objects、index(OID→下标的哈希)、in_pack_pos(在源 pack 中的 pack 位置)、delta_size 等。v2.56 还引入了 regions(struct packing_region),把对象数组切成可独立处理的区段——这是 --path-walk 与 bitmap 复用并存的基础。

2.7 struct delta_base_cache_entry(packfile.c#L1147-L1164)

读侧缓存。为避免反复展开 deep delta 链,Git 把「最近用过的 base 的最终数据」缓存起来:

字段含义
enthashmap 节点
key{packed_git *p, off_t base_offset},即「哪个 pack 的哪个偏移」
lruLRU 链表节点
database 对象的解压后完整字节
size字节数
type规范类型

哈希函数 pack_entry_hash()(packfile.c#L1166-L1173)把指针与偏移混合;缓存容量由仓库设置 delta_base_cache_limit 控制(默认 DEFAULT_DELTA_BASE_CACHE_LIMIT)。add_delta_base_cache()(packfile.c#L1265+)在超限时从 LRU 淘汰。

2.8 struct index_entry / struct delta_index(diff-delta.c#L117-L132)

delta 生成侧(打包时才用)的结构:

字段含义
index_entry.ptr指向 base 数据中的某个 16 字节窗口
index_entry.val该窗口的 Rabin 指纹(与下一窗口共享计算)
index_entry.next同哈希桶冲突链
delta_index.src_bufbase 缓冲
delta_index.src_sizebase 大小
delta_index.hash_mask哈希桶个数掩码
delta_index.hash[]柔性哈希桶数组

它的建立与释放分别由 create_delta_index() / free_delta_index()(delta.h#L16-L24)管理;create_delta() 用同一个 index 对任意目标缓冲反复生成 delta。

2.9 生命周期小结

一次「对象被发现 → 被读取」的路径上,结构体扮演的角色如下:

packed_git(磁盘族在内存的镜像,长期存活)
   └─ windows(按需 mmap,LRU 回收)
   └─ revindex / revindex_data(offset ↔ pack 位置,惰性建立)
        └─ delta_base_cache_entry(解压后的 base,LRU 容量受限)
             └─ 调用方拿到的对象字节(当场 malloc,用完即 free)

打包(写侧)则是另一条完全不同的生命周期:object_entry 数组从 packlist 建立,compute_write_order() 定序,write_one() 递归保证 base 先写,最后 write_idx_file()/write_rev_file() 落盘。两条路径共用的是「编码规则」而非「数据结构」,这一点在 §5 代码走读里会看得更清楚。

§3 磁盘格式逐字节解读

本节是整章的地基。我们按「文件被读到时指针如何移动」的顺序,把 .pack、.idx、.rev、.mtimes、multi-pack-index、松散对象逐个字节拆开。所有多字节整数一律网络字节序(大端),哈希长度记为 hashsz(SHA-1 为 20,SHA-256 为 32)。

3.1 松散对象:<type> <size>\0 + zlib

松散对象的磁盘内容只有两段:未压缩的前缀 + zlib 压缩的 payload。但更准确地说,是「前缀与 payload 一起被压缩」。规范(Documentation/gitformat-loose.adoc)写得很直白:前缀包含 <type> <size>\0,<type> ∈ {blob,tree,commit,tag},<size> 是十进制 ASCII 的 data 长度;前缀与 data 拼接后整体做 zlib 压缩,压缩结果即文件内容;OID 是未压缩的拼接串的哈希。

举两个可验证的例子:

  • 空 tree:未压缩串是 "tree 0\0",共 7 字节(74 72 65 65 20 30 00)。在 SHA-256 仓库里其 OID 为 6ef19b41225c5369f1c104d45d8d85efa9b057b53b14b4b9b939dd74decc5321,落盘路径 objects/6e/f19b41...5321,文件内容是 zlib("tree 0\0")。
  • blob abc:未压缩串 "blob 3\0abc"(62 6c 6f 62 20 33 00 61 62 63),OID 是这 10 字节的哈希。

路径分片规则由 fill_loose_path()(object-file.c#L45-L57)实现:把 OID 逐字节转成两位小写 hex,在第 1 个字节后插入 /,即 xx/yyyy...。分片的动机是文件系统:一个目录里放几十万文件会显著变慢,两位 hex 把对象摊到最多 256 个目录。

写侧的压缩流程在 odb/source-loose.c。write_loose_object()(odb/source-loose.c#L771-L815)先调 start_loose_object_common()(L675-L719):用 create_tmpfile() 建临时文件、git_deflate_init() 初始化 zlib、git_hash_init() 初始化哈希上下文,并先把 hdr 喂给 zlib 与哈希。随后循环调用 write_loose_object_common()(L725-L745):每轮 git_deflate(Z_FINISH 或 0)、更新哈希、把压缩产物 write_in_full() 写盘。最后 end_loose_object_common() 结束 zlib、得到 OID,并用 oideq() 对比「调用方给的 OID」与「实际算出的 OID」,不等则 die("confused by unstable object source data")——这是对「写入的数据必须自洽」的强制断言。

值得单独说的是 compatObjectFormat(对象格式兼容映射)。当仓库用 extensions.compatObjectFormat 配置了一个从属哈希算法时,Git 会额外维护 $GIT_DIR/objects/object-map/map-*.map(LMAP 文件),把主哈希 OID 与兼容哈希 OID 双向对应。其头部含 LMAP 魔数、版本 1、头部长度、对象数、格式数,以及每个格式的(标识符、短名长度、表偏移)三元组,末尾是主哈希校验和。这份映射也是 git gc 会整理的对象(把旧条目合并、清除已打包的松散映射)。

3.2 .pack 文件:头(12 字节)+ 对象条目序列 + trailer

规范(Documentation/gitformat-pack.adoc)给出的骨架是:

┌──────────────────────────────────────────────┐
│ Header (12 bytes)                             │
│   "PACK" (4) │ version=2 (4) │ nobjects (4)   │
├──────────────────────────────────────────────┤
│ Object entry 0                                │
│ Object entry 1                                │
│ ...                                           │
│ Object entry nobjects-1                       │
├──────────────────────────────────────────────┤
│ Trailer: pack checksum = SHA1(all bytes above)│
└──────────────────────────────────────────────┘

一个 3 对象 pack 的真实前 12 字节:

offset  bytes                       field
0x0000  50 41 43 4b                 signature "PACK" (0x5041434b)
0x0004  00 00 00 02                 version = 2
0x0008  00 00 00 03                 nobjects = 3

struct pack_header(pack.h#L19-L23)就是这三字段的 C 表示;write_pack_header()(pack-write.c#L364-L373)负责 htonl 后落盘。版本兼容规则在 pack.h#L17-L18:pack_version_ok_native(v) = (v == 2 || v == 3)——读接受 2 和 3,写只产生 2。为什么允许 v3?历史上曾有 pack v3 提案(支持超过 4G 对象),但最终只保留了「读兼容」。

Trailer 是整段(除自身外)的哈希。fixup_pack_header_footer()(pack-write.c#L391-L470)讲清了它的计算细节:写 pack 时无法预先知道最终对象数(可能因 pack 尺寸上限而截断),于是先写一个占位头;写完数据后回到文件头重写 hdr_entries,同时并行维护「旧头哈希(old_hash_ctx)」与「新头哈希(new_hash_ctx)」:先把旧头喂进 old_ctx 用于校验 partial_pack_hash,再把改过的头喂进 new_ctx。循环读文件(每次 8KB),对 new_ctx 持续 update,最后 git_hash_final(new_pack_hash) 并把哈希追加写回文件末尾,再 fsync。这是「先写数据、后补头部、最后补校验和」的经典两遍写模式。

对象条目的两种形态:

(非 delta)
  n-byte type and length    ← 见 §3.3
  compressed data           ← zlib 流,解压后是原始对象字节

(delta)
  n-byte type and length    ← type 为 OBJ_OFS_DELTA(6) 或 OBJ_REF_DELTA(7)
  base reference:           ← OFS_DELTA 写「负相对偏移」;REF_DELTA 写 20/32 字节 OID
  compressed delta data     ← zlib 流,解压后是 delta 指令序列

关键点:packed 对象没有 loose 那样的 <type> <size>\0 前缀。规范明确写道:「Unlike loose objects, packed objects do not have a prefix containing the type, size, and a NUL byte」——因为类型与长度已经由对象头给出,再存一遍是浪费。但计算 OID 时仍需「重建」这个前缀(<type> <size>\0 + 数据),所以解压后 Git 会把它拼回去再哈希。

3.3 对象头与 delta base 的变长编码

3.3.1 对象头的「三段式」varint

对象头第一字节的位布局是:

bit  7   6 5 4   3 2 1 0
     [C] [ type ] [ size low 4 bits ]
     C=1 表示后面还有 continuation 字节

后续每个 continuation 字节:bit7 = 1(若还有)或 0(最后一字节),低 7 位是 size 的更高位。解码公式(unpack_object_header_buffer(),packfile.c#L869-L895):

c = buf[used++];
*type = (c >> 4) & 7;
size  = c & 15;
shift = 4;
while (c & 0x80) {
    c = buf[used++];
    size |= (c & 0x7f) << shift;   /* 代码里用 st_left_shift 防溢出 */
    shift += 7;
}

也就是说 size 的容量是 4 + 7·(n-1) 位,n 为头字节数。编码侧对称:encode_in_pack_object_header()(pack-write.c#L508+)先 c = (type << 4) | (size & 15); size >>= 4;,再逐字节写 continuation。MAX_PACK_OBJECT_HEADER 定义为 10(pack.h#L133),注释说「which will handle sizes up to 2^67」——10 字节头能表示 4 + 7*9 = 67 位长度,远超任何实际对象。

逐例验证:

场景typesize头字节(hex)说明
空 blobOBJ_BLOB=3030`(3<<4)0 = 0x30`
blob hello\n3636`0x306`
treeOBJ_TREE=23002c 92`0x2c=(2<<4)12;0x92=0x800x12;解出 12 + (0x12<<4)=300`
commitOBJ_COMMIT=120018 8c`0x18=(1<<4)8;0x8c=0x800x0c;解出 8 + 0x0c<<4 = 8+192=200`

注意 size 的含义:对非 delta 是未压缩对象长度;对 delta 是压缩前 delta 指令流长度(base 引用不计入)。

3.3.2 OFS_DELTA 的「负相对偏移」编码

OFS_DELTA 不写 base 的 OID,而写「从本对象头首字节到 base 对象头首字节的负距离」。规范:n bytes with MSB set in all but the last one,且 for n >= 2 要额外加 2^7 + 2^14 + ... + 2^(7*(n-1))。这个「+1 累加」的古怪规则来自编码实现(builtin/pack-objects.c#L580-L599):

off_t ofs = entry->idx.offset - DELTA(entry)->idx.offset;
unsigned pos = sizeof(dheader) - 1;
dheader[pos] = ofs & 127;
while (ofs >>= 7)
    dheader[--pos] = 128 | (--ofs & 127);

解码实现是 packfile.c#L1001-L1015:

c = base_info[used++];
base_offset = c & 127;
while (c & 128) {
    base_offset += 1;
    if (!base_offset || MSB(base_offset, 7)) return 0;   /* 溢出检查 */
    c = base_info[used++];
    base_offset = (base_offset << 7) + (c & 127);
}
base_offset = delta_obj_offset - base_offset;
if (base_offset <= 0 || base_offset >= delta_obj_offset) return 0;  /* 越界检查 */

逐例(ofs = 相对距离):

ofs编码字节(hex)解码验证
101单字节,值 1
1277f单字节,值 127
12880 00c=0x80:base=0→+1=1,再 (1<<7)+0x00=128
12980 01(1<<7)+1 = 129
25680 80 00((0+1)<<7+0)=128;+1=129;(129<<7)+0 = 16512→不对

最后一行需要小心:256 的正确编码是 80 80 00 还是别的?用编码器验证 ofs=256:

  • 末字节 256 & 127 = 0 → 0x00;ofs >>= 7 = 2(真)。
  • --ofs → 1;1 & 127 = 1;写 0x80|1 = 0x81;再 ofs >>= 7 → 0,退出。 所以 ofs=256 实际编码是 81 00(两字节)。解码:c=0x81 → base=1(因为 0x81&127=1),有 MSB → +1=2,(2<<7)+0x00=256。对上了。而 80 80 00 解码是 ((0+1)<<7+0)+1=129,再 (129<<7)+0=16512。可见编码器不会产生 80 80 00,因为每遇到一个 continuation 就 --ofs。这个细节正是理解「为什么规范要写那条加和公式」的钥匙:每一级 continuation 都隐含加 1,所以 2^7+2^14+... 的增量不是自由选择的,而是编码约定。

REF_DELTA 则简单:紧跟对象头写 base 的 OID(hashsz 字节),由 find_pack_entry_one() 在同一个 pack 内定位 base(packfile.c#L1016-L1021)。规范提示:REF_DELTA 也可指向 pack 外的对象(thin pack),但落到磁盘的 pack 必须自包含,否则形成循环依赖。

3.3.3 delta 指令流:copy 与 add

delta 载荷的前两段是「base 大小」和「结果大小」两个 varint(get_delta_hdr_size(),delta.h#L89-L102,低位在前、逐字节 size |= (c & 0x7f) << i; i += 7)。之后是指令序列,指令类型由首字节 bit7 决定:

copy 指令(1xxxxxxx):

+----------+---------+---------+---------+---------+-------+-------+-------+
| 1xxxxxxx | offset1 | offset2 | offset3 | offset4 | size1 | size2 | size3 |
+----------+---------+---------+---------+---------+-------+-------+-------+

首字节低 7 位是「哪些参数字节存在」的位图:bit0→offset1、bit1→offset2、bit2→offset3、bit3→offset4、bit4→size1、bit5→size2、bit6→size3。offset 与 size 小端拼装,分别是 32 位与 24 位。省略某字节不会改变其余字节的位权——规范特别强调「if only offset2 is omitted, offset3 still contains bits 16-23」。最紧凑形式是单字节 0x80(offset=size=0)。特例:size 字段为 0 时自动视作 0x10000(64KiB),见 patch-delta.c#L57 if (cp_size == 0) cp_size = 0x10000;。解码在 patch_delta() 中用 PARSE_CP_PARAM 宏展开(patch-delta.c#L43-L56),并做三重边界检查:cp_off+cp_size 不溢出、≤ src_size、cp_size ≤ 剩余 size。

add 指令(0xxxxxxx):首字节低 7 位就是 data 字节数(必须非零),随后紧跟这些字节。解码时检查 cmd > size || cmd > top - data 防止越界(patch-delta.c#L65-L71)。

保留指令:00000000(全零字节)为未来扩展预留,当前遇到即报 unexpected delta opcode 0 并失败——这保证了对未知格式宁可失败也不猜。

delta 载荷最小长度是 4 字节(DELTA_SIZE_MIN,delta.h#L83)。一个合成的 delta 示例(base 12 字节 → 结果 10 字节):

0c (base size = 12)
0a (result size = 10)
91 (copy: 1xxxxxxx, bit0=1→offset1, bit4=1→size1  即 offset 与 size 各 1 字节)
00 (offset1 = 0)
06 (size1 = 6)
下一条 add: 但这里用 0x04 表示插入 4 字节
61 62 63 64

即「从 base 偏移 0 拷贝 6 字节,再插入 abcd」,结果 10 字节。解码 0x91 = 0b10010001:bit0=1(offset1 存在)、bit4=1(size1 存在),故 cp_off = offset1 = 0、cp_size = size1 = 6。

3.4 .idx:v1 与 v2 的逐字节差异

3.4.1 v1 布局
┌────────────────────────────────────────┐
│ fanout[256]                  1024 字节 │  每项 4 字节,记录「首字节 ≤ i 的对象数」
├────────────────────────────────────────┤
│ entry 0: offset(4) + oid(hashsz)       │  24 字节(SHA-1)
│ entry 1: ...                            │  按 OID 排序
│ ...                                     │
├────────────────────────────────────────┤
│ pack checksum (hashsz)                  │
│ idx  checksum (hashsz)                  │
└────────────────────────────────────────┘

v1 的问题有两个:(a) 偏移只有 32 位,pack 超过 4GiB 就废了;(b) 每条记录把 offset 与 OID 交错存放,二分查找时每次比较都要跨步 hashsz+4 字节,缓存足迹大。规范写道:v2「supports packs larger than 4 GiB」并把 OID 表单独抽出来「to reduce the cache footprint of the binary search」。

3.4.2 v2 布局
┌──────────────────────────────────────────────┐
│ magic  ff 74 4f 63  ("\377tOc")       4 字节  │
│ version = 2                          4 字节  │
├──────────────────────────────────────────────┤
│ fanout[256]                       1024 字节  │
├──────────────────────────────────────────────┤
│ oid table     : nobjects × hashsz            │  排序后的 OID,紧凑排列
│ crc32 table   : nobjects × 4                 │  每个 packed 对象原始字节的 zlib CRC32
│ offset32 table: nobjects × 4                 │  通常 31 位偏移;MSB=1 表示「转 64 位表」
│ offset64 table: k × 8(k ≤ nobjects-1)      │  仅当有偏移 ≥ 2^31 时存在
├──────────────────────────────────────────────┤
│ pack checksum (hashsz)                       │
│ idx  checksum (hashsz)                       │
└──────────────────────────────────────────────┘

load_idx()(packfile.c#L105-L183)做了严格的尺寸与单调性校验,值得逐条读:

  1. idx_size < 4*256 + hashsz + hashsz → error("index file %s is too small")(至少要有 fanout + 两个 checksum)。
  2. 若 hdr->idx_signature == htonl(PACK_IDX_SIGNATURE) 则读 version;version < 2 || version > 2 → 报「unsupported by this binary, try upgrading GIT」。(注意:代码只接受 version == 2,尽管头文件写 idx_version 可达 3。实际上 Git 从不写 idx v3,v3 只用于 --object-format 的兼容讨论。)
  3. 否则 version = 1(没有 magic 就是 v1)。
  4. 遍历 fanout,if (n < nr) return error("non-monotonic index %s", path)——fanout 必须单调不减,否则立即判定损坏。
  5. v1 总尺寸精确公式:4*256 + hashsz + hashsz + nr*(hashsz+4),不符即 error("wrong index v1 file size")。
  6. v2 最小尺寸:8 + 4*256 + hashsz + hashsz + nr*(hashsz+4+4)(头 + fanout + OID + CRC + offset32 + trailer);最大尺寸再加 (nr-1)*8(offset64 表最多 nobjects-1 项)。若 idx_size != min_size 且 sizeof(off_t) <= 4 → error("pack too large for current definition of off_t")(32 位平台读不了大 pack)。
  7. p->crc_offset = 8 + 4*256 + nr*hashsz; 记录 CRC 表起点。
3.4.3 fanout 表:为什么是 256 项

fanout 是「空间换时间」的赌注。要找 OID 为 01xxxx... 的对象,若没有 fanout,需在 n 个对象里做 log2(n) 次二分,每次比较 20 字节。有了 fanout,直接读 fanout[0x01] 与 fanout[0x00] 得到该首字节区间的 [lo, hi),把二分的搜索区间从 n 缩到约 n/256——相当于免费省掉 8 次比较,代价只是 1KiB 常驻与一次 O(1) 查表。write_idx_file() 生成 fanout 的代码(pack-write.c#L113-L123)极其直白:

for (i = 0; i < 256; i++) {
    struct pack_idx_entry **next = list;
    while (next < last) {
        struct pack_idx_entry *obj = *next;
        if (obj->oid.hash[0] != i) break;
        next++;
    }
    hashwrite_be32(f, next - sorted_by_sha);   /* 累计计数 */
    list = next;
}

注意 next - sorted_by_sha 是累计计数(不是区间计数)——所以查表时 lo = fanout[h-1]、hi = fanout[h](h==0 时 lo=0),这正是 bsearch_hash() 的用法(hash-lookup.c#L107-L134)。

一个 3 对象(首字节分别为 0x01, 0x01, 0x80)的 fanout 示例:

fanout[0x00] = 00 00 00 00
fanout[0x01] = 00 00 00 02
fanout[0x02..0x7f] = 00 00 00 02   ← 共 127 项
fanout[0x80] = 00 00 00 03
fanout[0x81..0xff] = 00 00 00 03   ← 共 128 项

于是查 0x80...:lo = fanout[0x7f] = 2,hi = fanout[0x80] = 3,只需检查下标 2 这一个条目。

3.4.4 offset 表与大偏移转义

v2 的 offset32 表用 MSB 做转义开关(pack-write.c#L150-L160;读取侧 packfile.c#L1784-L1802):

offset = (need_large_offset(obj->offset, opts)
          ? (0x80000000 | nr_large_offset++)   /* 高位置 1,低位是 64 位表下标 */
          : obj->offset);

need_large_offset()(pack-write.c#L40-L51)判定条件是 (offset >> 31) || (opts->off32_limit < offset),另有一个 anomaly 特例表用于强制某些偏移走 64 位(byte-for-byte 校验场景)。读取时:

off = ntohl(...);
if (!(off & 0x80000000)) return off;
index += nobjects*4 + (off & 0x7fffffff)*8;
check_pack_index_ptr(p, index);
return get_be64(index);

注意 check_pack_index_ptr()(packfile.c#L1770-L1782)是唯一的运行时边界护栏:它断言指针 ≥ start 且 < end - 8(留 8 字节给 64 位读),超界则 die("offset beyond end of pack index (truncated index?)")。只有变长段(大偏移表)需要这个检查,定长段在 load_idx() 已核算过总量。

一个 v2 idx 的头部字节示例(3 对象):

offset  bytes
0x0000  ff 74 4f 63                magic
0x0004  00 00 00 02                version = 2
0x0008  00 00 00 00                fanout[0x00] = 0
0x000c  00 00 00 02                fanout[0x01] = 2
...      (256 项,共到 0x0408)
0x0408  <oid_0: 20 bytes>          OID 表第 1 条
0x041c  <oid_1: 20 bytes>
0x0430  <oid_2: 20 bytes>
...      crc32 表 3×4 = 12 字节
...      offset32 表 3×4 = 12 字节
...      pack checksum (20) + idx checksum (20)

3.5 .rev 与 .mtimes:反向索引与 cruft 时间戳

3.5.1 为什么需要反向索引

.idx 提供的是 OID → offset(名字找位置);但有一大类操作要反过来问:给定 pack 内偏移,这个对象是谁、排在 pack 里第几个? 典型场景:

  • git verify-pack -v 要按偏移顺序列出每个对象,并算出「对象压缩后占用多少字节」——即下一个对象的 offset 减当前 offset,这需要偏移有序的视图。
  • git fsck、git cat-file --batch-check 在遍历 pack、读到 delta base 时,需要把 base 的偏移映射到索引位次,才能取 OID。
  • 打包复用(pack reuse)与 bitmap 需要「包位置 → 位图位置」。
  • 若没有 .rev,只能从 .idx 线性扫描所有对象的 offset 再排序,复杂度 O(N log N) 且要把整个 idx 读进内存条目数组。

.rev 把「按偏移升序排列的 索引位次表」直接落盘,于是 offset→position 变成一次二分(offset_to_pack_pos(),pack-revindex.c#L433-L458),position→offset 是一次数组查表(pack_pos_to_offset(),L473-L486)。

3.5.2 .rev 逐字节布局

规范(Documentation/gitformat-pack.adoc「pack-*.rev files」)给出:

┌────────────────────────────────────────────┐
│ magic  "RIDX"  = 0x52494458         4 字节  │
│ version = 1                         4 字节  │
│ hash_id (=1 SHA-1 / =2 SHA-256)     4 字节  │
├────────────────────────────────────────────┤
│ pack_order[0..num_objects-1]              │  每项 4 字节
│   ↑ 第 i 项 = 「按偏移第 i 小的对象」在 .idx 中的索引位次
├────────────────────────────────────────────┤
│ pack checksum (hashsz 字节)                │
│ rev  checksum (hashsz 字节)                │
└────────────────────────────────────────────┘

12 字节固定头对应 struct revindex_header(pack-revindex.c#L200-L204):

struct revindex_header {
	uint32_t signature;
	uint32_t version;
	uint32_t hash_id;
};
#define RIDX_HEADER_SIZE (12)

hash_id 是 v2.56 才加入的字段(早期 .rev 没有它),用于让 SHA-256 仓库的 .rev 能被正确识别。load_revindex_from_disk()(L206-L274)的校验链条非常严:

  1. 文件大小 < 12 + 2*hashsz → reverse-index file ... is too small;
  2. 文件大小 必须恰好等于 12 + 2*hashsz + 4*num_objects → 否则 is corrupt(注意这里用的是 st_mult 做溢出安全的乘法,拒绝任何长度对不上的文件);
  3. 魔数 != RIDX_SIGNATURE → unknown signature;
  4. 版本 != 1 → unsupported version;
  5. hash_id 既不是 1 也不是 2 → unsupported hash id。

这五条把「误把 idx 当 rev 读」之类的错配彻底挡在门外。装载后 p->revindex_data 指向 map + 12(L297),即位置表首地址。

读取映射的两个关键函数(L460-L486)说明了内存版与磁盘版的统一接口:

uint32_t pack_pos_to_index(struct packed_git *p, uint32_t pos) {
	if (p->revindex)      return p->revindex[pos].nr;
	else                  return get_be32(p->revindex_data + pos);
}
off_t pack_pos_to_offset(struct packed_git *p, uint32_t pos) {
	if (p->revindex)                 return p->revindex[pos].offset;
	else if (pos == p->num_objects)  return p->pack_size - hashsz; /* 末项哨兵 */
	else                             return nth_packed_object_offset(p, pack_pos_to_index(p, pos));
}

注意磁盘版 pack_pos_to_offset 并没有直接存 offset,而是「先查索引位次、再去 .idx 取 offset」——因为 .idx 本来就按索引位次排好,多一次跳转却省下 4 字节/对象的存储。内存版却直接存了 off_t,因为它要做基数排序,避免每次都回查 .idx。

3.5.3 .mtimes 逐字节布局(cruft pack)

cruft pack 是「不删除不可达对象、只把它们冻起来」的机制(详见 §10 与 §11)。为了让 GC 之后还能按对象年龄决定何时真正删除,需要为每个对象记 mtime——这就是 .mtimes:

┌────────────────────────────────────────┐
│ magic  "MTME" = 0x4d544d45      4 字节  │
│ version = 1                      4 字节  │
│ hash_id (=1/=2)                  4 字节  │
├────────────────────────────────────────┤
│ mtimes[0..num_objects-1]                │  每项 4 字节,第 i 项 = .idx 第 i 个对象的 mtime(epoch 秒)
├────────────────────────────────────────┤
│ pack checksum + mtimes checksum         │
└────────────────────────────────────────┘

规范特别强调:表中第 i 项对应按字典序(即 .idx 索引位次)排序的对象,而不是 pack 内偏移顺序。这与写侧的处理一致:struct packing_data 里 cruft_mtime「写入时按 pack 顺序存储,落盘时按字典序重排」(pack-objects.h#L176-L182 的注释)。原因是写 pack 的时候对象是按写序(拓扑序)排列的,只有索引生成完毕后才知道字典序,因此需要一次重排。

packed_git 用 is_cruft 位标记 cruft pack、mtimes_map/mtimes_size 挂载 mmap;is_cruft 的推断依据就是「同族里是否存在 .mtimes 文件」。cruft pack 与普通 pack 的字节格式完全相同——唯一区别是这些对象虽不可达但被保留,并用 .mtimes 记住年龄。

3.6 multi-pack-index(MIDX)逐字节解读

3.6.1 为什么要有 MIDX

当仓库有几十个 pack 时,每次按 OID 查对象都要逐个 pack 二分,最坏 O(num_packs · log N),且每个 pack 的 fanout 表都要 mmap。MIDX 把「所有 pack 的对象」合并进一张全局 OID 排序表,一次二分定位到 (pack_id, offset),并把重复对象(同一个 OID 出现在多个 pack)去重——这就是「多包索引」。

MIDX 文件有三种:multi-pack-index(主文件)、multi-pack-index-<hash>.rev(反向索引)、multi-pack-index-<hash>.bitmap(多包可达性位图)。命名由 odb_pack_name() 与 MIDX 专用命名逻辑生成。其整体设计动机(多包查找、去重、伪 pack 顺序、位图复用)另见 Documentation/technical/multi-pack-index.adoc,与本节逐字节解读互为「为什么 / 是什么」。

3.6.2 MIDX 采用 chunk 化布局

MIDX 不是一张平铺表,而是chunk-based 格式(见 Documentation/gitformat-chunk.adoc)。骨架:

┌────────────────────────────────────────────────────────┐
│ HEADER (12 字节)                                        │
│   'MIDX' 4B │ version 1B │ hash_id 1B │ num_chunks 1B   │
│   num_base_midx 1B │ num_packs 4B                       │
├────────────────────────────────────────────────────────┤
│ CHUNK LOOKUP: (C+1) × 12 字节                          │
│   每项 = chunk id(4B) + offset(8B)                      │
│   末项 id=0 作为终止符,其 offset = 文件体长度           │
├────────────────────────────────────────────────────────┤
│ CHUNK 0 数据(顺序任意,通常按 id 升序排布)             │
│ CHUNK 1 数据                                             │
│ ...                                                     │
├────────────────────────────────────────────────────────┤
│ TRAILER: 对以上全部内容的校验和                          │
└────────────────────────────────────────────────────────┘

头部逐字节(共 12 字节):

偏移长度字段说明
04signature{'M','I','D','X'} = 0x4d494458
41versionv2.56 写 midx.version(默认 2),读接受 1 和 2
51hash_id1=SHA-1,2=SHA-256
61num_chunks本文件 chunk 数 C
71num_base_midx增量链中下层 MIDX 个数(规范写「当前恒为 0」,代码支持 1)
84num_packs本地 pack 数(不含 base 层)

write_midx_header()(midx-write.c#L36-L51)负责落盘;load_multi_pack_index_one()(midx.c#L108-L234)解析。加载时会先用 chunkfile_validate 校验:

  • num_chunks 与「chunk lookup 表长度 (C+1)*12」自洽;
  • 每个 chunk 的 offset 落在 [header+lookup, trailer) 区间;
  • hash_id 必须与本仓库哈希算法一致,否则忽略并告警(规范原文:「should be ignored with a warning presented to the user」)。
3.6.3 chunk id 与各 chunk 语义

midx.h 里定义了一组四字节 id(大端整数):

id 常量四字节内容必需
MIDX_CHUNKID_PACKNAMESPNAMNUL 结尾 pack 名序列,字典序(v1 强制),4 字节对齐填充必需
MIDX_CHUNKID_BITMAPPEDPACKSBTMP每 pack 两个 u32:bitmap_pos、bitmap_nr可选
MIDX_CHUNKID_OIDFANOUTOIDF256 项 fanout,F[i] = 首字节 ≤ i 的对象数必需
MIDX_CHUNKID_OIDLOOKUPOIDL全部 OID 字典序排列必需
MIDX_CHUNKID_OBJECTOFFSETSOOFF每对象 2×u32:pack-int-id + offset必需
MIDX_CHUNKID_LARGEOFFSETSLOFF8 字节大偏移表可选
MIDX_CHUNKID_REVINDEXRIDX伪 pack 顺序(bitmap 位序)表可选
MIDX_CHUNKID_BASEBASE增量链:下层 MIDX 的对象数、pack 数可选

各 chunk 的写实现把「格式即代码」体现得淋漓尽致:

  • write_midx_pack_names()(midx-write.c#L452-L485):逐个写 <name>\0,最后补 NUL 到 4 字节对齐。
  • write_midx_bitmapped_packs()(L487-L505):每个 pack 写 bitmap_pos、bitmap_nr 对。
  • write_midx_oid_fanout()(L507-L534):遍历字典序 OID 填 fanout(累计计数),与 .idx 的 fanout 语义完全一致。
  • write_midx_oid_lookup()(L536-L559):顺序写 OID 表。
  • write_midx_object_offsets()(L561-L589):写 <pack_id, offset>。若全 pack 最大偏移 < 2^31(MIDX_LARGE_OFFSET_NEEDED 未命中),直接写 32 位;否则偏移 ≥ 2^31 的项写 0x80000000 | large_offset_index。
  • write_midx_large_offsets()(L591-L618):写 8 字节大偏移表。

OOFF 里 pack-int-id 就是 PNAM 中 pack 名的行号;nth_midxed_pack_int_id()(midx.c#L584-L590)就是从 OOFF 表里取它。

3.6.4 增量 MIDX 链(BASE chunk)

v2 MIDX 引入「增量链」以支持超大仓库的分批写入(git multi-pack-index write --bitmap 或并发 repack 场景):

  • 主 MIDX 通过 BASE chunk 指向 1 个 base MIDX(num_base_midx 字段此前恒 0,现在可为 1);
  • struct multi_pack_index 里 base_midx 指向下层,num_objects_in_base 是所有更早层对象数之和,num_packs_in_base 同理;
  • 全局位置 n 是「拼接后的位置」:nth_midxed_object_oid()(midx.c#L547-L559)先调 midx_for_object() 把 n 折算到具体某一层,再查该层的 OIDL/OOFF。

折算是这样:midx_for_object()(midx.c#L521-L531 附近的 bsearch_one_midx 家族)从最顶层开始,若 n < num_objects_in_base 则下沉到 base_midx,并把 n 减去当前层对象数;否则在当前层查。这让「全局 OID 搜索」在链上退化为「先看落在哪一层、再在该层二分」。

3.6.5 MIDX 的 RIDX 与伪 pack 顺序

MIDX 的 RIDX chunk 与单 pack .rev 不同:它映射的是「MIDX 位次 → 伪 pack 位置」。规范「multi-pack-index reverse indexes」一节把伪 pack 定义得非常清楚:

|a,0|a,1|...|a,9|b,0|b,1|...|b,14|c,0|c,1|...|c,19|

即把所有 pack 按 MIDX pack id(含 preferred pack 优先) 首尾相接,pack 内对象按 pack 偏移排序,重复对象只保留一次。排序规则(规范原文):

  1. 若 o1、o2 之一来自 preferred pack,则 preferred 排前;
  2. pack(o1) ≠ pack(o2) 时,按 pack id 降序;
  3. 否则按 pack 内偏移升序。

midx_pack_order()(midx-write.c#L659+)实现该排序,write_midx_revindex()(L620-L636)把结果写成 RIDX chunk。取用时 pack_pos_to_midx()(pack-revindex.c#L488-L498)先沿 base_midx 找到层,再 get_be32(revindex_data + pos - num_objects_in_base)。

BTMP chunk 则把每个 pack 的「位图起始位号 + 位数」记下来,供 pack-objects 在写 bitmap 时对单个 pack 整体做 verbatim 复用——这是 §8 性能一节会展开的多包复用基础。

3.7 delta 指令流的更多细节

§3.3.3 已经讲过 copy/add/保留三类指令。这里补三个容易踩的字节级细节。

(1) delta 头的两个 varint。 delta 载荷前 4 字节至少是「base 大小 + 结果大小」两个变长整数,get_delta_hdr_size()(delta.h#L89-L102)实现是低位在前(little-endian varint):

static inline unsigned long get_delta_hdr_size(const unsigned char **datap,
					       const unsigned char *top)
{
	const unsigned char *data = *datap;
	unsigned char cmd;
	unsigned long size = 0;
	int i = 0;
	do {
		cmd = *data++;
		size |= (cmd & 0x7f) << i;
		i += 7;
	} while (cmd & 0x80 && data < top);
	*datap = data;
	return size;
}

注意这与 pack 对象头的size varint 恰好相反:对象头是「最高位续位、低 4+7n 位」,delta 头是「高位续位、低 7 位、低位在前」。两套 varint 并存是历史包袱,写 parser 时极易混淆。

(2) copy 指令的字段位权固定。 正如 §3.3.3 强调,省略 offset2 不会把 offset3 左移——PARSE_CP_PARAM 宏(patch-delta.c#L43-L56)用「若位图命中才读字节」的条件读法:

#define PARSE_CP_PARAM(bit, var, shift) do { \
	if (cmd & (bit)) {                        \
		if (data >= top) goto bad;            \
		var |= ((unsigned) *data++ << (shift));\
	} } while (0)
PARSE_CP_PARAM(0x01, cp_off, 0);
PARSE_CP_PARAM(0x02, cp_off, 8);
PARSE_CP_PARAM(0x04, cp_off, 16);
PARSE_CP_PARAM(0x08, cp_off, 24);
PARSE_CP_PARAM(0x10, cp_size, 0);
PARSE_CP_PARAM(0x20, cp_size, 8);
PARSE_CP_PARAM(0x40, cp_size, 16);

每个参数有独立的位移量,所以「跳过某个字节」只影响该参数,其它字节的相对位置不变。

(3) delta 生成侧的 MAX_OP_SIZE。 diff-delta.c#L317 定义 MAX_OP_SIZE (100 * 1024 * 1024):按理一个 copy 指令最多搬 0x10000(或 24 位字段上限 0xffffff),但生成器在处理超大 base 时会主动把「单条 copy」限制到 100MiB 以下,并在需要时拆成多条 copy——避免构造出「解码器边界检查会拒绝」的指令。diff-delta.c 中 do { ... } while (...) 的分块逻辑就是为此。

另外一个实践事实:delta 指令流本身也会被 zlib 压缩。pack 里看到的 compressed data 是 zlib(delta 指令流),而不是 delta(zlib(data))。这一点从 unpack-objects 的 get_data()(builtin/unpack-objects.c#L117-L158)可以验证:它先 inflate 出 delta 指令,再交给 patch_delta() 应用到 base。


§4 核心算法推导

本节回答「为什么这么设计能跑得快」。我们把查找、反向映射、delta 选择、写序四个算法的复杂度与不变量推一遍。

4.1 索引查找:fanout + 二分,为什么是 O(log(N/256))

设一个 pack 有 N 个对象,OID 表已字典序排列。朴素二分是 ⌈log2 N⌉ 次比较,每次比较 20/32 字节。fanout 的作用是把「首字节区间」预计算出来:

  • 查 fanout[h] 与 fanout[h-1](h = oid[0])得到区间 [lo, hi),长度约为 N/256;
  • 在该区间内二分,只需 ⌈log2(N/256)⌉ = log2 N - 8 次比较。

bsearch_hash()(hash-lookup.c#L107-L134)的契约是:

/*
 * @hash: the hash value to lookup
 * @fanout: pointer to the fanout table
 * @table: pointer to the actual OID table (start of the lookup)
 * @hashsz: the hash function output size
 * @name: buffer of size hashsz to place the OID
 */
const void *bsearch_hash(const unsigned char *hash, const uint32_t *fanout,
			 const unsigned char *table, size_t hashsz,
			 void *name);

它先算 lo = hash[0] ? ntohl(fanout[hash[0]-1]) : 0、hi = ntohl(fanout[hash[0]]),再在 table + lo*hashsz .. table + hi*hashsz 内做标准二分。关键不变量:fanout 必须单调不减且 fanout[255] == N;load_idx() 的 monotonic 检查(§3.4.2)就是在守这个不变量。

再叠加 MIDX:当多包存在时,bsearch_midx()(midx.c#L533-L540)对同一个 fanout 语义在 MIDX 上做二分,bsearch_one_midx()(L521-L531)先在链上确定层。于是「多包查找」从「num_packs 次二分」降为「一次(含链下沉的常数次)二分」。

复杂度小结:

查找方式比较次数前提
无 fanout 纯二分log2 NOID 表有序
idx + fanoutlog2 N − 8fanout 单调、fanout[255]=N
MIDX + fanoutlog2 N_all − 8全局 OID 表有序、去重
MIDX 增量链log2 N_layer − 8 + 链深每层 fanout 自洽

4.2 反向映射:基数排序 offset↔pos

create_pack_revindex()(pack-revindex.c#L133-L172)建内存版反向索引的步骤:

  1. ALLOC_ARRAY(p->revindex, num_ent + 1)——多分配一个哨兵;
  2. 从 .idx 读出每个对象的 offset,填入 revindex[i] = {offset, nr=i}(v2 需处理大偏移转义;v1 直接读交错表);
  3. 令 revindex[num_ent].offset = p->pack_size - hashsz(trailer 起始位置)、nr = -1;
  4. sort_revindex(p->revindex, num_ent, p->pack_size)。

哨兵的意义:偏移排序后,revindex[i] 的下一项 revindex[i+1].offset 就是对象 i 的结束位置,于是 size(i) = offset(i+1) - offset(i)(含对象头与 zlib 流)。最后一项的结束位置由哨兵给出,正是 pack trailer 起点。

sort_revindex()(L39-L128)是 LSD 基数排序,digit 16 位:

#define DIGIT_SIZE (16)
#define BUCKETS (1 << DIGIT_SIZE)              /* 65536 桶 */
#define BUCKET_FOR(a, i, bits) (((a)[(i)].offset >> (bits)) & (BUCKETS-1))

for (bits = 0; max >> bits; bits += DIGIT_SIZE) {
	MEMZERO_ARRAY(pos, BUCKETS);
	for (i = 0; i < n; i++) pos[BUCKET_FOR(from, i, bits)]++;
	for (i = 1; i < BUCKETS; i++) pos[i] += pos[i-1];
	for (i = n - 1; i != UINT_MAX; i--)      /* 反向稳定放置 */
		to[--pos[BUCKET_FOR(from, i, bits)]] = from[i];
	SWAP(from, to);
}

复杂度分析:一共 ⌈max_bits/16⌉ 轮,每轮 O(n + 65536)。对 4GiB 以内 pack(max < 2^32),只需 2 轮;每轮 65536 个桶的清零/前缀和是常数开销。因此总复杂度 O(n)(当 n 远大于 65536 时),远优于 qsort 的 O(n log n)。这正是 pack.rev 存在的性能理由:反向索引的构建是对「对象数」线性的,而不是对「对象数×对数」。

代价是 65536 个 unsigned(256KiB)临时桶 + n 项临时数组,内存为 O(n)。代码注释明确权衡了「DIGIT_SIZE 16 兼顾内存与轮数」。

offset_to_pack_pos()(L433-L458)在排好序的 revindex 上二分,hi = num_objects + 1(含哨兵),命中即返回位次。整体是 O(log n)。

4.3 MIDX 构建:去重、排序与伪 pack 顺序

MIDX 的构建(midx-write.c)要解决三个问题:(a) 哪些 pack 入选;(b) 重复对象如何只保留一份、并决定「留哪一份」;(c) 排序键如何统一。

(a) 入选与「首选 pack」。 midx_pack_order() 与 add_pack_to_midx() 把「每个 pack 的 <hash>.idx」读入,用 struct pack_info(midx-write.c#L53-L62)记录 pack 名、mtime、preferred 标志。preferred pack 通常是最新、最大的 pack(--preferred-pack 可指定),它在伪 pack 顺序里排最前,从而让多包位图复用优先命中它。

(b) 去重规则。 关键函数是 cmp_idx_or_pack_name()(midx.c#L635-L664)与 midx_fill_entry()(midx.c#L592-L632)。构建时把所有 pack 的 (oid, pack_id, offset) 三元组合并成一个数组,按 OID 排序;相同 OID 只保留一条,保留优先级大致是「更早的层优先、同层内由 pack 顺序与 offset 决定」——这样保证结果稳定且可复现。nth_midxed_offset()(midx.c#L561-L582)返回 (pack_id, offset) 对,nth_midxed_pack_int_id() 返回 pack 行号。

(c) 排序与 fanout。 合并后按 OID 字典序排,重算 OIDF fanout;OOFF 的 pack-int-id 是「该 OID 被选中时所在 pack 的行号」,因此 MIDX 查到一个 OID 立刻知道去哪个 pack 的哪个偏移取对象。

复杂度上界:设总对象(含重复)为 M,去重后为 N。合并 + 排序是 O(M log M);fanout 与各 chunk 写入是 O(N);伪 pack 顺序表(RIDX)是 O(N log N)(或可优化为线性,取决于 midx_pack_order 是否借用既有排序)。因此 MIDX 写入的主要成本在排序,这也是 git multi-pack-index write 在大仓库上偏慢的原因之一。

伪 pack 顺序(§3.6.5)的去重语义很重要:同一位图位只对应一个对象,即使该对象在多个 pack 都有副本。这样多包位图的位数 = 去重后对象数 N,而不是 M。

4.4 delta 选择:窗口、深度、Rabin 哈希与启发式剪枝

delta 的核心权衡:(i)把对象表示成「base + 编辑指令」能省多少字节?(ii)展开这条链要多少代价? 前者是压缩率,后者是读取性能。Git 用「窗口 + 深度 + 启发式」三件套控制。这套启发式最初由 Linus 亲自设计,Documentation/technical/pack-heuristics.adoc 完整保留了 2006 年他在 IRC 上对该算法的口述解释——它是理解本节「为什么是窗口 10、深度 50」的一手材料。

(1) 窗口(window)。 find_deltas()(builtin/pack-objects.c#L2992-L3125)维护一个大小 window 的环形数组 array,逐个取候选对象 n,与窗口内「最近处理过的」至多 window-1 个对象 m 做 try_delta()。默认 window = 10(L228)。窗口越大,找到更好 base 的概率越高,但候选比较次数呈 O(window) 增长。完成后若 n 成为 delta,还会把它的 base「上移到窗口中靠前位置」(L3107-L3117),让它更久地留在窗口里——这是一种「热 base 长寿」的启发式。

(2) 深度(depth)。 默认 depth = 50(L230)。try_delta() 用 if (src->depth >= max_depth) return 0; 拒绝过深 base(L2838-L2840)。同时 check_delta_limit()(L2965-L2976)沿 DELTA_CHILD 链递归算出「以当前对象为 base 的子树最大深度」,把 max_depth 相应调小,防止「链尾对象已经 50 层,再给它的孩子加 delta 会超限」。

(3) Rabin 指纹。 create_delta_index()(diff-delta.c#L135+)在 base 上滑一个 16 字节窗口(RABIN_WINDOW 16),用 Rabin 滚动哈希(RABIN_SHIFT 23)算出每个位置的指纹,填入哈希桶。滚动哈希的关键性质:从位置 i 到 i+1 的指纹可以在 O(1) 内递推,无需重算 16 字节,这就是为什么它对大数据可行。桶冲突用链表处理,但链表长度被 HASH_LIMIT 64 截断(L19-L20)——超过 64 个同哈希项就丢弃新的,因为「极端重复数据(如全零文件)」下继续加项只会拖慢而不提升压缩率。

(4) 大小启发式剪枝。 try_delta()(L2808-L2963)在真正做 diff 前做多道过滤:

  • 类型必须相同(oe_type(trg) != oe_type(src) → 拒绝);
  • max_size 按 trg_size/2 - hashsz 起步(若是非 delta),再乘 (max_depth - src->depth) / (max_depth - ref_depth + 1) 做深度折扣(L2842-L2854);
  • sizediff = max(0, src_size - trg_size) >= max_size → 拒绝(源比目标还不小,不值得);
  • trg_size < src_size / 32 → 拒绝(目标太小,delta 头开销吃掉收益);
  • !in_same_island(...) → 拒绝(delta islands:不能跨「孤岛」建 delta,否则瘦身/浅克隆复用会破坏依赖)。

这些剪枝让「候选对数」在实践中远小于窗口的理论上界,是 git gc 耗时可接受的关键。

(5) delta 链与读取成本。 一条深度为 d 的 delta 链,读取最深对象时需 inflate + patch d+1 次。delta_base_cache(§2.7)缓存「最近展开过的 base 完整数据」,命中则只需 patch 一次。因此写侧的 depth 上限与读侧的缓存共同决定了「压缩率 ↔ 读取延迟」的平衡点:depth 越大文件越小但读取越慢,缓存越大读取越快但内存越多。

4.5 写序:拓扑序、base 先行与 compute_write_order()

pack 的一个硬约束:delta 对象的 base 必须出现在它之前(OFS_DELTA 用「负相对偏移」,即 base 在更早的位置;REF_DELTA 虽写 OID 无位置要求,但落在同一 pack 也要能顺序解析)。因此写出顺序不是任意排序,而是一次以 delta 边为依赖的有向无环图拓扑排序。

compute_write_order()(builtin/pack-objects.c#L989-L1040)的思路:

  1. 先写非 delta 的对象(它们没有依赖,且是很多 delta 的 base);
  2. 用 depth 做「按深度分层」:深度 0 的非 delta 先出;
  3. write_one()(L801-L852)在遇到 delta 对象时,先递归写它的 base,再写它自己,并顺手算出 OFS_DELTA 的相对偏移(此时两对象的 idx.offset 都已确定)。

write_object()(L727-L792)负责真正的落盘:调 do_compress() 压缩数据、写对象头、写 delta base 引用、写压缩流,并把 idx.offset 与 idx.crc32 填回 object_entry。

一个容易忽略的细节:写序还影响 CRC 与 offset 的稳定性。write_idx_file() 需要在所有 idx.offset 确定后,按 OID 排序重排哈希表(pack_order_cmp)。因此写出阶段是「先按拓扑序写 pack、再按字典序生成 idx」——两套顺序,一次转换。

--path-walk(§11.9)则改变了「对象的组织粒度」:它按目录树逐条路径把对象分组,让同目录下的相似 blob 更容易进入同一个 delta 窗口,从而提升压缩率(API 细节见 Documentation/technical/api-path-walk.adoc);同时把对象数组切成 packing_region(pack-objects.h#L123-L130),使 bitmap 复用与 delta islands 能与 path-walk 并存。

算法层面的三句话总结:

  • 索引查找:fanout 把二分区间缩 256 倍,O(log N − 8);MIDX 把多包合并为一次查找。
  • 反向索引:基数排序把 offset↔pos 的构建降到 O(N),查询 O(log N)。
  • delta:窗口提供候选、深度约束链长、Rabin 指纹加速匹配、大小启发式剪枝,共同逼近「压缩率与读取速度的帕累托前沿」。

§5 关键路径代码走读

本节把「谁在什么时候写什么文件」落到具体函数,并标注 v2.56.0 的行号。四个主流程:打包(pack-objects)、索引(index-pack)、解包(unpack-objects)、整理(repack/multi-pack-index)。

5.1 git pack-objects:从对象列表到 .pack + .idx + .rev

主入口在 builtin/pack-objects.c。整体流水线:

argv 解析 → 收集待打包对象(packlist)
   → prepare_pack()(按 name hash 排序、划分 window 候选)
   → find_deltas()(多线程找 delta)        §4.4
   → compute_write_order()(拓扑定序)      §4.5
   → write_pack_file() → write_one() 逐个压缩写盘
   → 写完 fixup_pack_header_footer()(补头 + 补校验和)
   → write_idx_file()(按 OID 排序生成 idx)
   → write_rev_file()(可选,生成 .rev)

(1) 收集对象。 通过 add_object_entry() + traverse_commit_list() / mark_edges_uninteresting() 得到「要打包的 OID 集合」,每个对象在 to_pack.objects 里分配一个 object_entry(packlist_alloc())。冷启动时还要读源 pack 的对象头,填 in_pack_offset、in_pack_header_size。

(2) prepare_pack()。 用 pack_name_hash()/pack_name_hash_v2()(pack-objects.h#L204-L250)给每个对象算「名称提示哈希」——把路径名的末 16 个非空白字符压成一个可排序整数,让形如 *.c 的对象排在相邻位置、更容易互作 delta base。随后按该哈希排序,切分成若干窗口候选列表。

(3) find_deltas() 已在上节展开。多线程时每个线程处理一部分候选,packing_data_lock/unlock(pack-objects.h#L189-L196)保护对 ODB 的并发访问,cache_lock 保护 delta 缓存。

(4) write_pack_file()(L1333+)。 先写 12 字节占位 pack 头,再按写序逐对象调 write_one()。write_one() 内用 write_reuse_object()(L635)尝试直接从源 pack 复制原始字节(verbatim reuse,避免重新压缩),失败才 write_object() 重新编码。

(5) 两遍写头。 对象数只有写完才知道(可能因 --max-pack-size 截断),所以先用占位头,最后 fixup_pack_header_footer()(pack-write.c#L391-L470)回填 hdr_entries 并追加 pack 校验和(§3.2)。

(6) 生成 idx 与 rev。 write_idx_file()(pack-write.c#L57-L180)接受 pack_idx_entry ** 数组,先按 OID 排序(pack_order_cmp),再写 fanout、OID 表、CRC 表、offset32/64 表、双校验和。write_rev_file()(pack-write.c#L219-L244)调用 write_rev_file_order()(L246-L300),按 offset 排序生成 RIDX。

一个 v2.56 的细节:pack-objects 现在通过 ODB 回调读取对象(odb_read_object,见 L2869)而非直接 read_object_file(),这让它在「对象来自可插拔 source」时也能工作(§13)。

5.2 git index-pack:从 .pack 反向生成 .idx

index-pack 是「只有一个 pack、没有 idx」时的修复/索引工具,也是 fetch/receive-pack 落盘收尾的核心。流程(builtin/index-pack.c):

  1. parse_pack_header()(L381-L396):校验 PACK 魔数、版本 ∈ {2,3},读 hdr_entries。
  2. parse_pack_objects()(L1250-L1319):流式扫描每个对象头,非 delta 当场解压校验,delta 只记录不展开(避免随机访问),把 (oid, offset, crc) 存入条目表。OFS_DELTA/REF_DELTA 的 base 引用被解析成「目标索引」。
  3. resolve_deltas()(L1329-L1362):对 delta 对象做拓扑展开(base 先解压),逐层 patch_delta 重建对象并校验 OID。若 pack 是 thin pack(base 在 pack 外),会把外部 base 追加进 pack(--fix-thin)。
  4. conclude_pack()(L1370):写 idx(write_idx_file),并视选项写 rev。

index-pack 的一个关键设计:它在解包过程中重建每个对象的 CRC32(原始字节的 zlib CRC),因为 CRC 只存在于 .idx 而不在 .pack 里。它必须边读 pack 边算,无法事后补。

5.3 git unpack-objects:把 pack 摊回松散对象

unpack-objects 是「pack → 一堆 loose object」的逆操作,主要用于 fetch 时为小批量对象避免建 pack(unpackLimit 未超时),或从损坏 pack 抢救对象。核心(builtin/unpack-objects.c):

  • fill()(L67-L88)从 stdin 读入缓冲,并同步更新 pack 校验和的哈希上下文;
  • use()(L90-L104)推进读指针、累计 consumed_bytes,并检查 max_input_size(防超大 pack 攻击);
  • get_data()(L117-L158)inflate 出对象或 delta 指令;
  • unpack_all()(L585-L615)循环读对象头、区分 delta/非 delta、对 delta 延迟到 base 就绪后再 patch,最终调 write_object_file() 落成松散对象。

--strict 模式下,可疑可达性的对象只留在内存(obj_decorate 装饰)不落盘,避免把可能有害的对象写进 ODB。

5.4 git repack / git gc / git multi-pack-index

git repack 本身是 pack-objects 的驱动:它先把现有 pack 里的对象 + 松散对象重新喂给 pack-objects,用 -A(--all 剔除松散)/-a(--all)/-d(删旧 pack)/--cruft(保留不可达对象为 cruft pack,并写 .mtimes)等选项控制。git gc 则是 repack + prune + commit-graph + MIDX 的编排。

git multi-pack-index <write|verify|expire|repack>(builtin/multi-pack-index.c):

  • write:调用 write_midx_file() 生成/更新 MIDX(含 --bitmap 时同时写多包位图);
  • verify:调 verify_midx_file() 校验 chunk offset、fanout 单调性、OID 有序、OOFF 的 pack-id 合法;
  • expire:清理 MIDX 里已不存在的 pack 引用(expire_midx);
  • repack:midx_repack() 把「不包含在 preferred pack 里的对象」合并进新 pack,并保持 MIDX 覆盖。

git verify-pack(builtin/verify-pack.c#L22-L29)实际上只是转发给 index-pack --verify:它把 verify-pack 的参数翻译成 index-pack --verify 的命令行,然后 run_command() 执行。因此「校验 pack」的实现只有一份,在 index-pack 里。

流程对照表:

命令输入输出关键函数
pack-objectsOID 列表.pack+.idx(+.rev)find_deltas/write_pack_file/write_idx_file
index-pack.pack.idx(+.rev)parse_pack_objects/resolve_deltas
unpack-objectspack 流loose objectsunpack_all/write_object_file
repack全部对象新 pack,删旧驱动 pack-objects
multi-pack-index多 packMIDX(+bitmap/rev)write_midx_file/verify_midx_file
verify-pack.idx+.pack校验报告转发 index-pack --verify

§6 命令与机制的映射

本节把「用户敲的命令/选项」一一对应到「底层动了哪个字节、走了哪个函数」,这是把前几节的格式知识转化为可操作直觉的桥梁。

6.1 写侧命令

命令 / 选项触发的机制落盘产物关键实现
git pack-objects对象→pack.pack+.idx(+.rev)write_pack_file/write_idx_file
--window=<n>delta 候选窗口大小影响压缩率find_deltas(window=...),默认 10
--depth=<n>delta 链最大深度影响压缩率/读取try_delta(max_depth=...),默认 50
--window-memory=<n>窗口内存上限内存换压缩率find_deltas 中 window_memory_limit 淘汰(L3022-L3028)
--delta-base-offset用 OFS_DELTA 而非 REF_DELTA省 20/32 字节/对象allow_ofs_delta
--reuse-delta / --reuse-object直接复用源 pack 的 delta/对象字节省 CPUwrite_reuse_object
--compression=<n>zlib 级别pack 体积/时间do_compress
--max-pack-size=<n>单 pack 上限拆分多 packwrite_pack_file 截断 + fixup_pack_header_footer
--stdout输出到 stdout(传输用)不落盘pack_to_stdout(L232)
--threads=<n>delta 搜索多线程并行delta_search_threads(L5171)
--thin允许 thin pack(base 在 pack 外)传输更小仅用于 wire,落盘前需 --fix-thin
--path-walk按目录路径分组对象提升相似 blob 的 delta 命中v2.56 起可与 bitmap/delta-islands 并用(§11.9)

6.2 读侧命令

命令 / 选项触发的机制关键实现
git cat-file -p <oid>ODB 查找 + 解压(含展开 delta 链)odb_read_object → unpack_entry
git cat-file --batch批量 read_object_info走 odb_source_packed.read_object_info
git verify-pack -v <idx>转发 index-pack --verifyverify-pack.c#L22-L29
git index-pack <pack>生成 idx/rev、校验parse_pack_objects/resolve_deltas
git unpack-objects < <pack>pack→looseunpack_all
git multi-pack-index write生成 MIDXwrite_midx_file
git multi-pack-index verify校验 MIDXverify_midx_file
git multi-pack-index expire清理失效 pack 引用expire_midx
git multi-pack-index repack合并「非首选 pack」对象midx_repack
git pack-refs非本章但常与 pack 同现—

6.3 配置项映射

配置作用代码位置
core.packedGitWindowSizemmap 单窗大小use_pack 中 packed_git_window_size(L655-L665)
core.packedGitLimitmmap 总量上限use_pack 中 unuse_one_window 循环(L668-L670)
core.deltaBaseCacheLimitdelta base 缓存上限delta_base_cache_limit
core.packReadReverseIndex是否读磁盘 .revpack_read_reverse_index(pack-revindex.c#L311-L312)
pack.threads默认线程数delta_search_threads
pack.windowMemory窗口内存默认值window_memory_limit
pack.writeReverseIndex是否生成 .revwrite_rev_file 调用点
pack.writeBitmapHashCachebitmap 哈希缓存pack-bitmap 写入
midx.versionMIDX 文件版本(默认 2)write_midx_header
gc.cruftPacks是否用 cruft packrepack --cruft
extensions.objectFormatSHA-1/SHA-256决定 hashsz

6.4 一个端到端例子:git gc 做了什么

git gc 在对象存储层依次编排:

  1. git pack-refs --all(与 pack 无关,压缩 refs);
  2. git reflog expire --all(决定可达性);
  3. git repack -d -l(默认):把松散对象与旧 pack 重新打包,-d 删除冗余 pack;
  4. git pack-objects 生成新 pack-*.pack/.idx/.rev;
  5. git prune 或 cruft pack:删除/冻结不可达对象;
  6. git multi-pack-index write(若启用 core.multiPackIndex):为多个 pack 建 MIDX;
  7. git commit-graph write(第 5 章内容)。

每一步都对应前面某个函数;理解这条链后,「gc 之后对象为什么合并了、旧 pack 为什么消失、MIDX 何时重建」就有了确定答案。


§7 边界条件、错误路径与坑位

格式的健壮性体现在「对损坏输入的响应」。本节把 pack 侧的护栏逐条列出,并标注坑位。

7.1 idx 加载时的护栏(load_idx)

load_idx()(packfile.c#L105-L183)在信任任何指针前做全套尺寸校验:

检查失败信息
idx_size < 4*256 + 2*hashszindex file %s is too small(L113)
magic 存在但 version < 2 || version > 2index file %s is version %d, which is not supported
fanout 非单调non-monotonic index %s(L134)
v1 尺寸公式不符wrong index v1 file size
v2 尺寸在 32 位 off_t 下超限pack too large for current definition of off_t

坑位 1:代码只接受 version == 2。pack.h 里 struct pack_idx_header 的注释提到 idx 版本可达 3,但 Git 从未写 v3 idx;文件头的 4 字节版本字段因此是个「预留但未启用」的扩展点。误读头文件注释会以为存在 v3 idx。

坑位 2:v1 的 fanout[255] 与 v2 的 fanout[255] 都是总对象数 num_objects,但如果文件被截断,num_objects 可能大于实际能容纳的条目数——这正是尺寸公式校验存在的意义。

7.2 偏移访问护栏(use_pack / check_pack_index_ptr)

  • use_pack()(packfile.c#L620-L635)拒绝 offset > pack_size - hashsz → offset beyond end of packfile (truncated pack?),以及 offset < 0 → offset before end of packfile (broken .idx?)。为什么留 hashsz 余量?因为对象头与 delta base 解析要保证「偏移处至少还有一个完整哈希长度可用」(in_window 的注释,L609-L617)。
  • check_pack_index_ptr()(packfile.c#L1770-L1782)对大偏移表指针做边界断言:p >= start 且 p < start + size - 8,否则 die("offset beyond end of pack index ... (truncated index?)")。只有变长的大偏移段需要它——定长段在校验尺寸时已核算。

坑位 3:32 位平台(sizeof(off_t) <= 4)读不了 offset ≥ 2^31 的 pack;load_idx 会在加载时直接报「pack too large」。这不是 bug 而是平台限制,交叉编译 32 位 Git 访问大仓库必须注意。

7.3 pack 签名与索引一致性

open_packed_git_1() 会校验:

  • .idx 开头 4 字节既不是 PACK_IDX_SIGNATURE 也不是合法的 fanout → packfile %s signature is unavailable(L591);
  • .idx 尾部两个校验和中的**第一个(pack checksum)**必须与 .pack 尾部校验和一致 → 否则 packfile %s does not match index(L592-L594)。

坑位 4:.idx 尾部有两个校验和:先 pack checksum、后 idx checksum。二者顺序固定,混淆会导致「明明 pack 没问题却报 does not match index」。

7.4 delta base 损坏与 bad_objects

展开 delta 链时(unpack_entry,packfile.c#L1613-L1670),若 base 数据缺失,Git 不立即崩溃,而是:

  1. 用 offset_to_pack_pos() 定位当前偏移的索引位次,取回 base OID;
  2. mark_bad_packed_object(p, &base_oid) 把该 OID 记入 p->bad_objects;
  3. 尝试 odb_read_object_info_extended() 从别的 pack 或 loose 重新读取 base(L1651-L1653)。

这是「损坏局部化」设计:单个坏对象不拖垮整仓库,bad_objects 让后续查找直接跳过该 pack 里的这个对象。

坑位 5:use_pack() 若 mmap 到的窗口越界会 die()(L634-L635),这是不可恢复的——因为此时连「对象头」都读不出来。区分「die(致命)」与「error(可跳过)」是读源码时必须注意的语义差异。CRC 校验失败则是 error("bad packed object CRC for %s")(L1553),属可继续。

7.5 .keep 文件与删除竞态

unlink_pack_path()(packfile.c#L368-L394)删除 pack 族时:

static const char * const suffixes[] = { ".idx", ".pack", ".rev", ".keep",
					 ".bitmap", ".promisor", ".mtimes" };

若存在 <basename>.keep 且 force_delete == 0,则整体不动。这就是 gc 不会删掉「正在被其他进程/传输引用」的 pack 的机制。

坑位 6:repack 删除旧 pack → 但若有进程正 mmap 它,POSIX 下 unlink 是安全的(inode 延迟释放),Windows 下则可能失败——这是 Git 跨平台差异的经典来源,也是 v2.56 中并发 repack 需要额外恢复逻辑的原因(§11)。

7.6 并发与 MIDX

MIDX 的并发写入通过「临时文件 + rename」保证原子性:写 <hash>.midx.tmp(或 multi-pack-index-*.tmp)再 rename。但「MIDX 引用的 pack 被并发删除」是已知风险,v2.56 引入 midx_repack 的恢复路径:检测到 MIDX 与磁盘 pack 不一致时重建,而不是报错退出。

坑位 7:multi-pack-index verify 与 write 之间没有全局锁;两个并发 write 可能互相覆盖。实践中靠上层 gc 的单实例语义(gc.pid)而非文件锁避免。

7.7 OID 冲突与哈希算法不匹配

  • MIDX 的 hash_id 与仓库哈希算法不一致 → 加载时忽略并告警(规范要求),而不是报错。这避免了「SHA-1 仓库误读 SHA-256 MIDX 而把 OID 长度算错」。
  • .rev 的 hash_id 非法(非 1/2)→ unsupported hash id,报错拒绝(pack-revindex.c#L256-L260)。两者策略不同:MIDX 是可选优化可以丢弃,.rev 是显式请求的加速文件、错配意味着配置错误。

坑位 8:extensions.objectFormat 一旦设为 sha256,所有 pack 的 hashsz 从 20 变 32:.idx 的文件尺寸公式、.rev 的 hash_id、MIDX 的 hash_id、loose 路径长度全部随之变化。混用两种格式的对象在同一仓库是被明令禁止的(哈希兼容层靠 compatObjectFormat 的 LMAP 而非同仓混存)。

7.8 空 pack 与 --non-empty

pack-objects 可能产出「0 对象 pack」;--non-empty 使其在无对象时报错而非生成空文件。空 pack 的 .idx 仍有完整的 256 项 fanout(全 0)、空 OID 表与双校验和——尺寸公式能容纳它,load_idx 不会误判为损坏。

坑位 9:nobjects 为 0 时,pack 头是 PACK+2+0,trailer 是「头 + 空载荷」的哈希。write_pack_header() 对 0 对象完全合法;但若用户手工截断了一个「本该有对象」的 pack 到只剩头,index-pack 会给出一致性错误而非静默接受。

7.9 delta 编码的边界

  • OFS_DELTA 的距离编码要求 base 偏移 < 本对象偏移,且解码后 base_offset > 0(packfile.c#L128-L129):if (base_offset <= 0 || base_offset >= delta_obj_offset) return 0;。构造「指向自己或之后」的 base 会被解析器拒绝。
  • delta 头 varint 的 size 用 st_left_shift 做溢出检查,防止恶意超大 size 触发越界分配。
  • copy 指令 size == 0 → 0x10000 是隐式 64KiB,构造时若真要拷贝 0 字节则不可能(规范不允许 size=0 表达「空」),必须用 add 指令。

坑位 10:手工解析 delta 时最容易错的是「把 copy 的 size 位图当成连续字节」——实际每个参数字节的存在与否由位图决定,且位权固定。任何「跳过缺失字节后重新编号」的实现都会解析错误。

7.10 小结:护栏的分类

类别行为例子
尺寸/单调性error 可恢复non-monotonic index、too small
指针越界(读 pack)die 致命offset beyond end of packfile
指针越界(大偏移表)die 致命offset beyond end of pack index
局部坏对象error + mark_bad + 回退其它 sourcedelta base 缺失
哈希算法不匹配(可选文件)忽略 + 告警MIDX hash_id 不符
哈希算法不匹配(显式文件)error 拒绝.rev hash_id 非法

这条「可选优化可丢弃、必需索引必须严」的原则贯穿整个对象存储层,是 Git 在「性能文件」与「正确性文件」之间划出的清晰边界。

§8 性能特征

对象存储的性能由四层预算叠加决定:地址空间(mmap 窗口)、delta 展开缓存、跨 pack 复用、写侧并行与压缩级别。逐层拆解。

8.1 mmap 窗口与 LRU

pack 文件不做整文件一次性 mmap,而是按 use_pack() 的请求按需开窗(packfile.c#L620-L680):

  • 窗口大小 packed_git_window_size 的默认值:有 mmap 时 64 位平台 1 GiB、32 位平台 32 MiB;无 mmap(NO_MMAP)时 1 MiB(git-compat-util.h#L570-L591)。
  • 全局 mmap 总量 packed_git_limit 默认 64 位 32 GiB、32 位 256 MiB(DEFAULT_PACKED_GIT_LIMIT)。
  • 每次 use_pack() 若目标偏移不在当前窗口,就先退掉旧窗(win->inuse_cnt--),再查链表里是否有覆盖该偏移的窗口(in_window()),没有则 CALLOC_ARRAY 新建;新窗的 offset 会被对齐到 window_size/2(L655-L661),保证「窗口覆盖的哈希余量」成立。
  • 内存超预算时循环调用 unuse_one_window()(L668-L670),它遍历所有 source 的所有 pack,找 inuse_cnt == 0 且 last_used 最小的窗口释放(L274-L299)。

设计要点:窗口只在被换出时才 munmap,正在被引用的窗口(inuse_cnt > 0)绝不释放——这保证任何已交给调用方的指针在调用期间始终有效。

8.2 delta base 缓存

展开一条深度 d 的 delta 链要 inflate + patch d+1 次。delta_base_cache(§2.7)把「最近展开过的 base 完整数据」按 {pack, base_offset} 键缓存,容量由 core.deltaBaseCacheLimit 控制(默认 DEFAULT_DELTA_BASE_CACHE_LIMIT = 96 MiB,pack-objects.h#L12)。超过上限时 add_delta_base_cache() / evict_from_delta_base_cache() 从 LRU 淘汰。

为什么按「base offset」而不是「最终 OID」缓存? 因为展开过程是自底向上:读了 A 的 delta,需要 B 的完整数据;缓存 B 能同时服务「读 A」「读 C(也以 B 为 base)」「读 D(在 B 之上)」。以 offset 为键使缓存天然复用。

性能影响:对「全仓库遍历」(如 git log --objects、fsck),缓存命中率极高,因为同一条 delta 链会被反复经过。对「随机单对象读取」(cat-file),缓存往往冷启动,此时 depth 越大越慢——这就是 --depth 需在压缩率与随机读之间取舍的原因。

8.3 pack 复用(verbatim reuse)

write_reuse_object()(L635)在重新打包时,若目标对象已在某个 pack 中、且其字节可原样搬运,就直接从源 pack 复制原始字节,跳过「解压→重新压缩」。这在新 pack 与旧 pack 高度重叠(增量 fetch、频繁 gc)时能把写侧 CPU 从「压缩主导」降为「内存拷贝主导」。

复用的两个前置条件(规范 BTMP 一节):

  • 复用的字节块只包含被请求的对象(不能夹带调用方没要的对象);
  • 非 thin pack 里,作为 delta 存储的对象必须连同其 base 一起进入新 pack。

--reuse-object 放宽到可复用整个对象(而不仅是 delta);多包复用的粒度由 MIDX 的 BTMP chunk 描述(§3.6.5):每个 pack 的 bitmap_pos/bitmap_nr 让它能被当作「整体」复用。

8.4 位图与多包复用

可达性位图(pack-bitmap,第 5 章)让 git rev-list 类操作从「逐对象遍历」变成「位运算 + 少量修正」。对对象存储而言,位图的关键作用是驱动 pack 复用:知道了「哪些对象调用方要」,就能判定「源 pack 的哪段字节可以整体搬运」。MIDX 的多包位图把这一能力从「单 pack」扩展到「多 pack 集合」,BTMP 给出每个 pack 对应的位区间。

8.5 写侧:并行、压缩与窗口

  • 多线程 delta 搜索:--threads 让多个线程并行跑 find_deltas(),用 packing_data_lock/unlock 保护 ODB 访问、cache_lock 保护 delta 缓存。默认线程数受 pack.threads 与核数影响;并行度提升的是「找 delta」的吞吐,不是「写盘」。
  • --window / --depth / --window-memory 三角:窗口决定候选数量(时间),深度决定链长(压缩率与读速),window-memory 决定内存上限(超过则提前淘汰窗口尾部,L3022-L3028)。git gc 默认 window=10/depth=50,是「通用仓库」的经验折中。
  • 压缩级别:--compression=<0..9> 传给 do_compress() 的 zlib 级别。级别 0(不压缩)显著加快写但 pack 巨大;级别 9 反之。默认取 core.compression(通常 1,偏向速度)。
  • 压缩的并行化:find_deltas() 在非 --stdout 时会当场压缩已决定的 delta 并缓存(L3079-L3093),把压缩工作分摊进多线程阶段;而 --stdout(传输)故意不这么做,因为网络会限速,早压缩不如晚压缩。缓存后的压缩 delta 用 z_delta_size 记录大小(若超过 1 << OE_Z_DELTA_BITS 则放弃缓存)。

8.6 MIDX 对查找的加速

无 MIDX:查一个 OID 要逐个 pack 调 find_pack_entry_one()(packfile.c#L1804-L1818),每 pack 一次 fanout + 二分;pack 越多越慢,且每次都要 touch 每个 pack 的 .idx 前 1KiB(fanout),对冷缓存不友好。

有 MIDX:一次 fanout + 二分定位到 (pack_id, offset),再只打开那一个 pack。bsearch_midx()(midx.c#L533-L540)先沿增量链下沉(bsearch_one_midx(),L521-L531),再查该层。代价:MIDX 写一次要合并所有 pack 并排序(O(M log M)),所以它适合「pack 数量多、查询频繁」的仓库(如 CI 上的大 monorepo 镜像)。

8.7 冷热路径概览

操作冷缓存热缓存主导成本
cat-file -p <oid>打开 idx + 解压链直接命中inflate + patch(mmap 页缺失)
全历史遍历建 revindex/位图位图加速delta 展开 + 缓存命中率
repack -a -d读全部对象 + 压缩复用减少压缩排序 + 压缩
multi-pack-index write合并排序增量写O(M log M) 排序
verify-pack全量索引校验—解压每个 delta

8.8 与磁盘/内存的互动

  • 随机读 vs 顺序读:.idx 二分是随机读;.pack 对象解压常是顺序读(同一窗口内)。因此 pack 对象读取倾向「大窗口 + 顺序」。
  • mmap 的页错误成本:首次触及窗口内某页触发缺页中断。对大仓库,core.packedGitWindowSize 调大能减少窗口切换与页错误,但增大常驻内存。
  • inode 与目录规模:松散对象每对象一文件,几十万对象会产生大量 inode 与目录项——这是「为什么必须 repack」的根本原因,git gc 的 --auto 阈值(松散对象超过 gc.auto)正是为此设置。

§9 与其他子系统的交互

对象存储不是孤岛。它向上支撑几乎所有 Git 功能,向下被多种格式约定约束。本节列出关键接口面。

9.1 与 commit-graph / bitmap 的交互

  • commit-graph(第 5 章)存「commit 的父链、时间、树 OID、代际数」,避免反复解析 commit 对象。它本身不是对象,但依赖 pack 里的 commit 对象存在;git commit-graph write 会从 ODB 读 commit。
  • reachability bitmap(第 5 章)把「可达集合」压成位图,位序与 pack 的 pseudo-pack 顺序对应。单 pack 位图的位序来自该 pack 的 pack order;多包位图的位序来自 MIDX 的 RIDX 伪 pack 顺序(§3.6.5)。位图与 .rev 是同一套「按偏移/伪 pack 排序」思想的两个应用;位图的磁盘格式细节见 Documentation/technical/bitmap-format.adoc。
  • 位图驱动 pack 复用(§8.3):pack-objects 在写新 pack 前读 bitmap 判定「哪些对象要、哪些源字节可整体复用」。

9.2 与传输协议(fetch/push)的交互

  • upload-pack 在服务端调 pack-objects --stdout(或直接走 pack-objects 的传输路径)生成 pack 流;fetch-pack 在客户端用 index-pack(或 unpack-objects)落盘。
  • thin pack:传输时允许 delta base 不在 pack 内(因为对方已有),用 REF_DELTA 表达。落盘前必须 index-pack --fix-thin 把外部 base 补进 pack,否则违反「落盘 pack 自包含」的不变量(§3.3.2)。
  • --delta-base-offset 在传输中通常关闭(用 REF_DELTA),因为接收端能按 OID 找 base、不必依赖位置;落盘 pack 才用 OFS_DELTA 省字节。
  • packfile-uris:promisor / partial clone 里,某些对象的字节根本不在本地 pack 中,由 pack-*.promisor 标记,按需向远端取。

9.3 与 shallow / alternate / promisor

  • shallow clone:.git/shallow 记录「截断点」,这些 commit 的对象可能确实不在本地;读取时要处理「对象缺失」为正常。
  • alternate ODB:objects/info/alternates 指向别的仓库的对象目录,odb_source_files 会把多个 source 链起来;pack_local 位区分「本地 pack / alternate pack」。坑位:alternate 的 pack 被删除会让本仓库出现悬空引用。
  • promisor pack:pack-*.promisor 存在表示该 pack 是部分克隆的一部分;缺失对象可延迟获取。

9.4 与 index / read-cache 的交互

索引(第 6 章 read-cache)存「路径 → OID + stat」,本身不含对象字节;读工作树内容时按 OID 去 ODB 取对象。因此对象存储的读延迟直接影响 git status/git diff(当需要读 blob 时)。索引的 git update-index --index-info、git add 最终都调用 write_object_file(写松散对象)或 index-pack。

9.5 与 refs / 可达性 / fsck

  • git fsck 遍历所有 source(odb_source_packed_fsck,odb/source-packed.c#L984-L1003)做连通性与格式校验:检查对象头、校验和、delta 链、OID 重算。v2.56 把 fsck 变成 ODB source 的可插拔回调,因此 git fsck 不再硬编码「先看 loose 再看 pack」。
  • git prune / cruft pack 决定「哪些不可达对象可删/可冻」;cruft pack 用 .mtimes(§3.5.3)记住年龄,gc.cruftPacks 控制是否启用。

9.6 与 ODB 抽象层(odb_source)

v2.56 的 struct odb_source(§1.4)定义了对象存储的统一 vtable。三类实现:

source文件职责
odb_source_filesodb/source-files.c组合层:管理 alternates、写新对象、按前缀遍历
odb_source_looseodb/source-loose.c松散对象读写、缓存 quick_has_loose(L61)
odb_source_packedodb/source-packed.cpack/MIDX 装载、查找、fsck、按需打开

打包 source 的关键函数:

  • find_pack_entry()(odb/source-packed.c#L22-L76):先查 MIDX,再回退逐 pack 查;
  • sort_pack()(L805-L830):按 mtime/MRU 排序 pack,影响「先查哪个」;
  • odb_source_packed_for_each_prefixed_object()(L338):按 OID 前缀批量枚举。

这层抽象的意义:对象存储的实现细节(loose/pack/MIDX)被隔离在 source 内部,上层(cat-file、fsck、pack-objects)只面对 read_object_info/write_object 等稳定接口。第 8 章会展开「如何写一个自定义 ODB source」。

9.7 交互全景图

                    ┌──────────────────────────────┐
   gc/repack ──────▶│  pack-objects (写 pack/idx/rev)│
                    └──────────────┬───────────────┘
                                   │ 产出
                                   ▼
   index-pack ◀── fetch ─── .pack 文件 ───▶ upload-pack (读)
        │ 生成                              ▲
        ▼                                    │
   .idx / .rev   ◀── odb_source_packed ──────┘
        │                  │
        │                  ├── MIDX (多包统一索引 + 位图)
        │                  └── bitmap (可达性)
        ▼
   cat-file / fsck / rev-list / log  (上层消费者)

一句话:pack-objects 与 index-pack 是「写入者」,odb_source_packed 是「读取者」,MIDX/bitmap/rev 是「加速器」,而上层命令只是消费者。 理解谁是写入者、谁是加速器,就能在出问题时快速定位该查哪个文件、哪个函数。

§10 历史演进:格式为什么长成今天这样

格式不是一次性设计出来的,每一次演进都对应一个具体的工程痛点。按时间线梳理(部分小版本号以官方 release notes 为准,此处给更准确的机制对应)。

10.1 从松动对象到 pack(2005)

Git 早期只有松散对象:每对象一文件、zlib 压缩。它简单可靠,但传输一个完整仓库要搬运海量小文件、且几乎无跨对象压缩。Linus 与 Junio 很快引入 pack:把对象连续排列、跨对象做 delta。最初的 delta 只有 REF_DELTA(写 base 的 OID),因为实现最简单。

10.2 OFS_DELTA 的诞生

REF_DELTA 的坏处是每个 delta 要额外写 20/32 字节的 base OID。Nicolas Pitre 引入 OFS_DELTA:用「负相对偏移」替代 OID,省下这 20 字节——这正是 §3.3.2 那条「每级 continuation 隐含 +1」古怪编码的来历(它由一个先写末字节、再向前回填的循环生成)。--delta-base-offset 至今仍是写侧可选项:落盘 pack 倾向用 OFS_DELTA 省字节,wire 传输倾向用 REF_DELTA 因为接收端按 OID 找 base 更自然。

10.3 idx v1 → v2:突破 4 GiB 与缓存足迹

.idx v1 的每条记录把 offset(4) + oid(20) 交错存放,且 offset 只有 32 位。两个问题在大仓库上暴露:

  1. 超过 4 GiB 的 pack 无法寻址;
  2. 二分查找每次比较要跨步 24 字节,cache line 利用率低。

v2 用 \377tOc magic 与「OID/CRC/offset 分表」解决两者:OID 表紧凑排列让二分的比较成本降到最小;offset 表用 MSB 转义支持 64 位。magic 被刻意选成「不可能的 fanout[0]」,保证旧 Git 读到 v2 idx 会报错而不是静默误读(§2.3)。

10.4 反向索引:从内存到磁盘

「按偏移排序」的视图早期只在内存构建(pack_revindex)。但每次新进程都要重建 O(N log N)(后来用基数排序降到 O(N)),对大仓库仍是启动开销。于是有了落盘 .rev(RIDX 格式,§3.5.2):一次构建、后续 mmap。hash_id 字段是后来补上的,用来让 SHA-256 仓库正确识别。core.packReadReverseIndex 控制「读磁盘版还是现算」(pack-revindex.c#L311-L312)。

10.5 MIDX(约 2018 年,v2.19)与 chunk 化

多 pack 场景(alternate、增量 gc、几何 repack)下,逐个 pack 查询代价高。Derrick Stolee 引入 multi-pack-index:把所有 pack 的对象合并成一张全局 OID 表,并去重(设计文档 Documentation/technical/multi-pack-index.adoc)。为了让扩展(反向索引、位图)能后加而不破坏向后兼容,MIDX 采用 chunk 化布局(chunk lookup 表 + 各 chunk),这套思路后来被广泛复用。

MIDX 之后的关键扩展:

  • 多包反向索引(RIDX chunk,§3.6.5):为多包位图提供「位次 → 伪 pack 位置」;
  • 多包位图(multi-pack-index-*.bitmap):把可达性位图从单 pack 扩展到 pack 集合;
  • BTMP chunk:记录每个 pack 的位区间,支撑多包 verbatim 复用;
  • 增量 MIDX 链(BASE chunk):允许分批写入与并发 repack,避免「每次重建整个 MIDX」的 O(M log M)。

10.6 cruft pack 与 .mtimes(约 2022 年,v2.37)

传统 git prune 直接删除不可达对象。但「刚产生的不可达对象」可能是即将被引用的(例如正在进行的 rebase/am),立刻删除会丢数据。cruft pack 把这些不可达对象打包保留,并用 .mtimes(§3.5.3)记住每个对象的 mtime,等它们「足够老」再删。gc.cruftPacks 与 repack --cruft 控制启用。

10.7 packfile-uris 与 partial clone

大文件仓库里,与其把巨型 blob 塞进 pack,不如让客户端按需从 URI 下载。packfile-uris / promisor / partial clone 引入 pack-*.promisor 标记与「缺失对象延迟获取」机制(规范见 Documentation/technical/packfile-uri.adoc 与 Documentation/technical/partial-clone.adoc)。这是「对象存储与网络边界」的一次重新划分,也让 §9.3 的 promisor 交互成为必需。

10.8 --path-walk 的动机

delta 的命中率高度依赖「哪些对象被放进同一个窗口」。传统做法按 pack_name_hash 排序(末段路径相似的对象相邻)。--path-walk 改为按目录树逐条路径遍历,让同一目录下的相似 blob(例如同一组件的历史版本)自然聚在一起,提升压缩率。v2.56 让它能与 bitmap、delta-islands 并存(§11)。

演进的共同规律:每一次都是「先有一个正确的朴素实现,再用一个新的辅助文件/表项把某个 O(N) 或 O(N log N) 的操作变成 O(log N) 或 O(1)」——.rev 之于 offset↔pos、MIDX 之于多包查找、位图之于可达性、BTMP 之于复用。辅助文件都可丢弃重建(除 .pack/.idx 外皆可选),这正是 §7 那条「可选优化可丢弃」原则的历史根源。


§11 v2.56 的具体变化

v2.56 在对象存储层做了大量重构,主线是「把对象存储彻底抽象成可插拔的 ODB source」。以下逐条对应 Documentation/RelNotes/2.56.0.adoc。

11.1 packed source 结构体化

「The packed object source has been refactored into a proper struct」(RelNotes#L188-L189)。以前 pack 的查找/枚举散落在 packfile.c 里、被上层直接调用;现在收敛为 odb_source_packed(odb/source-packed.c),实现 §9.6 的 vtable。这解释了为什么本章大量引用 odb/source-packed.c 而非 packfile.c——职责划分变了。

11.2 loose 写入迁出 object-file.c

「The logic to write loose objects has been refactored and moved from object-file.c to the loose backend source file odb/source-loose.c」(RelNotes#L364-L367)。write_loose_object()/start_loose_object_common() 现在住在 odb/source-loose.c(§3.1 引用的行号正来自这里)。object-file.c 只保留 fill_loose_path() 之类的路径工具。

11.3 packfile 列表快路径

「The performance of adding numerous new packfiles has been improved by introducing a fast path for known-new packfiles to skip an unnecessary traversal in packfile_list_append()」(RelNotes#L461-L463)。当一次操作新增大量 pack(如 fetch 拿到很多小 pack)时,跳过重复扫描是显著优化。

11.4 reprepare() 回调与 ODB 初始化延迟

「The reprepare() callback for object database sources has been added ... pre-opening object database sources」(RelNotes#L218-L221);同时「the initialization of the object database has been deferred」(RelNotes#L433-L441)。这让「还没真正读对象」的命令不必过早打开 pack/loose。

11.5 object_info 接口重塑

「The whence field in struct object_info has been removed. The backend-specific object information retrieval has been refactored into an opt-in struct object_info_source structure」(RelNotes#L223-L225)。以前 whence 用枚举硬编码「对象来自 loose / pack / alternate」,现在由 source 自行填充 object_info_source。这是抽象化的关键一步。

11.6 缺失 vs 损坏的区分

「The object database (odb) API has been refactored to distinguish between missing objects and corrupt ones by returning more descriptive error statuses. Both the packed and loose backends now ...」(RelNotes#L475-L482)。这直接影响 §7.4:以前「读不到」只有一种错误码,现在能区分「确实没有」与「有但坏了」,从而决定「回退其它 source」还是「报损坏」。

11.7 可插拔 fsck

「The consistency checks for the object database (fsck) have been [refactored] ... backend-specific object source layers, making them pluggable」(RelNotes#L558-L561)。git fsck 现在遍历各 source 的 fsck 回调(odb_source_packed_fsck(),§9.5),不再硬编码遍历逻辑。

11.8 packfile 生成可插拔

「The mechanism to generate a packfile corresponding to the result of a fetch/push has been made pluggable through a set of object [source callbacks], ... enabling alternative ODBs to serve packfiles」(RelNotes#L496-L502)。配合 §5.1 的 odb_read_object 调用,pack-objects 现在能从任意 source 读对象。

11.9 --path-walk 与 bitmap/delta-islands 并存

「pack-objects ... support reachability bitmaps and delta-islands concurrently with the --path-walk option, allowing faster packaging by falling back to path-walk when bitmaps [don't help]」(RelNotes#L203-L205)。这解释了 packing_region(§2.6):对象数组被切成可独立处理的区段,使 path-walk 的分组与 bitmap 的整体复用互不冲突。

11.10 并发 repack 恢复

「The object lookup machinery has been taught to gracefully recover when a multi-pack-index points to an owning pack that was removed during a concurrent geometric repack」(RelNotes#L525-L528)。这是 §7.6 那条「MIDX 引用失效」风险的实际修复:不再报错退出,而是重建/回退。

11.11 trace2 记录 pack 字节数

「The pack-objects command has been updated to record the total bytes written to pack files in trace2 output」(RelNotes#L502-L505)。这让「为什么这次 gc 产出的 pack 变大」这类问题可被 trace2 数据直接回答,而不必手工比对文件尺寸。

11.12 大对象哈希与 size_t 转换

「Support for hashing loose or packed objects larger than 4GB on Windows and other LLP64 platforms has been improved by converting object header [sizes via size_t]」(RelNotes#L577-L580)。LLP64(Windows)上 long 是 32 位,超过 4GiB 的对象尺寸会被截断——本章 §3.3 的对象头 size 就受此影响,v2.56 统一用 size_t 处理。

11.13 receive-pack 走 ODB 事务

「git receive-pack has been refactored to use ODB transaction interface for writing incoming packfiles」(RelNotes#L285-L287)。把「接收 pack → 落盘」纳入 ODB 事务,向「后端无关」靠近。

11.14 变化总览表

变化影响的本章内容具体位置
packed source 结构体化§1.4、§9.6odb/source-packed.c
loose 写入迁出§3.1、§5.1odb/source-loose.c
reprepare() 回调§11.4ODB source vtable
whence 移除 / object_info_source§11.5object_info
缺失 vs 损坏§7.4、§11.6ODB 错误码
可插拔 fsck§9.5odb_source_*_fsck
packfile 生成可插拔§5.1、§11.8source 回调
--path-walk 并存§4.5、§11.9packing_region
并发 repack 恢复§7.6、§11.10MIDX 查找
trace2 pack 字节§8、§11.11trace2 事件
size_t 大对象§3.3、§11.12对象头长度
receive-pack 事务§9.2、§11.13ODB transaction

一句话概括 v2.56:它没有改变任何一个磁盘格式的字节布局(.pack/.idx/.rev/MIDX 全部向后兼容),而是把「谁负责读、谁负责写、谁负责校验」从分散的函数调用收拢为 ODB source 的接口。这是典型的「重构优先、格式稳定」的工程决策——对用户透明,对后续「支持新存储后端」则是地基。

§12 可复制实操实验

本节所有实验都在一个临时仓库里跑,命令与预期输出可直接复制验证。实验目标是「用眼睛看到 §3 的字节」。

12.1 实验一:亲手造一个松散对象并观察字节

git init -q lab && cd lab
printf 'abc' | git hash-object -w --stdin
# 输出:f2ba8f84ab5c1bce84a7b441cb1959cfc7093b7f  (SHA-1 仓库)
OID=f2ba8f84ab5c1bce84a7b441cb1959cfc7093b7f
xxd .git/objects/${OID:0:2}/${OID:2}

预期:文件以 78(zlib 头)开头,看不到明文的 blob 3\0abc——因为前缀与 payload 一起被压缩了。用 git cat-file blob $OID 能看到 abc,说明解压后是 blob 3\0abc。

验证「OID 是未压缩串的哈希」:

printf 'blob 3\0abc' | sha1sum
# 应等于 f2ba8f84ab5c1bce84a7b441cb1959cfc7093b7f

12.2 实验二:观察 .pack 头部与对象头

git add -A && git commit -qm x
git repack -ad -q            # 造出一个 pack
ls .git/objects/pack/        # pack-<hash>.pack / .idx / (可选 .rev)
PACK=$(echo .git/objects/pack/*.pack)
xxd -l 12 "$PACK"

预期前 12 字节:

00000000: 5041 434b 0000 0002 0000 000X   PACK........

即 PACK + 版本 2 + 对象数。接着看 trailer:

SIZE=$(stat -c %s "$PACK")       # 或 wc -c
tail -c 20 "$PACK" | xxd         # 最后 20 字节 = pack 校验和

断言:tail -c 20 的字节,应当与 sha1sum 前 20 字节的「除最后 20 字节外全部内容」一致。

12.3 实验三:解出第一个对象的对象头

用 git verify-pack -v 拿到每个对象的类型、大小、偏移:

IDX=$(echo .git/objects/pack/*.idx)
git verify-pack -v "$IDX" | head -20

输出形如 <oid> <type> <size> <size-in-pack> <offset-in-pack> [<depth> <base-oid>]。取第一个非 delta 对象的 offset,然后:

OFF=12   # 假设第一个对象偏移为 12(紧跟 pack 头)
xxd -s $OFF -l 4 "$PACK"

对照 §3.3:首字节高 4 位是类型(1=commit,2=tree,3=blob,4=tag),低 4 位是 size 低位。若该对象是 blob,首字节应是 0x30|size。

12.4 实验四:验证 OFS_DELTA 的负偏移

对含 delta 的 pack:

git verify-pack -v "$IDX" | awk 'NF>=7 {print}'   # 有 depth 与 base 的行即 delta

取一个 delta 对象的偏移 off_d 与它 base 的偏移 off_b,ofs = off_d - off_b。然后手工按 §3.3.2 的编码器公式推出字节序列,并用 xxd 在同一位置核对。用一个 ofs=256 的样例,编码应为 81 00(见 §3.3.2 的推导),而不是想当然的 80 80 00。

12.5 实验五:.idx v2 头部与 fanout

xxd -l 16 "$IDX"

预期:ff74 4f63 0000 0002(magic + version 2),随后是 256 项 fanout。用下面的方式验证 fanout 单调:

python3 - "$IDX" <<'PY'
import sys,struct
d=open(sys.argv[1],'rb').read()
assert d[:4]==b'\xfftOc' and struct.unpack('>I',d[4:8])[0]==2
fo=[struct.unpack('>I',d[8+4*i:12+4*i])[0] for i in range(256)]
assert all(fo[i]<=fo[i+1] for i in range(255)), "fanout not monotonic"
print("num_objects =", fo[255])
PY

12.6 实验六:.rev 头部与磁盘反向索引

git update-index --refresh >/dev/null 2>&1 || true
# 若仓库设置 pack.writeReverseIndex=1(现代默认),repack 会生成 .rev
ls .git/objects/pack/*.rev 2>/dev/null && xxd -l 12 .git/objects/pack/*.rev

预期:5249 4458 0000 0001 0000 0001(RIDX、version 1、hash_id 1=SHA-1)。若没有 .rev,用 git config pack.writeReverseIndex true && git repack -ad 生成。

12.7 实验七:生成并检查 MIDX

# 造两个 pack(先全部打包,再产生新松散对象并再打一个)
git repack -ad -q
git multi-pack-index write
ls .git/objects/pack/multi-pack-index
xxd -l 12 .git/objects/pack/multi-pack-index

预期:4d49 4458 02 01 XX XX——即 MIDX、版本 2、hash_id 1、num_chunks、num_base_midx、num_packs。再用 git multi-pack-index verify 确认 chunk offset 与 fanout 自洽。

12.8 实验八:感受 window/depth 对体积的影响

for d in 1 10 50; do
  git repack -ad -q --depth=$d
  printf 'depth=%s  size=%s\n' "$d" "$(stat -c %s .git/objects/pack/*.pack)"
done

预期:depth 增大,pack 变小(压缩率提升),但同时 git cat-file 展开更深链的延迟上升。这直观展示 §4.4 的「压缩率 ↔ 读取」权衡。

12.9 实验九:观察 repack 复用

git repack -ad -q          # 第一次:全部重新压缩
time git repack -a -d -q   # 第二次:大量对象可 verbatim 复用

第二次应明显更快(--reuse-object/--reuse-delta 路径命中),因为源对象的字节可直接搬运(§8.3)。

12.10 实验十:损坏检测

cp "$PACK" bad.pack
# 翻转表中的一个字节
printf '\x00' | dd of=bad.pack bs=1 seek=100 conv=notrunc
git index-pack --verify bad.pack; echo "exit=$?"

预期:非零退出,并报出「CRC/hash 不匹配」或索引尺寸错误。这验证 §7 的护栏。


§13 扩展接口:如何接入新的对象存储

v2.56 最大的结构性变化,是把对象存储变成可插拔。理解这套接口,就能理解「未来 Git 可能支持哪些存储后端」。

13.1 struct odb_source 的职责

一个 ODB source 是一组回调(vtable),上层只通过这些回调访问对象。核心回调(概念名,具体字段见 odb.h / odb/source-*.c):

回调语义
read_object_info只取对象的类型/大小,不读内容(--batch-check 用)
read_object_stream流式读对象内容(大对象不一次性进内存)
for_each_object枚举对象(支持过滤器,§11)
count_objects对象计数
write_object写入新对象
freshen_object刷新 mtime(避免被 prune)
fsck自检
reprepare重新准备(§11.4)

odb_source_files 负责组合:它管理 alternates 列表、把「写」委托给 loose、把「读」按 loose→packed 顺序尝试。

13.2 三个现成实现作为模板

  • odb_source_loose(odb/source-loose.c):最小实现,展示了「每对象一文件 + zlib + 临时文件 rename」的写路径(start_loose_object_common/write_loose_object);
  • odb_source_packed(odb/source-packed.c):最复杂的读路径,包含 MIDX 装载、pack 排序(sort_pack)、按前缀枚举;
  • odb_source_files(odb/source-files.c):组合与 alternates。

新增后端(例如「对象存在 S3」「对象存在 SQLite」)只需实现这些回调,即可让 cat-file/fsck/pack-objects 在其上工作——这正是「packfile 生成可插拔」(§11.8)背后的目的。

13.3 其他扩展点

  • packfile-uris:协议层扩展,让客户端从外部 URI 取 pack(§10.7、§9.2)。
  • object_info_source:让 source 描述「对象来自哪里」(§11.5),替代旧的 whence 枚举。
  • ODB 事务(odb/transaction.h):receive-pack 用它原子地写入接收到的 pack(§11.13)。
  • odb_stream:任意类型对象的流式 API(§11.4 相关的 RelNotes)。

扩展的设计原则:source 内部可以任意折腾(怎么存、怎么索引),但对上必须提供「可枚举、可读、可写、可校验」四件事;任何「只是加速」的东西(.rev、MIDX、bitmap)都必须可丢弃重建,否则就会破坏 §7 的可恢复性契约。


§14 设计取舍回顾

把全章的「为什么不用另一种方案」集中起来。

决策采用方案备选与代价
单个对象存储松散对象(每对象一文件)数据库式单文件:随机写复杂、易损坏、并发难
大仓库存储pack(单体 + delta)松散对象堆叠:inode 爆炸、无跨对象压缩
delta base 引用OFS_DELTA(负偏移)落盘 / REF_DELTA(OID)传输只用 OID:多 20/32 字节;只用偏移:传输端不知位置
idx 表布局v2 分表(OID/CRC/offset)v1 交错:4GiB 上限 + 二分缓存足迹大
查找加速256 项 fanout无 fanout:多 8 次比较;更大 fanout:更多常驻内存
反向索引落盘 .rev(可选)每次现算:O(N) 启动开销;不提供:某些操作要线性扫
多包查询MIDX(写时排序 O(M log M))逐 pack 二分:pack 多时 O(P log N)
MIDX 可扩展chunk 化(lookup 表)固定布局:加字段必须升版本、破坏旧读者
MIDX 大仓库增量链(BASE chunk)每次全量重建:并发 repack 代价高
不可达对象cruft pack + .mtimes直接 prune:可能误删「即将引用」的对象
压缩率/读速window/depth 可调(默认 10/50)极端 depth:文件小但随机读慢
写 pack两遍写(占位头 → 回填 + 校验和)预知对象数:需先全量记账,内存翻倍且无法截断
兼容性magic 让旧 Git 报错而非误读静默兼容:旧版会算出错误结果
哈希SHA-1/SHA-256 双支持 + hash_id单一 SHA-1:迁移受限;无 hash_id:无法识别格式
复用verbatim reuse(搬运原始字节)全部重压缩:CPU 高但实现简单

贯穿的一条主线:Git 在对象存储层反复做出同一个选择——把「正确且自包含的必需文件」(.pack/.idx)设计得严格、把「加速用的可选文件」(.rev/MIDX/bitmap)设计得可丢弃可重建。这让系统既能在最坏情况下(加速文件损坏)退化为「慢一点但正确」,又能在最好情况下(加速文件齐全)跑得极快。§7 的护栏分类正是这条原则的直接产物。


§15 本章小结

本章从「一个对象放在哪里」出发,把 Git 对象存储的两套物理表示拆到了字节级:

格式层面,我们逐字节解读了:

  • 松散对象:<type> <size>\0 与 payload 一起 zlib 压缩,OID 是未压缩串的哈希,路径按 xx/yyyy… 分片(fill_loose_path());写路径在 odb/source-loose.c,用「临时文件 + rename」保证原子性并断言 OID 自洽。
  • .pack:12 字节头(PACK + version 2 + nobjects)、对象头三段式 varint(类型 3 位 + size 4+7n 位)、OFS_DELTA 的「负相对偏移」(每级隐含 +1,故 ofs=256 编码为 81 00)、REF_DELTA 的 OID base、trailer 校验和(两遍写:先占位头、后回填 + 补校验和)。
  • .idx:v1 交错 vs v2 分表(magic \377tOc)、256 项累计 fanout、CRC 表、offset32 的 MSB 大偏移转义与 check_pack_index_ptr 护栏。
  • .rev/.mtimes:RIDX/MTME + version + hash_id;.rev 的 pack_order 表是「按偏移排序的索引位次」,.mtimes 按字典序记录 cruft 对象年龄。
  • MIDX:chunk 化(PNAM/BTMP/OIDF/OIDL/OOFF/LOFF/RIDX/BASE)、增量链的全局位置折算、伪 pack 顺序(preferred 优先、pack id 降序、包内按偏移)。
  • delta 指令:copy(位图 + 小端 offset32/size24,size=0→0x10000)、add(低 7 位长度)、保留 0x00、两个「低位在前」的 varint 头。

算法层面,我们推导了四个复杂度:

  • fanout + 二分把查找从 log2 N 降到 log2 N − 8,MIDX 把多包查找合并为一次;
  • .rev 用 LSD 基数排序(digit 16)把 offset↔pos 构建降到 O(N)、查询 O(log N);
  • MIDX 构建的主成本是 O(M log M) 排序与去重,伪 pack 顺序保证「一位图位一对象」;
  • delta 用「窗口(默认 10)+ 深度(默认 50)+ Rabin 指纹(窗口 16)+ 大小启发式」逼近压缩率与读速的帕累托前沿。

工程层面,我们展示了:

  • 谁在什么时候写什么文件(pack-objects/index-pack/unpack-objects/repack/multi-pack-index/verify-pack);
  • 损坏时如何局部化(bad_objects、回退其它 source)、哪些错误是不可恢复的 die;
  • 四层性能预算(mmap 窗口/LRU、delta base 缓存、verbatim 复用、写侧并行与压缩级别);
  • 与 commit-graph、bitmap、传输协议、alternate/promisor、索引、refs 的交互面;
  • v2.56 把对象存储抽象为可插拔 ODB source 的全套变化(且磁盘格式零变更)。

一句话总结:Git 对象存储是「用最少字节存下全部历史、用辅助索引把每次访问压到对数级、并让所有辅助索引可丢弃重建」这一设计哲学的教科书式实现。掌握本章的字节布局与算法不变量,就能在第 5 章(可达性/commit-graph/bitmap)、第 7 章(传输协议)和第 8 章(ODB 扩展)中,把「上层功能」与「底层存储」的因果关系看得一清二楚——因为几乎所有上层的性能故事,最终都落回本章这几张表与几条不变量上。