15.2.1 大文件为什么不进主库
15.1 管的是版本号,这一节管版本库里到底放什么。美术的真身是二进制大文件:一张 .psd 源稿 40 MiB、一个 .psb 图集源 60 MiB、一段 .mov 参考 8 MiB。二进制文件有两个致命性质:一是不可 diff(改一个像素,整块字节全变,版本库存不下增量,只能整份重存);二是不可合并(两个人改同一份只能二选一)。把这类文件直接塞进主库,结果是仓库体积指数膨胀、每次克隆要拉几十 GB、切分支慢到没法用。
行业的通行解法是指针文件 + 远端存储:主库里每个大文件只留一份几十到几百字节的指针(记录真身的 oid 与大小),真身放在对象存储/制品库上按 oid 寻址。本节不重推这套机制的原理(那是工具链与管线工程的事),只做两件可算的账:指针清单 ↔ 远端清单的双向对账,以及仓库体积账。
15.2.2 D 层:指针清单与远端清单
config/sp15_lfs.json 是这份账的静态输入:pointerFiles(主库里的指针,每份 pointerBytesEach 字节)、remoteObjects(远端对象,带 oid 与真实字节)、otherRepoFiles(真正入库的常规小文件)。样本里故意留了一条悬空指针与一条孤儿对象:
{
"project": "stardust-inn",
"pointerBytesEach": 132,
"note": "《星屑旅店》大文件协作样本:主库只放指针文件(每份固定 132 字节),真身放在远端对象存储。对账「指针清单 ↔ 远端清单」两个方向:悬空指针(指向不存在的对象)与孤儿对象(无人引用)。样本各含一条。",
"pointerFiles": [
{ "path": "art/source/furn_chair_01.psd", "oid": "3f9a1c7e2b" },
{ "path": "art/source/portrait_host_01.psd", "oid": "7b2d4e6a10" },
{ "path": "art/source/atlas_ui.psb", "oid": "c1e8a5039d" },
{ "path": "art/source/tile_floor_01.aseprite", "oid": "5a6f2b8c44" },
{ "path": "art/source/vfx_steam_01.mov", "oid": "deadbeef01" }
],
"remoteObjects": [
{ "oid": "3f9a1c7e2b", "size": 48234496 },
{ "oid": "7b2d4e6a10", "size": 31457280 },
{ "oid": "c1e8a5039d", "size": 62914560 },
{ "oid": "5a6f2b8c44", "size": 12582912 },
{ "oid": "0123456789", "size": 4194304 }
],
"otherRepoFiles": [
{ "path": "art/atlas/ui_atlas.png", "size": 1398128 },
{ "path": "art/atlas/furn_atlas.png", "size": 1398128 },
{ "path": "assets/config_bundle.bin", "size": 131072 }
]
}
读法:vfx_steam_01.mov 的指针指向 deadbeef01,但远端没有这个对象——悬空指针,谁去取这份参考都会失败。远端另有 0123456789(4 MiB),没有任何指针引用它——孤儿对象,白占远端。
15.2.3 S 层:双向对账
下面这段是 S 层脚本。reconcile 做两个方向的差集,volume 算体积账,结果落 out/sp15_lfs.json:
#!/usr/bin/env python3
# file: sim/sp15_lfs.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/sp15_lfs.py
"""15.2 大文件与二进制资产协作的 S 层对账器。读 config/sp15_lfs.json:
1) 指针清单 ↔ 远端清单双向对账:
DANGLING 指针指向的 oid 在远端不存在(悬空指针,取不到真身)
ORPHAN 远端存在、却没有任何指针引用的对象(孤儿,白占远端)
2) 仓库体积账(全字节):主库 = 指针文件(每份 pointerBytesEach)+ 其它入库文件;
若不进指针而把真身直接入库,则主库 = 其它入库文件 + 被引用对象字节。
两者之差即「指针化省下的字节」。
违例是字符串数组,体积是整数,排序后落 out/sp15_lfs.json,供 P 层跨语言对照。全整数 → 逐位一致。
"""
import json
import os
import sys
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
def reconcile(cfg: dict):
pointers = cfg["pointerFiles"]
remote = {o["oid"]: o["size"] for o in cfg["remoteObjects"]}
referenced = {p["oid"] for p in pointers}
dangling = sorted(f"DANGLING:{p['path']}:{p['oid']}" for p in pointers if p["oid"] not in remote)
orphan = sorted(f"ORPHAN:{oid}" for oid in remote if oid not in referenced)
return dangling, orphan
def volume(cfg: dict):
pointers = cfg["pointerFiles"]
remote = {o["oid"]: o["size"] for o in cfg["remoteObjects"]}
other = sum(f["size"] for f in cfg["otherRepoFiles"])
pointer_overhead = cfg["pointerBytesEach"] * len(pointers)
repo_bytes = pointer_overhead + other
referenced_bytes = sum(remote[p["oid"]] for p in pointers if p["oid"] in remote)
naive_repo_bytes = other + referenced_bytes
return {"pointerOverheadBytes": pointer_overhead, "otherRepoBytes": other,
"repoBytes": repo_bytes, "referencedRemoteBytes": referenced_bytes,
"naiveRepoBytes": naive_repo_bytes, "savedBytes": naive_repo_bytes - repo_bytes}
def main() -> int:
cfg = json.load(open(os.path.join(ROOT, "config/sp15_lfs.json"), encoding="utf-8"))
dangling, orphan = reconcile(cfg)
vol = volume(cfg)
viol = dangling + orphan
report = {"project": cfg["project"], "pointerCount": len(cfg["pointerFiles"]),
"remoteCount": len(cfg["remoteObjects"]), "dangling": dangling, "orphan": orphan,
"volume": vol, "violations": viol, "count": len(viol)}
with open(os.path.join(ROOT, "out/sp15_lfs.json"), "w", encoding="utf-8") as f:
json.dump(report, f, ensure_ascii=False, indent=2)
f.write("\n")
print(f"pointers={report['pointerCount']} remote={report['remoteCount']} "
f"dangling={len(dangling)} orphan={len(orphan)}")
for v in viol:
print(f" VIOLATION {v}")
print(f" repoBytes={vol['repoBytes']} (pointerOverhead={vol['pointerOverheadBytes']} "
f"other={vol['otherRepoBytes']})")
print(f" naiveRepoBytes={vol['naiveRepoBytes']} savedBytes={vol['savedBytes']}")
print("wrote out/sp15_lfs.json")
return 0
if __name__ == "__main__":
sys.exit(main())
实跑 /opt/homebrew/bin/python3 sim/sp15_lfs.py:
pointers=5 remote=5 dangling=1 orphan=1
VIOLATION DANGLING:art/source/vfx_steam_01.mov:deadbeef01
VIOLATION ORPHAN:0123456789
repoBytes=2927988 (pointerOverhead=660 other=2927328)
naiveRepoBytes=158116576 savedBytes=155188588
wrote out/sp15_lfs.json
15.2.4 仓库体积账:指针化省下的到底是什么
体积账的三行数字要连起来读:
| 量 | 字节 | 含义 |
|---|---|---|
pointerOverheadBytes | 660 | 5 份指针 × 132 字节,这是指针化唯一的代价 |
repoBytes | 2927988 | 主库实际占用 = 指针 660 + 其它入库 2927328 |
naiveRepoBytes | 158116576 | 若真身直接入库 = 其它入库 2927328 + 被引用对象 155189248 |
savedBytes | 155188588 | naive − repo,即省下的净字节 |
关键点:省下的字节 = 被引用对象的全部字节 − 指针开销(155189248 − 660)。也就是说指针化把「几十兆的真身」换成了「132 字节的指针」,代价几乎可以忽略。注意 savedBytes 用的是被引用对象的字节,不含孤儿 0123456789——孤儿根本不参与主库账,它只体现在对账违例里。
15.2.5 P 层:同一套对账的 TS 内核
下面这段是 P 层 TS 内核 core/src/sp15/lfs.ts,与 Python 逐条对应——同一套 oid 集合运算、同一套体积公式:
// file: core/src/sp15/lfs.ts
// tier: P
// verify: node --test 'core/test/sp15_*.test.ts'
// 15.2 大文件协作的 P 层内核。与 sim/sp15_lfs.py 同一套规则:指针清单 ↔ 远端清单双向对账
// (DANGLING 悬空指针 / ORPHAN 孤儿对象)+ 仓库体积账。字节与条数全是整数 → 逐位一致。
// 只允许可擦除 TS 语法(禁 enum / namespace / 参数属性 / 装饰器)。
export function reconcile(cfg: any): { dangling: string[]; orphan: string[] } {
const remote: Record<string, number> = {};
for (const o of cfg.remoteObjects) remote[o.oid] = o.size;
const referenced = new Set(cfg.pointerFiles.map((p: any) => p.oid));
const dangling = cfg.pointerFiles
.filter((p: any) => !(p.oid in remote))
.map((p: any) => `DANGLING:${p.path}:${p.oid}`)
.sort();
const orphan = Object.keys(remote)
.filter((oid) => !referenced.has(oid))
.map((oid) => `ORPHAN:${oid}`)
.sort();
return { dangling, orphan };
}
export function volume(cfg: any): any {
const remote: Record<string, number> = {};
for (const o of cfg.remoteObjects) remote[o.oid] = o.size;
const other = cfg.otherRepoFiles.reduce((s: number, f: any) => s + f.size, 0);
const pointerOverheadBytes = cfg.pointerBytesEach * cfg.pointerFiles.length;
const repoBytes = pointerOverheadBytes + other;
const referencedRemoteBytes = cfg.pointerFiles
.filter((p: any) => p.oid in remote)
.reduce((s: number, p: any) => s + remote[p.oid], 0);
const naiveRepoBytes = other + referencedRemoteBytes;
return {
pointerOverheadBytes,
otherRepoBytes: other,
repoBytes,
referencedRemoteBytes,
naiveRepoBytes,
savedBytes: naiveRepoBytes - repoBytes,
};
}
15.2.6 负例:悬空指针与孤儿对象
core/test/sp15_lfs.test.ts 一边读 Python 产物做跨语言对照,一边用内存对象构造两条负例——悬空指针与孤儿对象都必须 fail:
// file: core/test/sp15_lfs.test.ts
// tier: P
// verify: node --test 'core/test/sp15_*.test.ts'
// 跨语言对照:读 sim/sp15_lfs.py 落盘的 out/sp15_lfs.json,断言 TS 侧复算的对账结果与
// 体积账逐位一致。负例:悬空指针、孤儿对象都必须 fail。
import test from "node:test";
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { reconcile, volume } from "../src/sp15/lfs.ts";
const HERE = dirname(fileURLToPath(import.meta.url));
const ROOT = resolve(HERE, "../..");
const cfg = JSON.parse(readFileSync(resolve(ROOT, "config/sp15_lfs.json"), "utf8"));
const py = JSON.parse(readFileSync(resolve(ROOT, "out/sp15_lfs.json"), "utf8"));
test("大文件对账:TS 复算与 Python 产物逐位一致", () => {
const mine = reconcile(cfg);
assert.deepEqual(mine.dangling, py.dangling);
assert.deepEqual(mine.orphan, py.orphan);
assert.deepEqual(volume(cfg), py.volume);
assert.equal(mine.dangling.length + mine.orphan.length, py.count);
});
test("正例:样本恰好一条悬空指针与一条孤儿对象", () => {
const mine = reconcile(cfg);
assert.deepEqual(mine.dangling, ["DANGLING:art/source/vfx_steam_01.mov:deadbeef01"]);
assert.deepEqual(mine.orphan, ["ORPHAN:0123456789"]);
});
test("负例:悬空指针(指针指向远端不存在的 oid)必须 fail", () => {
const bad = {
pointerBytesEach: 132,
pointerFiles: [{ path: "a/x.psd", oid: "gone" }],
remoteObjects: [],
otherRepoFiles: [],
};
assert.deepEqual(reconcile(bad).dangling, ["DANGLING:a/x.psd:gone"]);
});
test("负例:孤儿对象(远端有、无指针引用)必须 fail", () => {
const bad = {
pointerBytesEach: 132,
pointerFiles: [],
remoteObjects: [{ oid: "wasted", size: 100 }],
otherRepoFiles: [],
};
assert.deepEqual(reconcile(bad).orphan, ["ORPHAN:wasted"]);
});
test("正例:体积账——指针化省下被引用对象的全部字节", () => {
const v = volume(cfg);
assert.equal(v.repoBytes, 132 * 5 + 2927328);
assert.equal(v.naiveRepoBytes, 2927328 + 155189248);
assert.equal(v.savedBytes, 155189248 - 660);
});
实跑 node --test 'core/test/sp15_*.test.ts'(本节 5 条;三节合计 12 条全绿):
✔ 大文件对账:TS 复算与 Python 产物逐位一致 (1.053834ms)
✔ 正例:样本恰好一条悬空指针与一条孤儿对象 (0.109042ms)
✔ 负例:悬空指针(指针指向远端不存在的 oid)必须 fail (0.080125ms)
✔ 负例:孤儿对象(远端有、无指针引用)必须 fail (0.07525ms)
✔ 正例:体积账——指针化省下被引用对象的全部字节 (0.073542ms)
ℹ tests 12
ℹ pass 12
ℹ fail 0
15.2.7 边界与取舍
- 真实指针服务端 / 真远端对象存储(Git LFS / Perforce / 制品库)需要真实环境,命令行不可复现,标 X;本节只对两份清单做静态集合运算与体积汇总,不触网、不上传、不下载任何真身。
- 两方向对账都是字符串数组,逐位一致;体积账全是整数(字节),同样逐位一致。本节不涉及色彩派生量,故无浮点容差问题。
- 孤儿对象要定期清(远端计费按存储量),悬空指针必须阻断合并(否则别人取不到真身,本地却看起来「文件在」)。两条违例的处理方向相反:孤儿删远端、悬空补上传或改指针。
- 指针的 oid 只做寻址,不做完整性校验;真身的 sha256 校验属于远端存储的职责,本节不展开。
阅读导航:上一节:15.1 资产版本治理:命名与分支 · 下一节:15.3 资产 diff 与回滚 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。