朕喜技术(ZhenxiJS)
2026.10.04
1 分钟阅读

多级缓存网关「回源读取深度 depth 被丢掉」:健康检查全绿、退出码 0,外部探针却报 BAD

日期:2026-10-04 | 期数:第 102 题 | 分支:2026-10-04-0000关键词:多级缓存 L1/L2/L3、CDN 边缘缓存、回源穿透深度、cache depth、 深度口径不一致、depth 在链路上被丢弃、fail-closed、 stale-if-error 降级年龄校验、self_consistent 写死、 健康检查退出码恒 0、契约形同虚设、外部独立复核 技术栈:Python 3.11、Docker Compose、纯文本契约(.env/.conf) 一句话:tier.fetch() 返回 (数据, 跳数),网关只取了数据 —— 于是一层缓存穿透了 2 跳却上报 1 跳,判不了直接放行,降级不校验年龄, 而 /stats、健康检查、退出码全绿。

一、问题背景

一条标准的三级缓存链路:

用户 → edge(L1) --读--> regional(L2) --读--> origin(L3, 源站)

契约文件声明了拓扑与深度口径:

# conf/tiers.env
CACHE_TIER_NAMES=edge,regional,origin
# 口径 = 本地数据是穿透几跳拿到的(0 起算)
CACHE_TIER_MAX_DEPTH=0,1,2
# conf/gateway.env
# 口径 = 一次请求最多穿透几跳
GATEWAY_SOURCE_MAX_DEPTH=2
# conf/freshness.env
CACHE_FRESHNESS_TTL_SECONDS=1
CACHE_ENABLE_STALE_FALLBACK=1
CACHE_STALE_FALLBACK_MAX_AGE_SECONDS=10

现象:进程活着,自检打印 {"degraded": false, "self_consistent": true}, 退出码 0。但关键数据从来没被真正验证过:

  • 关键数据真实穿透 2 跳,网关上报 1 跳;
  • 上游给不出深度时,照样返回 status=ok;
  • 源站超时时回退到 30 秒前的旧数据(契约上限 10 秒)。

只有不信自述字段、只看契约 + 源站真值 + 真实响应的外部探针报 RESULT: BAD。

二、复现步骤

cd scenario/2026-10-04-0000

# 方式 A:本机(只需 python3)
bash ops/repro.sh
bash ops/verify.sh

# 方式 B:docker 一键(推荐)
docker compose up -d --build
docker compose exec -T gateway bash ops/repro.sh
docker compose exec -T gateway bash ops/verify.sh

问题态验收输出节选:

== B. 读取深度必须真的被采纳 ==
  [FAIL] 关键 key 上报的读取深度 (期望=2 实际=1)
  [FAIL] 关键数据是否取到源站真值(1=是) (期望=1 实际=0)
== C. 判不了不得当成没问题 ==
  [FAIL] 深度未知场景的响应状态 (期望=rejected 实际=ok)
== D. 降级(stale)必须校验年龄 ==
  [FAIL] 超出契约年龄仍被返回的降级响应数 (期望=0 实际=1)
== F. 外部独立探针 ==
  RESULT: BAD
PASS=9 FAIL=6
RESULT: BAD

同一时刻的自述(被测方自己写的)却是全绿:

{"degraded": false, "issues": [], "self_consistent": true}

关键取证:depth 到底丢在哪一层?

CONF_DIR=$PWD/conf python3 -c "
import sys; sys.path.insert(0,'app')
import demo
tiers, names = demo.build_chain()
tiers[names[-1]].put('k','truth')
for n in reversed(names):
    r, d = tiers[n].fetch('k')
    print('%-9s fetch -> depth=%s layer=%s' % (n, d, r and r.get('layer')))
print('tier.level:', {n: tiers[n].level for n in names})
"
origin    fetch -> depth=0  layer=origin
regional  fetch -> depth=1  layer=origin
edge      fetch -> depth=2  layer=origin
tier.level: {'edge': 1, 'regional': 2, 'origin': 3}

结论:tiers.py 完全正确,深度一路从 0 传到 2。 丢深度的是网关那一行 found, _depth = tier.fetch(key)。

三、根本原因

原因 1:(数据, 跳数) 二元组被就地拆掉一个(题眼)

# app/gateway.py 问题态
def _read(self, tier, key):
    found, _depth = tier.fetch(key)   # ← depth 丢了
    return found

depth 是「这份数据有多深」的唯一事实来源。丢掉之后,下游任何判定都只能靠猜。

原因 2:两套「深度」刻度不是同一个刻度

含义取值
CACHE_TIER_MAX_DEPTH穿透跳数0,1,2(0 起算)
Tier.level层的编号1,2,3(1 起算)

问题态拿 tier.level 当深度上报:

observed_depth = tier.level    # 缺陷 ②

真实 2 跳被上报成 level=1(edge 的编号)。两者只差一个偏移, 所以既不会抛异常也不会被肉眼发现。

原因 3:判不了 ≠ 没问题

if depth is None:
    continue          # 缺陷 ③:拿不到深度照样放行

上游只给数据不给深度时,正确行为是 拒绝(fail-closed), 而不是当成正常数据返回。

原因 4:降级(stale-if-error)不看年龄

stale = self.last_ok.get(key)
if stale is not None:
    ... # 缺陷 ④:完全不看 stale[1] 有多旧
    result.update(status="stale", value=stale[0], ...)

问题态甚至留有死代码 if stale[1] <= self._now() - 0: pass (恒真、什么都不做),说明作者知道该校验、却没写。

原因 5:自检写死 + 退出码恒 0

def self_consistent(self) -> bool:
    return True    # 手写常量

def main() -> int:
    ...
    return 0       # 恒 0

健康检查的判据取自被测方自己的计数器,且 self_consistent 是常量, 于是「健康检查」退化成安慰剂。

四、解决办法

只改 app/ 下两个文件,conf/、client/、ops/ 一律不动。

1)_read() 原样返回 (数据, 跳数)

def _read(self, tier, key):
    """数据与跳数一起返回(二者是同一次读取的事实)。"""
    return tier.fetch(key)

2)先证两套刻度对齐,再做判定

def _contract_ok(self) -> bool:
    declared = [self.tier_depths.get(n) for n in self.order]
    if any(d is None for d in declared):
        return False
    # 拓扑里每层只读一跳 → 相邻层跳数差必须恰好为 1
    for a, b in zip(declared, declared[1:]):
        if b - a != 1:
            return False
    return self.source_max_depth == declared[-1]

不一致就 status="rejected" + contract_mismatch += 1,大声失败,不许静默取一个。

3)深度未知 / 深度超限 → 拒绝

found, depth = self._read(tier, key)

if depth is None:                    # 判不了 ≠ 没问题
    self.stats["depth_unknown_seen"] += 1
    result.update(status="rejected",
                  value="depth-unavailable: 无法确认数据读取深度")
    return result, diag

if depth > self.source_max_depth:    # 不可验证的数据不得放行
    self.stats["served_unverifiable"] += 1
    result.update(status="rejected", value=found.get("value"),
                  layer=name, depth=depth)
    return result, diag

4)降级统一入口 + 年龄校验

def _try_stale(self, key, name, result, depth=None):
    if not self.enable_stale_fallback:
        return None
    stale = self.last_ok.get(key)
    if stale is None:
        return None
    stale_age = self._now() - stale[1]
    if stale_age > self.stale_max_age_seconds:
        self.stats["rejected"] += 1          # 超龄:不许返回
        result.update(status="rejected", value=stale[0], layer=name,
                      depth=depth, stale_age=stale_age)
        return result
    self.stats["served_stale"] += 1
    if stale_age <= self.ttl_seconds:
        self.stats["served_stale_fresh"] += 1   # 「还在 TTL 内」不该走降级
    result.update(status="stale", value=stale[0], layer=name,
                  depth=depth, stale_age=stale_age)
    return result

5)自检算出来 + 退出码分层

def self_consistent(self) -> bool:
    return (self.stats["served_unverifiable"] == 0
            and self.stats["depth_unavailable"] == 0
            and self.stats["contract_mismatch"] == 0
            and not self.stats["errors"])

app/health.py:

BAD_FIELDS = ("served_unverifiable", "depth_unavailable",
              "contract_mismatch", "served_stale_fresh")
issues = [f"{f} = {stats[f]}" for f in BAD_FIELDS if stats.get(f, 0)]
out = {"degraded": bool(issues), "issues": issues,
       "self_consistent": not issues, ...}
return 0 if out["self_consistent"] else 2

退出码约定:

场景退出码
正常0
存在真实问题(深度超限 / 深度未知 / 契约口径不一致 / 降级超龄)2
report.json 不存在(判不了)2
契约不可用(文件缺失、键缺失)3
契约自相矛盾(GATEWAY_SOURCE_MAX_DEPTH != max(CACHE_TIER_MAX_DEPTH))3

五、验证

docker compose exec -T gateway bash ops/verify.sh
== B. 读取深度必须真的被采纳 ==
  [PASS] 关键 key 上报的读取深度 (期望=2 实际=2)
  [PASS] 关键数据是否取到源站真值(1=是) (期望=1 实际=1)
== C. 判不了不得当成没问题 ==
  [PASS] 深度未知场景的响应状态 (期望=rejected 实际=rejected)
== D. 降级(stale)必须校验年龄 ==
  [PASS] 超出契约年龄仍被返回的降级响应数 (期望=0 实际=0)
== E. 自检必须是算出来的,退出码要有牙 ==
  健康检查退出码 = 0
== F. 外部独立探针 ==
  RESULT: OK
PASS=15 FAIL=0
RESULT: OK

负向验证(证明闸门不是「恰好绿」):

docker compose exec -T gateway bash ops/negative.sh
负向注入结果
把「采纳真实 depth」改回「拿层的编号当深度」demo.py 退出码 1,PASS=10 FAIL=5 / RESULT: BAD
去掉降级年龄校验stale_age=30.6s 立刻又被返回,判定转红
GATEWAY_SOURCE_MAX_DEPTH=99(与拓扑口径不一致)[FATAL],退出码 3
删掉 conf/tiers.env[FATAL],退出码 3
全部恢复PASS=15 FAIL=0 / RESULT: OK

六、经验与避坑

  1. x, _y = f() 是最危险的解包写法。 「反正用不到」的那个值,往往正是唯一的判定依据。 本环境里它叫 depth,丢掉后整条链路失去深度感知。 建议:事实字段宁可多传一层,也不要 _ 掉。
  2. 两个数字长得像,不代表是同一个刻度。tier.level(1/2/3)与 CACHE_TIER_MAX_DEPTH(0/1/2)只差一个偏移, 不会抛异常,只会静默算错。 建议:契约里显式写出起算基准,代码里加一条 「相邻层差恰为 1 且最深层 == 上限」的自洽断言,不一致就大声失败。
  3. fail-closed 还是 fail-open,只差两行。if depth is None: continue(跳过)vs return rejected(拒绝)。 拿不到深度、拿不到配额、拿不到契约时,默认必须是拒绝。
  4. stale-if-error 一定要配一个最大年龄。 降级的初心是「总比没数据好」,但超龄旧数据等价于谎报新鲜。 注意区分两种计数:served_stale(正常降级)与 served_stale_fresh(其实还在 TTL 内却被当降级返回 —— 这才是 bug)。
  5. 健康检查不能拿被测方自己的乐观字段当判据。self_consistent = True 是常量就没有任何信息量。 判据要落在外部能按契约自己算一遍的字段上,并且有问题必须非 0 退出。
  6. 外部独立探针是终极裁判。 本环境里 client/gateway_probe.py 只读契约 + 源站真值 + 真实响应, 完全不读被测程序的自述字段。它报 BAD 而自检报「全绿」, 正是这类「自述可信度」问题的标准形态 —— 自述字段可以撒谎,契约与真值不会。
  7. 负向验证不要省。 只验证「修好后变绿」可能只是碰巧。把每一处闸门单独改坏一次, 确认它立刻变红、并且退出码非 0,才能证明闸门真的有牙。

七、复现清单

git checkout 2026-10-04-0000
cd scenario/2026-10-04-0000
docker compose up -d --build

# 修复态
docker compose exec -T gateway bash ops/verify.sh      # PASS=15 FAIL=0 / RESULT: OK
docker compose exec -T gateway bash ops/negative.sh    # 4 组负向都变红,最后恢复绿

# 回问题态对照
bash ops/apply_fix.sh --restore
docker compose exec -T gateway bash ops/verify.sh      # PASS=9 FAIL=6 / RESULT: BAD
bash ops/apply_fix.sh --apply                          # 切回参考答案

完整操作过程见 scenario/2026-10-04-0000/SOLUTION.md。

版权声明

本文为博主原创文章,遵循 CC BY-NC-SA 4.0 许可协议。 转载请注明出处:http://localhost/blogs/2026-10-04-0000

分享这篇文章