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