灰度路由实验优先级裁决错误:priority 当字符串比(字典序)导致「高优先级实验被低优先级压掉」,而健康检查退出码恒 0 全绿
关键词:灰度发布、gray release、A/B 实验分流、同层互斥、layer capacity、priority_wins、 priority 字符串比较、字典序比较、
"90" > "250"、Python str 比较陷阱、int vs str 排序错误、 冲突消解 conflict resolution、规则书写顺序、self_consistent 自证、健康检查退出码恒 0、 fail-closed、外部独立探针 probe、Docker 复现、Python 标准库 技术栈:Python 3(纯标准库)、Docker / docker compose、JSONL 规则集、env 契约文件 日期:2026-10-11 环境:CNB 流水线 +docker compose分支:2026-10-11-1600(第120题) 场景:scenario/2026-10-11-1600
一、问题背景
grayd 是一个灰度路由决策服务:一条请求带一份用户画像,服务按实验规则集
(conf/experiments.jsonl,每行一条 JSON,含 id / layer / priority / match / variant)
算出一组 layer -> variant 的分配结果,并落产出 outbox/<request>.json。
契约 conf/router.env(唯一权威来源)写死两条口径:
ROUTER_EXPERIMENT_LAYER_CAPACITY=1 # 同一层只能有一个实验生效
ROUTER_EXPERIMENT_CONFLICT_POLICY=priority_wins
# priority_wins = 同层多个实验同时命中时,取 priority 数值最大者生效(与书写顺序无关)
现象:实验运营同学报「我们新配的高优先级实验(priority=250)根本没生效,
最后跑的还是老实验(priority=90)」。可是现场所有绿色信号都亮着:
| 观测点 | 输出 |
|---|---|
python3 -m app.demo | {"assignments": 12, "degraded_experiments": 13, "ok": true, "self_consistent": true},退出码 0 |
python3 -m app.report | 同上,退出码 0 |
python3 -m app.health | {"degraded": false, "issues": []},退出码 0 |
四个绿灯没有一件指向真相。
一眼可见的规律:每一层生效的都是 priority 更小的那条。
把 priority 按字符串排一下就全明白了:"90" > "250"、"80" > "240"、
"70" > "200"、"60" > "150"。
二、复现步骤
git fetch origin && git checkout 2026-10-11-1600
cd scenario/2026-10-11-1600
# docker 一键复现
docker compose up -d --build
docker compose exec -T grayd bash -c 'cd /srv && bash ops/repro.sh; echo "repro rc=$?"'
docker compose exec -T grayd bash -c 'cd /srv && bash ops/verify.sh; echo "verify rc=$?"'
问题态关键输出:
=== 1) 跑一轮灰度路由决策 ===
{"assignments": 12, "degraded_experiments": 13, "ok": true, "requests": 4, "self_consistent": true}
demo rc=0
=== 3) 健康检查 ===
{"degraded": false, "issues": [], "ok": true, "self_consistent": true, ...}
health rc=0 ← 全绿,却什么都没判断
=== 5) 外部独立探针 ===
分配与契约口径不一致:r-001
期望 [checkout:exp-checkout-payment(250), home:exp-home-feed(150), ...]
实际 [checkout:exp-checkout-copy(90), home:exp-home-banner(60), ...]
RESULT: BAD(判定不一致 8 条)
repro rc=1 ← 预期复现信号
PASS=10 FAIL=7
RESULT: BAD
verify rc=1
坑:非交互环境
docker compose exec必须加-T,否则报the input device is not a TTY。ops/repro.sh以外部探针退出码为脚本退出码,问题态返回1—— 这是预期复现信号, 不是环境坏了。用;接住,不要用&&串联。
三、根本原因
3.1 题眼:priority 被当成字符串比较(字典序)
问题代码 app/router.py:
def _winner(rules):
best = None
for rule in rules:
if best is None or str(rule["priority"]) > str(best["priority"]): # ← BUG
best = rule
return best
str(rule["priority"]) 把整数 90 变成字符串 "90",> 退化成字典序:
先比首字符 '9' > '2',于是 "90" 被认为大于 "250"。
契约要求「取 priority 数值最大者生效」,实现却按字符串比较 ——
判定口径和契约对着干。规则集里 priority 恰好是「两位数 vs 三位数」的组合,
所以每一层都被压错。
这类 bug 的迷惑性在于:当所有数字位数相同时它「碰巧是对的」, 一旦混入不同位数就静默出错,而且不报错、不崩溃、自述报告看起来完全正常。
3.2 为什么四个绿灯全亮:健康检查判据表为空 + 退出码写死
问题代码 app/health.py:
BAD_FIELDS = () # 判据表是空的,循环体永不执行
...
for field, bad_value in BAD_FIELDS: # 空元组 → 永不进循环
...
out["degraded"] = bool(issues) # issues 恒为 []
print(json.dumps(out, ...))
return 0 # 无条件 0
健康检查只读自述报告、不看任何事实:既不重算 assignments,
也不与 outbox/ 产出对账,判据表还是空的,退出码写死 0。
于是「判定错了」在健康检查这一层完全不可见。
同时 app/gate.py 里 self_consistent: True / ok: True 是手写常量,
不是算出来的 —— 典型「自述字段自证」,不能作为判据。
两个根因的关系:第一个让判定错,第二个让错误不可见。 只修第一个,问题会再犯;只修第二个,会立刻报错但不知道为什么。
四、解决办法
4.1 app/router.py:按数值取最大 + 并列即契约不可用
def _winner(rules):
top = max(rule["priority"] for rule in rules) # 数值比较,不是字符串
winners = [rule for rule in rules if rule["priority"] == top]
if len(winners) != 1:
raise RuntimeError("契约兜底未定义:priority 并列 %s(层内 %s)"
% (top, [r["id"] for r in winners]))
return winners[0]
要点:
- 先去重取数值 max,再在并列档里裁决,而不是「边扫描边用比较符号决定谁替换谁」—— 后者会把结果变成书写顺序的函数。
- priority 两两不同(
jq -r .priority conf/experiments.jsonl | sort | uniq -d无输出), 出现并列即契约未定义 → 大声失败,而不是随手取一个。 degraded_experiments按experiment_id排序输出,保证产出稳定(与书写顺序解耦)。
4.2 app/health.py:判据落到外部可独立复核的事实上 + 退出码分层
- 独立重算:读
conf/experiments.jsonl+requests/requests.jsonl+outbox/*.probe.json里的 profile,在 health 里另写一遍裁决逻辑 (不复用router._winner,避免「自己治自己」),算出每层应有的assignments与产出逐条比对。 - 交叉对账:自述报告的
assignments/degraded_experiments必须等于outbox/的 真实统计;不一致即报 issue。 - 拿不到事实也算问题:请求清单 / 规则集缺失、产出或探测块缺失、探测块无 profile → 全部计入 issues(fail-closed,「判不了」≠「没问题」)。
- 退出码分层:
| 退出码 | 含义 |
|---|---|
0 | 干净一轮:契约可用、产出齐全、判定与契约一致、自述与事实对得上 |
2 | 有真问题,或拿不到事实(产出缺失 / 自述与实际不符 / 请求或规则集不可用) |
3 | 契约不可用(conf/router.env 缺失、缺字段、口径非法) |
- 报告里给出外部可复核字段
outbox_rows/degraded_rows/mismatch。
五、验证
docker compose exec -T grayd bash -c 'cd /srv && bash ops/verify.sh; echo rc=$?'
── B) 各层生效实验(契约 priority_wins,与书写顺序无关) ──
[PASS] B1 生效实验与契约口径一致
[PASS] B2 契约口径下 checkout 层生效实验 exp-checkout-payment 的条数(期望 2 实际 2)
── E) 健康检查退出码分层(四种生产事实) ──
[PASS] E1 正常一轮 health 退出码(期望 0 实际 0)
[PASS] E2 自述与实际产出不一致时 health 退出码(期望 2 实际 2)
[PASS] E3 产出被删除(拿不到事实)时 health 退出码(期望 2 实际 2)
[PASS] E4 契约不可用时 health 退出码(期望 3 实际 3)
── F) 外部独立探针 ──
[PASS] F1 探针退出码(期望 0 实际 0)
[PASS] F2 探针结论(期望 RESULT: OK 实际 RESULT: OK)
PASS=17 FAIL=0
RESULT: OK
顺序无关性(契约明文要求,打乱 conf/experiments.jsonl 行顺序后产出必须一字不变):
shuffled order: ['exp-search-suggest', 'exp-home-banner', 'exp-checkout-payment', ...]
RESULT: OK
顺序无关性:OK(打乱前后一致)
负向验证(把裁决改回字典序比较 → 探针必须立刻转红):
[PASS] A1 字典序比较时探针转红(rc=1)
[PASS] A2 探针结论 RESULT: BAD
[PASS] A3 探针指出 exp-checkout-copy(90) 压掉了 exp-checkout-payment(250)
[PASS] A4 判定错时 health 退出码 2
[PASS] B1 退出码被改回恒 0 → 自述造假时仍 rc=0(假绿已实证)
[PASS] D1 产出缺失时 health 退出码 2(不做重跑洗白)
PASS=10 FAIL=0
RESULT: OK(闸门有牙)
六、经验与避坑
6.1 可复用的排查思路
- 「绿灯全亮」本身就是最强的信号:自述报告 / 健康检查 / 退出码三者都由同一份自述数据 推导时,它们同时绿不代表没问题,只代表它们没在看事实。 排查第一步:问「这个绿是算出来的还是写死的」。
- 数字类型的数据一定先看类型:
priority/version/port/size这类 从 JSON / env / 配置文件读入的值,很容易是str。str(a) > str(b)是字典序的经典陷阱。 自检命令:jq -r .priority conf/experiments.jsonl | sort | uniq -d看有没有并列, 再python3 -c 'print("90" > "250")'确认比较语义。 - 排序/取极值不要依赖书写顺序:契约写了「与书写顺序无关」, 就必须用「先定档(max/min)再裁决」的写法,而不是「边扫描边替换」。 验证手段:把输入文件行顺序随机打乱,产出必须一字不变。
- 健康检查要有独立事实源:判据必须落在外部能按契约自己算一遍的字段上 (如对端实收字节数、产出条数),而不是服务自己的自述字段。 并且「拿不到事实」必须 fail-closed(报错),不能默认通过。
- 退出码要分层:
0健康 /2真问题或拿不到事实 /3契约不可用。 把「判不了」和「没问题」区分开,是生产可用性的分水岭。
6.2 操作踩坑
- 状态文件里的
issue编号可能是旧的:scenario/.current-round.json写issue=120, 但 #120 是上一轮「第103题 sendfile 部分发送」(2026-10-04-1600)且已关闭。 本轮(2026-10-11-1600 / 第120题)的真实 Issue 是 #137。 用cnb issues list-issues --repo "$CNB_REPO_SLUG" --keyword "第120题"交叉确认, 以标题里的日期+题号为准,别盲信状态文件里的数字。 docker compose exec加-T:非交互环境不加会报the input device is not a TTY。- 探针 rc=1 是复现信号:
ops/repro.sh用外部探针退出码做脚本退出码, 问题态返回 1 是预期的;用; echo rc=$?接住。 git checkout -- <dir>不可逆:我误执行了一次git checkout -- app/, 把修复打回了问题态。改完文件先git diff --stat看清单,别顺手 checkout。ops/negative.sh有「必须是参考解」的前置检查:它 diffapp/router.pyvsops/expected-fixed/router.py,不一致直接exit 3。 自写等价实现需先cp ops/expected-fixed/*.py app/才能过前置检查, 负向验证跑完再切回本解。- 改只读考卷后务必还原:验证顺序无关性时临时打乱了
conf/experiments.jsonl, 跑完立刻用md5sum双向核对还原 (7b53c52157cdb1ed1481e1ae1940172d)。
6.3 一句话教训
"90" > "250"是True。 当「取最大值」的口径遇上字符串类型的数值,判定会静默反转; 而当健康检查只读自己的自述报告时,这个反转永远不会被人发现。 让判定看事实、让报告能被外部复核、让退出码有牙 —— 三者缺一不可。
七、一键复现命令汇总
git fetch origin && git checkout 2026-10-11-1600
cd scenario/2026-10-11-1600
docker compose up -d --build
docker compose exec -T grayd bash -c 'cd /srv && bash ops/repro.sh; echo rc=$?' # 修复态 rc=0
docker compose exec -T grayd bash -c 'cd /srv && bash ops/verify.sh; echo rc=$?' # PASS=17 FAIL=0 / OK
docker compose down -v
# 切问题态 / 参考解
# docker compose exec -T grayd bash -c 'cd /srv && bash ops/verify.sh --restore' # 问题态 PASS=10 FAIL=7
# docker compose exec -T grayd bash -c 'cd /srv && bash ops/verify.sh --fix' # 参考解 PASS=17 FAIL=0
相关文档:scenario/2026-10-11-1600/SOLUTION.md(完整操作过程)、
scenario/2026-10-11-1600/README.md(题面)、
scenario/2026-10-11-1600/docs/DECISION_SPEC.md(决策口径文字版)。
八、质检修复轮(收尾登记 · 避坑补记)
本节记录质检提出、Solver 修复的一处收尾遗漏,以及由此暴露的一条通用坑: 「分支上的文件」与「main 上的文件」是两份文件。
8.1 现象:索引看似"补了",其实没进本轮分支
- 质检在本轮分支
2026-10-11-1600上看到experience/README.md只有 170 行, 末行是第119题,grep不到「第120题」——本轮成果未登记进索引。 - 但 Issue 评论却写着「索引见
experience/README.md第 171 行」, 两处说法互相矛盾。 - 用两个 ref 对拍同一文件,真相立刻清楚:
# 本轮分支:170 行,无第120题行
git show origin/2026-10-11-1600:experience/README.md | wc -l # 170
git show origin/2026-10-11-1600:experience/README.md | grep -c 第120题 # 0
# main:171 行,第120题行已存在
git show origin/main:experience/README.md | wc -l # 171
git show origin/main:experience/README.md | grep -c 第120题 # 1
根因:收尾时把索引只推到了 main,漏推了本轮分支。
于是「main 上有、分支上没有」——质检在分支上复核,自然判定为「索引未更新」;
而 Issue 评论按 main 的视角写,看起来又是对的。
8.2 修复
在本轮分支的 experience/README.md 表格末尾(第 171 行)补上登记行,
并以 main 上已有的那行为准(内容更完备),写入后 diff 核准逐字节一致:
git show origin/main:experience/README.md > /tmp/main-readme.md
# 把第120题行追加到分支的 README 末尾
diff <(git show origin/main:experience/README.md) experience/README.md
# 无输出 → 分支索引已与 main 对齐
wc -l experience/README.md # 171
sed -n '171p' experience/README.md | cut -c1-70
# | [第120题](./2026-10-11-1600-priority-string-compare-routing.md) |
test -f experience/2026-10-11-1600-priority-string-compare-routing.md && echo LINK_OK
同时在 Issue 下补一条更正说明(不改写历史评论,保留可追溯性)。
8.3 可复用的避坑清单(收尾阶段)
- 收尾动作必须双落:补索引 / 同步经验文档要同时 commit 到本轮分支与
main, 只推一处 = 另一处永远是旧版。 - 出现"两边说法矛盾"时先对拍两个 ref:
git show <ref>:<path>把同一文件拉出来diff, 别急着改文档,先确认哪个 ref 才是事实来源。 - 索引行的"存在性"要用命令证伪,不要凭印象:
wc -l+grep -c+sed -n 'Np'+test -f <链接目标>四连, 比"我记得推过了"可靠得多。 - 链接行务必校验目标文件真实存在(
test -f):索引里的相对路径写错时, 没有任何脚本会报错。 - 质检意见与解题本体分离看待:本轮两条修改点都是收尾登记,
app/、conf/、docs/、client/、ops/*.sh、requests/零改动 —— 修复收尾问题时绝不能顺手动解题本体,否则会把"已通过的部分"重新引入风险。
8.4 本轮回归验证(真实执行)
cd scenario/2026-10-11-1600
bash ops/verify.sh # PASS=17 FAIL=0 / RESULT: OK
bash ops/repro.sh # 外部独立探针 RESULT: OK(rc=0)
diff <(git show origin/main:experience/README.md) experience/README.md # 无输出
git diff HEAD -- conf/ docs/ client/ ops/ requests/ # 无输出
