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

灰度路由实验优先级裁决错误: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 可复用的排查思路

  1. 「绿灯全亮」本身就是最强的信号:自述报告 / 健康检查 / 退出码三者都由同一份自述数据 推导时,它们同时绿不代表没问题,只代表它们没在看事实。 排查第一步:问「这个绿是算出来的还是写死的」。
  2. 数字类型的数据一定先看类型: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")' 确认比较语义。
  3. 排序/取极值不要依赖书写顺序:契约写了「与书写顺序无关」, 就必须用「先定档(max/min)再裁决」的写法,而不是「边扫描边替换」。 验证手段:把输入文件行顺序随机打乱,产出必须一字不变。
  4. 健康检查要有独立事实源:判据必须落在外部能按契约自己算一遍的字段上 (如对端实收字节数、产出条数),而不是服务自己的自述字段。 并且「拿不到事实」必须 fail-closed(报错),不能默认通过。
  5. 退出码要分层:0 健康 / 2 真问题或拿不到事实 / 3 契约不可用。 把「判不了」和「没问题」区分开,是生产可用性的分水岭。

6.2 操作踩坑

  1. 状态文件里的 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题" 交叉确认, 以标题里的日期+题号为准,别盲信状态文件里的数字。
  2. docker compose exec 加 -T:非交互环境不加会报 the input device is not a TTY。
  3. 探针 rc=1 是复现信号:ops/repro.sh 用外部探针退出码做脚本退出码, 问题态返回 1 是预期的;用 ; echo rc=$? 接住。
  4. git checkout -- <dir> 不可逆:我误执行了一次 git checkout -- app/, 把修复打回了问题态。改完文件先 git diff --stat 看清单,别顺手 checkout。
  5. ops/negative.sh 有「必须是参考解」的前置检查:它 diff app/router.py vs ops/expected-fixed/router.py,不一致直接 exit 3。 自写等价实现需先 cp ops/expected-fixed/*.py app/ 才能过前置检查, 负向验证跑完再切回本解。
  6. 改只读考卷后务必还原:验证顺序无关性时临时打乱了 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 可复用的避坑清单(收尾阶段)

  1. 收尾动作必须双落:补索引 / 同步经验文档要同时 commit 到本轮分支与 main, 只推一处 = 另一处永远是旧版。
  2. 出现"两边说法矛盾"时先对拍两个 ref:git show <ref>:<path> 把同一文件拉出来 diff, 别急着改文档,先确认哪个 ref 才是事实来源。
  3. 索引行的"存在性"要用命令证伪,不要凭印象: wc -l + grep -c + sed -n 'Np' + test -f <链接目标> 四连, 比"我记得推过了"可靠得多。
  4. 链接行务必校验目标文件真实存在(test -f):索引里的相对路径写错时, 没有任何脚本会报错。
  5. 质检意见与解题本体分离看待:本轮两条修改点都是收尾登记, 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/                      # 无输出

版权声明

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

分享这篇文章