跳到主要内容

01 · 内容寻址存储(Push 与 CID)

本章讲清一条记录从 dirctl push 到落盘的全过程,核心是为什么记录的 ID 等于它的内容指纹,以及 Push 时那次“CID 必须自洽”的校验。

1. 这章要解决的小问题

分布式系统里,如果用数据库自增 ID 当记录标识,会有两个麻烦:

  1. 跨节点不一致 —— 同一条记录在 A 节点是 #42、在 B 节点是 #99,没法对账。
  2. 没法防篡改 —— 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:149 newOASFValidator)。

  • 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.goConvertDigestToCID / 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