11.3 版本一致性与缓存失效
11.1 把差异判据定在 hash,11.2 把差异变成更新与回滚计划。还剩最后一个问题:更新完之后,客户端缓存里哪些该丢、哪些能留。答案看似显然——「内容变了就丢」——但落到工程里有两个坑:版本号与内容不一致会骗过缓存,缓存键选错会把陈旧内容当成命中。本节用两条整数判据把它们抓出来。
11.3.1 两条判据
第一条,版本一致性:hash 变了则 version 必须增(否则 stale——客户端若按版本号判断,会继续用旧内容);hash 没变则 version 不应增(否则 wasteful——白白多下一次内容相同的文件)。
第二条,缓存失效:以内容 hash 为键时,只有 changed 需要失效,安全;以 path:version 为键时,版本漏升的资产会被误判命中,喂出陈旧内容。两条判据都只看整数与字符串,跨语言逐位一致。
11.3.2 S 层:一致性分类与两种缓存键法
下面这段是 S 层脚本,复用 11.1 的 classify,一次算两条账:
#!/usr/bin/env python3
# file: sim/ta11_version.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/ta11_version.py
"""11.3 版本一致性与缓存失效的 S 层计算器(卷二第 11 章)。
两条判据,都只看整数与字符串:
* **版本一致性**:hash 变了则 version 必须增(否则 stale:缓存会喂陈旧内容);hash 没变则
version 不应增(否则 wasteful:白白多下一次)。added / removed 另计 new / removed。
* **缓存失效**:以内容 hash 为键时只有 changed 需要失效——安全。作为反例,同时算一遍以
path:version 为键的命中:tex_lamp_brass 因版本漏升被判命中,于是喂了陈旧内容(staleServed)。
两种键法命中数可以一样,安全性却不同。
落 out/ta11_version.json。
"""
import hashlib
import json
import os
import sys
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from ta11_diff import classify, load_manifests # noqa: E402
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
OUT = os.path.join(ROOT, "out")
def consistency(base, target):
"""每个 id 的版本一致性分类。"""
rows = []
for aid in sorted(set(base) | set(target)):
status = classify(aid, base, target)
if status == "added":
kind = "new"
elif status == "removed":
kind = "removed"
else:
bv, tv = base[aid]["version"], target[aid]["version"]
if status == "changed":
kind = "consistent" if tv > bv else "stale"
else:
kind = "wasteful" if tv > bv else "consistent"
b, t = base.get(aid), target.get(aid)
rows.append({"id": aid, "status": status, "kind": kind,
"fromVersion": b["version"] if b else 0,
"toVersion": t["version"] if t else 0})
return rows
def cache_content(base, target):
"""以内容 hash 为键:只有 changed 需要失效。"""
changed = [a for a in target if a in base and base[a]["hash"] != target[a]["hash"]]
keep = [a for a in target if a in base and base[a]["hash"] == target[a]["hash"]]
return {"invalidateCount": len(changed), "keepCount": len(keep),
"addCount": sum(1 for a in target if a not in base),
"removeCount": sum(1 for a in base if a not in target),
"hitPermille": len(keep) * 1000 // len(target), "staleServedCount": 0}
def cache_path_version(base, target):
"""以 path:version 为键:版本漏升会误命中并喂陈旧内容。"""
hits = stale = 0
for a, t in target.items():
b = base.get(a)
if b and b["path"] == t["path"] and b["version"] == t["version"]:
hits += 1
stale += b["hash"] != t["hash"]
return {"hitCount": hits, "hitPermille": hits * 1000 // len(target), "staleServedCount": stale}
def main() -> int:
cfg, base, target = load_manifests()
rows = consistency(base, target)
kinds = {"new": 0, "removed": 0, "stale": 0, "wasteful": 0, "consistent": 0}
for r in rows:
kinds[r["kind"]] += 1
content, pv = cache_content(base, target), cache_path_version(base, target)
lines = sorted(f"{r['id']}:{r['kind']}:{r['fromVersion']}->{r['toVersion']}" for r in rows)
digest = hashlib.sha256("\n".join(lines).encode("utf-8")).hexdigest()[:16]
report = {"project": cfg["project"], "keying": cfg["cachePolicy"]["keying"],
"kindCounts": kinds, "staleIds": [r["id"] for r in rows if r["kind"] == "stale"],
"wastefulIds": [r["id"] for r in rows if r["kind"] == "wasteful"],
"content": content, "pathVersion": pv,
"gate": "warn" if kinds["stale"] > 0 else "pass", "digest": digest, "entries": rows}
os.makedirs(OUT, exist_ok=True)
with open(os.path.join(OUT, "ta11_version.json"), "w", encoding="utf-8") as fh:
json.dump(report, fh, ensure_ascii=False, indent=2, sort_keys=True)
fh.write("\n")
print(f"consistency new={kinds['new']} removed={kinds['removed']} stale={kinds['stale']} "
f"wasteful={kinds['wasteful']} consistent={kinds['consistent']}")
print(f" stale={','.join(report['staleIds'])} wasteful={','.join(report['wastefulIds'])}")
print(f"content invalidate={content['invalidateCount']} keep={content['keepCount']} "
f"hitPermille={content['hitPermille']} staleServed={content['staleServedCount']}")
print(f"pathVersion hit={pv['hitCount']} hitPermille={pv['hitPermille']} "
f"staleServed={pv['staleServedCount']}")
print(f"gate={report['gate']} digest={digest}")
print("wrote out/ta11_version.json")
return 0
if __name__ == "__main__":
sys.exit(main())
实跑 /opt/homebrew/bin/python3 sim/ta11_version.py:
consistency new=2 removed=1 stale=1 wasteful=1 consistent=9
stale=tex_lamp_brass wasteful=icon_coin
content invalidate=5 keep=6 hitPermille=461 staleServed=0
pathVersion hit=6 hitPermille=461 staleServed=1
gate=warn digest=f04445aa9b6b1607
wrote out/ta11_version.json
14 个 id 判成 consistent 9 / stale 1 / wasteful 1 / new 2 / removed 1。stale 是 tex_lamp_brass(内容变了版本没升),wasteful 是 icon_coin(版本升了内容没变)。gate=warn——只要 stale 非零就告警,因为它是会真出事的一类。
11.3.3 P 层:同一套判据的 TS 内核
下面这段是 P 层 TS 内核 core/src/ta11/version.ts,与上面的 Python 逐条对应:
// file: core/src/ta11/version.ts
// tier: P
// verify: node --test 'core/test/ta11_*.test.ts'
// 11.3 版本一致性与缓存失效的 P 层内核。与 sim/ta11_version.py 同一口径:
// hash 变则 version 必须增(否则 stale);hash 不变则 version 不应增(否则 wasteful)。
// 缓存以内容 hash 为键时只有 changed 失效;以 path:version 为键会把 stale 误判成命中。
import { type Entry, classify } from "./diff.ts";
export type KindRow = { id: string; status: string; kind: string; fromVersion: number; toVersion: number };
export function consistency(base: Map<string, Entry>, target: Map<string, Entry>): KindRow[] {
const ids = [...new Set([...base.keys(), ...target.keys()])].sort();
return ids.map((id) => {
const status = classify(id, base, target);
const b = base.get(id);
const t = target.get(id);
let kind: string;
if (status === "added") kind = "new";
else if (status === "removed") kind = "removed";
else {
const bv = (b as Entry).version;
const tv = (t as Entry).version;
if (status === "changed") kind = tv > bv ? "consistent" : "stale";
else kind = tv > bv ? "wasteful" : "consistent";
}
return { id, status, kind, fromVersion: b ? b.version : 0, toVersion: t ? t.version : 0 };
});
}
export function cacheContent(base: Map<string, Entry>, target: Map<string, Entry>) {
const invalidate = [...target.keys()].filter((a) => base.has(a) && (base.get(a) as Entry).hash !== (target.get(a) as Entry).hash);
const keep = [...target.keys()].filter((a) => base.has(a) && (base.get(a) as Entry).hash === (target.get(a) as Entry).hash);
const addCount = [...target.keys()].filter((a) => !base.has(a)).length;
const removeCount = [...base.keys()].filter((a) => !target.has(a)).length;
return { invalidateCount: invalidate.length, keepCount: keep.length, addCount, removeCount,
hitPermille: Math.floor((keep.length * 1000) / target.size), staleServedCount: 0 };
}
export function cachePathVersion(base: Map<string, Entry>, target: Map<string, Entry>) {
let hits = 0;
let stale = 0;
for (const [a, t] of target) {
const b = base.get(a);
if (b && b.path === t.path && b.version === t.version) {
hits += 1;
if (b.hash !== t.hash) stale += 1;
}
}
return { hitCount: hits, hitPermille: Math.floor((hits * 1000) / target.size), staleServedCount: stale };
}
11.3.4 跨语言对照:digest 逐位一致
P 层测试读的是 Python 落盘的 out/ta11_version.json:
// file: core/test/ta11_version.test.ts
// tier: P
// verify: node --test 'core/test/ta11_*.test.ts'
// 11.3 跨语言对照:读 out/ta11_version.json,断言 TS 侧版本一致性分类与两种缓存键法的账
// 与 Python 逐位一致。要点:内容键 staleServed=0,而 path:version 键 staleServed=1。
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 { mapById, type Entry } from "../src/ta11/diff.ts";
import { cacheContent, cachePathVersion, consistency } from "../src/ta11/version.ts";
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "../..");
const cfg = JSON.parse(readFileSync(resolve(ROOT, "config/ta11_manifest.json"), "utf8"));
const py = JSON.parse(readFileSync(resolve(ROOT, "out/ta11_version.json"), "utf8"));
const base = mapById(cfg.base.entries as Entry[]);
const target = mapById(cfg.target.entries as Entry[]);
test("跨语言:TS 版本一致性分类 == Python(逐位一致)", () => {
const rows = consistency(base, target);
assert.deepEqual(rows, py.entries);
const kinds: Record<string, number> = { new: 0, removed: 0, stale: 0, wasteful: 0, consistent: 0 };
for (const r of rows) kinds[r.kind] += 1;
assert.deepEqual(kinds, py.kindCounts);
assert.deepEqual(cacheContent(base, target), py.content);
assert.deepEqual(cachePathVersion(base, target), py.pathVersion);
});
test("分类计数:new 2 / removed 1 / stale 1 / wasteful 1 / consistent 9", () => {
const rows = consistency(base, target);
const stale = rows.filter((r) => r.kind === "stale").map((r) => r.id);
const wasteful = rows.filter((r) => r.kind === "wasteful").map((r) => r.id);
assert.deepEqual(stale, ["tex_lamp_brass"]);
assert.deepEqual(wasteful, ["icon_coin"]);
assert.equal(py.gate, "warn");
});
test("缓存失效:内容键安全(staleServed=0),path:version 键喂陈旧(staleServed=1)", () => {
const c = cacheContent(base, target);
const pv = cachePathVersion(base, target);
assert.equal(c.invalidateCount, 5);
assert.equal(c.keepCount, 6);
assert.equal(c.hitPermille, 461);
assert.equal(c.staleServedCount, 0);
assert.equal(pv.hitCount, 6);
assert.equal(pv.hitPermille, 461);
assert.equal(pv.staleServedCount, 1);
});
实跑 node --test 'core/test/ta11_*.test.ts'(本节 3 条;本章三节合计 9 条):
✔ 跨语言:TS 版本一致性分类 == Python(逐位一致)
✔ 分类计数:new 2 / removed 1 / stale 1 / wasteful 1 / consistent 9
✔ 缓存失效:内容键安全(staleServed=0),path:version 键喂陈旧(staleServed=1)
ℹ pass 9
ℹ fail 0
两侧 digest 都是 f04445aa9b6b1607——分类输出全是枚举字符串与整数,逐位一致。
11.3.5 命中率相同 ≠ 安全
两种键法的 hitPermille 都是 461、命中数都是 6。但命中的是同一批资产吗?不是。
- 内容键命中的 6 个是
hash未变的:scene_inn_lobby、icon_coin、portrait_innkeeper、furn_counter_01、mat_lamp_brass、mat_counter_wood,staleServed=0。 path:version键命中的 6 个里含tex_lamp_brass——它的version没变(3→3)但hash变了,于是被误判命中,staleServed=1。
同一个命中率,一个是安全的,一个会喂陈旧内容。 这解释了缓存键为什么必须选内容摘要:version 是人填的,人会忘;hash 是算出来的,不会忘。命中率只是数量指标,安全性要看「命中的是不是真的没变」——这正是本节要断言 staleServedCount 而不只是 hitPermille 的原因。
11.3.6 边界:真实缓存行为不可复现
本节判的全是清单层字段——hash、version、path,都是可复算的整数与字符串,能逐位对拍。但「客户端拿到新清单后缓存到底怎么淘汰、内存缓存与磁盘缓存怎么分层、CDN 边缘节点多久刷新」——需真实环境,命令行不可复现:那取决于具体引擎与 CDN 的实现,本仓库既没有运行时也没有 CDN。本节把这条边界标成 X 层,只断言「按记录该失效哪些」,不断言「客户端会怎么失效」。
阅读导航:上一节:11.2 增量更新与回滚 · 下一节:12.1 规则引擎:命名/尺寸/引用/预算 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。