11.3 版本一致性与缓存失效

更新完之后,客户端缓存里哪些该丢、哪些能留?本节用两条整数判据回答:hash 变则 version 必须增(否则 stale)、hash 不变则 version 不应增(否则 wasteful);缓存以内容 hash 为键时只有 changed 失效。样本里 stale 1 / wasteful 1,两种键法命中率都是 461‰,但 path:version 键会喂陈旧内容。

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 规则引擎:命名/尺寸/引用/预算 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「gameart」更多文章

  1. 附录 A:风格参数表、量化规则与数据集索引
  2. 9.3 等距资产的制作约束
  3. 9.2 等距 tile 与遮挡规则