01 · 内容寻址存储(Push 与 CID)
本章讲清一条记录从
dirctl push到落盘的全过程,核心是为什么记录的 ID 等于它的内容指纹,以及 Push 时那次“CID 必须自洽”的校验。
1. 这章要解决的小问题
分布式系统里,如果用数据库自增 ID 当记录标识,会有两个麻烦:
- 跨节点不一致 —— 同一条记录在 A 节点是
#42、在 B 节点是#99,没法对账。 - 没法防篡改 —— ID 和内容无关,改了内容 ID 不变,你拿到东西也不知道有没有被动过。
dir 的答案是内容寻址(content addressing):ID 由内容算出来。
2. 思路 / 直觉
指纹即身份。 把记录序列化成确定性的字节,算 SHA-256,再包装成 IPFS 的 CIDv1 字符串——这个字符串就是记录的 ID(CID = Content IDentifier,内容标识符)。
推论很强:
- 同一条记录在任何节点、任何时刻算出的 CID 都一样(全球唯一)。
- 改动任何一个字节,CID 就完全变了——所以拿到 CID 就等于拿到一份防伪封条:拉回内容、重算 CID、比对,即可确认没被改过。
前提是“确定性序列化”。 同样的数据如果 JSON key 顺序不同,字节就不同,CID 就不同。所以必须有一套规范化(canonical)序列化,这是整个内容寻址成立的隐形地基。
3. 图示:Push 的数据流
下图怎么读:从左到右是 Push 的步骤,注意 CID 被算了两次(一次从记录内容、一次从 OCI 推回来的摘要)然后比对。
record.json
│
▼
┌──────────────────────┐ 规范化 JSON 字节
│ record.Marshal() │──────────────┐
│ (key 排序、去抖动) │ │
└──────────────────────┘ ▼
│ ┌─────────────────┐
│ 同一份字节 │ oras.PushBytes │ 存进 OCI 仓库
│ │ → layerDesc. │ 得到 blob 摘要
▼ │ Digest │
┌──────────────────────┐ └────────┬────────┘
│ record.GetCid() │ │ digest
│ = SHA-256 → CIDv1 │ ▼
└──────────┬───────────┘ ┌ ─────────────────────────┐
│ expectedCID │ ConvertDigestToCID(digest)│
│ │ → recordCID │
▼ └────────────┬────────────┘
┌───────────────────────────────────────┐
│ if recordCID != expectedCID → 报错 │ ← 关键自洽校验
│ 否则:打 manifest、用 CID 当 tag 落盘 │
└───────────────────────────────────────┘
4. 原理演示(示意,非源码)
这段演示“内容寻址 + 自洽校验”的核心想法,帮你建立直觉:
# 示意,非源码:内容寻址的最小心智模型
import hashlib, json
def canonical_bytes(record: dict) -> bytes:
# 关键:sort_keys 保证同样数据 → 同样字节(否则 CID 不稳定)
return json.dumps(record, sort_keys=True, separators=(",", ":")).encode()
def compute_cid(record: dict) -> str:
digest = hashlib.sha256(canonical_bytes(record)).hexdigest()
return "cid-v1:" + digest # 真实实现包装成 IPFS CIDv1
def push(record: dict, store: dict) -> str:
data = canonical_bytes(record)
blob_digest = hashlib.sha256(data).hexdigest() # “存进仓库”后回来的摘要
cid_from_content = compute_cid(record)
cid_from_blob = "cid-v1:" + blob_digest
assert cid_from_content == cid_from_blob # 重点看这行:两条路径必须一致
store[cid_from_content] = data
return cid_from_content
重点看那行 assert:真实代码里就是这次比对,保证“记录自己声称的 CID”与“存储层算出的 CID”一字不差,任何序列化/编码漂移都会在 Push 时当场暴露。
5. 真实实现
规范化序列化 —— Marshal() 用三步保证确定性:先 json.Marshal,再 unmarshal 成 any,再 marshal(Go 的 encoding/json 对 map key 字母序排序),从而抹掉 key 顺序抖动。见 api/core/v1/record.go:144(Marshal),注释明确“maps must have consistent key order for deterministic results”。
算 CID —— GetCid() 走 Marshal → CalculateDigest(SHA-256) → ConvertDigestToCID。见 api/core/v1/record.go:73(GetCid)。
digest → CID 的转换 —— 固定参数 CIDv1、codec 1、SHA2-256 multihash,且只接受 SHA256 摘要。见 api/core/v1/cid.go:19(ConvertDigestToCID):
// api/core/v1/cid.go:39-47(ConvertDigestToCID 节选)
mhash, err := mh.Encode(hashBytes, mh.SHA2_256)
// ...
cidVal := cid.NewCidV1(1, mhash) // Version 1, codec 1, with our multihash
return cidVal.String(), nil
这段把 OCI 的 SHA-256 摘要重新编码成 IPFS CIDv1 字符串——这是“OCI 世界”和“IPFS/libp2p 世界”之间的胶水。
Push 主流程 + 自洽校验 —— oci.store.Push 是核心。它先 oras.PushBytes 存字节拿到 layerDesc.Digest,再 ConvertDigestToCID 算出 recordCID,然后和记录自己声称的 record.GetCid() 比对:
// server/store/oci/oci.go:212-217(Push 节选:CID 自洽校验)
expectedCID := record.GetCid()
if recordCID != expectedCID {
return nil, status.Errorf(codes.Internal,
"CID mismatch: OCI digest CID (%s) != Record CID (%s)",
recordCID, expectedCID)
}
比对通过后,Push 用 oras.PackManifest 打一个 manifest、把 CID 本身当作 OCI tag(cidTag := recordCID),再 tagWithRetry 打 tag。见 server/store/oci/oci.go:189(Push)。用 CID 当 tag 的意义:Lookup/Pull 直接拿 CID 当 tag 去 OCI 仓库解析 manifest,无需额外索引。
Pull 的反向校验 —— Pull 用 CID 解析 manifest,取第一个 layer 的 blob,读回字节后用 UnmarshalRecord 还原。见 server/store/oci/oci.go:307(Pull)。
6. 关键细节 / 坑
-
4MB 上限。 单条记录最大 4MB(要能塞进一个 gRPC 请求),
maxRecordSize = 1024*1024*4,在ValidateWith里用proto.Size拦截。见api/core/v1/record.go:20和:188。store 服务 proto 注释也写了“Max object size: 4MB”(proto/agntcy/dir/store/v1/store_service.proto:18)。 -
校验在控制器层,不在存储层。
StoreController.Push先用 OASF 校验器record.ValidateWith(ctx, s.validator)验 schema 合法,校验不过直接InvalidArgument拒绝,根本不进存储。见server/controller/store.go:62-78。校验器从外部 OASF schema URL 构造(server/server.go:149newOASFValidator)。 -
Push 是幂等的。 算出 CID 后先
Lookup,已存在就直接返回 ref、不重复写。见server/store/oci/oci.go:230-234。因为内容寻址,重复 push 同一内容必然得到同一 CID,天然去重。 -
存储后顺手建搜索索引。 Push 成功后调用
s.db.AddRecord把记录加入本地数据库二级索引——索引失败不让 Push 失败(只记日志),因为“存储是真相来源”。见server/controller/store.go:401-406。这条索引就是 04 章 的 Search 用的。 -
Tag 竞态重试。 远端 registry 下并发 push 时,manifest 推上去但 tag 时可能还没可见,所以
tagWithRetry对 “not found” 错误做指数退避重试(最多 3 次)。见server/store/oci/oci.go:109(tagWithRetry)。
7. 小结与下一步
这章你应该记住:CID = 对规范化字节算的 SHA-256 → CIDv1,既是 ID 又是防伪封条;Push 会做一次“内容算的 CID == 记录声称的 CID”自洽校验;字节存进 OCI 仓库、CID 当 tag。
下一章看这个 CID 如何在 P2P 网络里被宣告和发现 → 02 · 路由与发现。
8. 本章代码地图
| 主题 | 文件 | 符号 |
|---|---|---|
| 算 CID(内容→指纹) | api/core/v1/record.go | (*Record).GetCid |
| 规范化序列化 | api/core/v1/record.go | (*Record).Marshal |
| digest↔CID 转换 | api/core/v1/cid.go | ConvertDigestToCID / ConvertCIDToDigest |
| OCI Push + 自洽校验 | server/store/oci/oci.go | (*store).Push |
| OCI Pull(反向取回) | server/store/oci/oci.go | (*store).Pull |
| Tag 竞态重试 | server/store/oci/oci.go | (*store).tagWithRetry |
| 控制器层 schema 校验 | server/controller/store.go | (storeCtrl).Push |
| 大小上限 + 校验 | api/core/v1/record.go | (*Record).ValidateWith |