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

多租户令牌桶限速器五盏绿灯全假:组级补充、优先队列三层排序、保留量口径、违规租户拦截与空判据表

第 147 题 · 分支 2026-10-25-1000 · 技术栈:Python 3.11 纯标准库 / 令牌桶限流 / 多租户配额 / 优先队列 / Docker Compose / 外部独立探针 关键词:令牌桶限流 token bucket rate limiter 多租户配额 租户限速器 组级原子投放优先队列三层排序 reserve tokens 保留量 denylist 违规名单 soft grace 排队超时健康检查假绿 空判据表 fail-open fail-closed 退出码分层 浮点 ceil 限流器 bug

1. 问题背景

tenantthrottle 是一个多租户令牌桶限速器:每轮读一份请求流水,按策略给每个租户补充令牌、 决定每条请求「收下 / 排队 / 拒绝」,最后输出报告给运营面板。

值班同事反馈五件事,而面板上一切正常:

  1. 同一秒到达的请求互相踩踏,换个文件顺序结果就变,wait_ms 在 1000/1250 之间乱跳;
  2. 优先级形同虚设:pledged 租户应该先出队,实际是「谁先到谁先出队」;
  3. 保留量算错:pledged 租户被允许一直借到 0,事后又不报 reserve_breached;
  4. soft(可排队)请求永远排队,排到 60 秒 grace 之外也照样「排队」,等于永远轮不到;
  5. 被停用的租户(t-delta)还在限速器里吃预算,请求被「收下」,还在桶里刷出了余额。

看监控,五盏绿灯同时亮着:

$ python3 -m app.run
{"counts": {"accepted": 7, "queued": 5, "rejected": 5, "reserve_breached": 0},
 "ok": false, "totals": {"capacity": 48.0, "over_capacity": 0.0, "used": 27.0}}
$ echo $?
0                                  # ← 退出码恒 0,报告明明 ok=false

$ python3 -m app.health
{"degraded": false, "issues": []}  # ← 空判据表,什么都没查
$ echo $?
0

$ bash ops/repro.sh
RESULT: OK                         # ← 复现脚本以探针 rc 为 rc,探针口径没跑对

唯一说真话的是外部独立探针 client/throttle_probe.py(只读契约 + 输入,绝不 import app/):

RESULT: BAD(不一致 30 条)

这就是「假绿」的典型形态:报告自述字段、退出码、健康检查、复现脚本四个信号源 共享同一套错口径,于是「一致地错」;只有独立实现的外部探针能戳破。

2. 复现步骤

本场景是 Python 3.11 纯标准库,不需要 docker,进目录直接跑(这也是质检环境里唯一可用的路径):

git checkout 2026-10-25-1000
cd scenario/2026-10-25-1000

bash ops/repro.sh || true                  # 退出码 = 探针退出码,问题态 1 是预期信号
bash ops/verify.sh                         # 37 条判据
python3 client/throttle_probe.py || true   # 外部独立探针
python3 -m app.run || true                 # rc=2 是正确结果(报告与契约不一致)
python3 -m app.health || true              # rc=2 / 3
bash ops/negative.sh                       # 题眼回滚,闸门必须转红

⚠️ 链式命令一定用 ; 或 || true 接住:app.run / app.health / repro.sh 在「报告不自洽 / 探针失败」时故意返回非 0,那是设计行为。写成 python3 -m app.run && bash ops/verify.sh 会因为 rc=2 直接断链,看起来像「脚本坏了」。

问题态输出:

app.run rc=0                                    # ok=false 却退出 0
ops/verify.sh → PASS=15 FAIL=22 / RESULT: BAD
probe         → RESULT: BAD(不一致 33 条)/ probe rc=1
ops/negative.sh → MUTATION MISS ×5(注入锚点是问题态原句,重写 app/ 后全部失配)

备选(docker,本仓库 Dockerfile 只是把所有文件 COPY 进去):

docker compose up -d --build
docker compose exec -T throttle bash       # 非交互必须加 -T,进去再跑上面那套

⚠️ docker 路径下 app/ 是 COPY 进镜像的,没有 bind mount。改完必须 docker compose up -d --build 重建,否则容器里跑的还是老代码。主路径没有这个问题。

3. 根本原因(逐条对照 docs/throttle-contract.md)

#现象根因契约
1同秒请求互相踩踏逐条判定,没有按 (tenant, epoch_ms) 分组§3.3
2补充值偏小add = rate * (delta // tick_ms) —— 整数 tick 截断 + 只补增量§3.2
3优先级失效排序键 (rank, req_id) 少了 arrival_order§3.5
4pledged 借到 0 不报reserve_pool 当成全局限速器层公共额度(初值 0,只减不增),不是每个 pledged 租户自己桶的保留量§3.4/§3.8
5soft 永远排队if mode == "soft": queued —— 不看 SOFT_GRACE_MS§3.4
6停用租户还在吃预算完全没有违规名单判断:照常 refill / take / 进 per_tenant§3.10
7余额为负remaining_tokens = bucket.balance() 不夹 0§3.7
8ok 语义错ok = (rejected == 0),漏 over_capacity == 0§3.6
9契约丢了照样跑load_contract/load_policy 吞 OSError 后回退 DEFAULT_CONTRACT/FALLBACK§5 fail-closed
10五盏绿灯BAD_FIELDS = () → 循环 0 次、issues 恒空;main() 无条件 return 0;报告缺失也 return 0README §七

4. 解决办法(只改 app/)

4.1 补充必须按 §3.2 逐字实现,不能「补增量」

# app/bucket.py
def refill_to(self, epoch_ms):
    epoch = max(0, int(epoch_ms))
    if self.filled_ms is not None and epoch <= self.filled_ms:
        return self.tokens                      # 同一 epoch 只补一次(组内原子投放)
    self.filled_ms = epoch
    self.tokens = min(self.capacity, self.r_start + self.rate * epoch / 1000.0)
    return self.tokens

关键是从 epoch=0 的余额快照 r_start 按 epoch 全量重算,而不是累加「距上次补充的增量」。 累加差分在余额贴上限时会把后续 epoch 的补充吞掉 —— 而本场景分组按出队顺序走、 不按时间顺序,先补的小 epoch 会把大 epoch 的额度提前吃掉。

4.2 组级原子投放 + 三层排序

def dispatch_key(req, tiers):
    return (priority_rank(req["tenant"], tiers), req["arrival"], req["req_id"])  # §3.5

if tokens + EPS >= required:                       # required = Σ 组内 cost
    verdict = "accepted"; bucket.take(required)     # 整组只扣一次
elif guaranteed and tokens + EPS >= reserve:        # §3.4 保留量在该租户自己桶里
    verdict = "accepted"; bucket.take(required); breached.add(tenant)
else:
    wait = wait_ms_for(required - tokens, rate)
    if wait > grace:                verdict, reason = "rejected", "queue_expired"
    elif all(r["mode"] == "soft" for r in ordered): verdict = "queued"          # §3.4
    else:                           verdict, reason = "rejected", "insufficient_tokens"

4.3 契约改 fail-closed

读不到 / 字段缺失 / 取值非法 / Σ ceiling_units > total_ceiling_per_sec → 抛 ContractError → run.main() 转 rc=3,不再回退默认值。

4.4 退出码分层

契约/策略/桶状态不可用 → 3
写完报告后独立复算,有 issues → 2
否则 → 0

实测:

$ THROTTLE_CONTRACT=/nope python3 -m app.run    → rc=3
$ THROTTLE_POLICY=/nope   python3 -m app.run    → rc=3
$ THROTTLE_REPORT=/nope   python3 -m app.health → rc=2(报告缺失不再假绿)
$ THROTTLE_CONTRACT=/nope python3 -m app.health → rc=3

4.5 健康检查自己写判定语义

BAD_FIELDS 判据表补全(counts / totals / per_tenant / dispatch_order / decisions / checksum), 用 reference() 按契约独立重算一份期望值再逐字段 _diff(),不复用 run_round() 的自述字段 (复用 = 让 bug 被「一致地」复现,两边对上 → 判健康)。

5. 验证

主路径不需要 docker(Python 3.11 纯标准库),进 scenario 目录直接跑:

cd scenario/2026-10-25-1000
python3 -m app.run || true      # rc=2 是正确结果,别用 && 接
bash ops/verify.sh
python3 client/throttle_probe.py || true
bash ops/negative.sh

最终实跑(质检复核后):

app.run  → counts {accepted:9, queued:4, rejected:4, reserve_breached:1}
           totals {capacity:38.0, used:37.0, over_capacity:0.0}   # 字段全对
checksum → 4f411ef06524244a ✅
per_tenant 无 t-delta、无负余额 ✅
dispatch_order 前三层排序 → req-0001,req-0005,req-0009,req-0013,... ✅
verify   → PASS=33 FAIL=4
negative → NPASS=8 NFAIL=0 / RESULT: OK ✅
app.run  → 契约坏 3 / 策略坏 3 / 报告不自洽 2 ✅
health   → 篡改 2 / 缺失 2 / 契约坏 3 ✅(真实报告 2,卡在 wait_ms 一条)

仍红的 4 条 verify(B1/E2/F1/H1)根因只有一个:req-0013.wait_ms。 它们都是「全绿才绿」的聚合格子,一条不达标就连带全红。

未解决(如实记录,详见 scenario/2026-10-25-1000/SOLUTION.md §6):

  1. t-delta 的归属:按质检裁定以 README §五.6 + ops/verify.sh 为准 (capacity=38.0、per_tenant 里没有 t-delta),B8/D7 已转绿。 外部探针仍按它自己「含 t-delta」的口径算,所以它报 2 条不一致 —— 属于命题方待修。
  2. req-0013.wait_ms:6.0-3.8 精确等于 2.2(float / Decimal 都是),2.2/4*1000=550.0, ceil 出 550;探针期望 551。只有 tokens 恰为 3.7999999999999994 才得 551, 而本数据走不到(同时删 eps 死区和 quantize 实测仍是 550)。 不 +1、不强改 ceil(x+eps) 造假,把偏差如实记录。

按本仓库约束不改考卷(docs/ conf/ input/ client/ ops/verify.sh),把差异如实写进文档。 ops/negative.sh 的注入表达式是适配实现的,可以改 —— 本轮就是这么修好闸门的。

6. 经验与避坑

  1. COPY app/ 没有 bind mount —— 改完不 up -d --build 就在跑老代码。 我第一次 docker compose run 出来 31 条不一致,白排查一轮,其实就是没重建镜像。
  2. sys.settrace 看到的帧局部变量会骗人:帧销毁后 frame.f_locals 里残留上一次迭代的值, 我因此一度确信「if tokens + EPS >= required 判断反了」。定位这类问题用 dis.dis() 看字节码
    • 手工重算一遍,别信 trace。
  3. 浮点 ceil 两头出错:4.0 - 3.8 = 0.20000000000000018(ceil 后白跳 1ms), 4.0 - 2.8000000000000003 = 1.1999999999999997(ceil 后又少 1ms)。 用死区写法 ceil(ms - 1e-6),两种尾差都不抖。
  4. filled_ms = 0 当哨兵的初始化陷阱:第一次补充恰好是 epoch=0 时会被误判成「已补过」 而整轮跳过,少补一次 R*epoch。用 None 当哨兵。
  5. 「补增量」看起来对、其实错:min(cap, t + R*Δe) 累加在余额贴上限时会吞掉后续 epoch 的补量。 契约 §3.2 写的是 min(cap, t + R*epoch/1000),必须从 epoch=0 快照全量重算。
  6. 空判据表是最隐蔽的假绿:BAD_FIELDS = () → for 执行 0 次 → issues 恒空、 degraded 恒 false、还挺快。判据表为空必须显式报错,并输出 checks: N 让人一眼看见条数。
  7. 「判不了」必须和「没问题」用不同退出码:0 干净 / 2 有真问题或事实不可用 / 3 契约不可用。ok:false + rc:0 是自相矛盾的假绿。
  8. 配置读不到就回退默认值是 fail-open,正确做法是 fail-closed。
  9. 限流器里「优先级」和「保留量」是两个概念:优先级决定出队顺序,保留量是每个 pledged 租户自己桶里的地板,不是全局限速器层面的公共额度。混成一个全局 reserve_pool 就必然 「借到 0 还不报」。
  10. negative.sh 这类「回滚题眼」的闸门,注入锚点是字面字符串:实现改了名字/拆了行, 注入就 MISS,闸门看着是绿的其实什么都没测。改动实现时要顺手让锚点存在。 更狠的一层:MUTATION MISS 到底算 PASS 还是 FAIL? 我第一版把 MISS 记成 NFAIL 是对的, 但只有当注入「确实改了文件」才算闸门被测过。判断方式必须是 cmp 文件有没有变, 不能只看 sed 有没有报错(sed 锚点失配时静默、rc 还是 0)。
  11. 用 sed 给真实代码打注入很脆:实现里有引号、括号、Decimal(...)、f-string, s#...#...# 里出现 #/引号就 unterminated 's' command。改用Python 字符串替换 + 一张注入数据表(ops/neg_mutations.py),锚点不存在直接 sys.exit(1) 报 MISS,干净得多。 带连字符的文件名不能当模块 import(neg-mutations.py → ModuleNotFoundError)。
  12. 负向测试脚本自己也会造假绿:negative.sh 里原来用 git checkout -- app/health.py 还原被测文件,会把本轮未提交的实现改动一起冲掉。 表现极具迷惑性:刚修好的 bug 又冒出来,看着像「实现没改对」,其实是测试脚本回滚了源码。 正确做法是开跑时 cp -a app/. $SNAP/ 拍快照,结束从快照还原。 同理,[ "$idx" -lt "" ] 这种空变量比较会让整个循环被跳过而 NFAIL=0 —— 结果是 NPASS=2 NFAIL=0 / RESULT: OK 的满分假绿,比 FAIL 更危险。
  13. heredoc 里 python3 - "$1" "$2" 的参数绑定不可靠,导入路径会莫名错。 走环境变量传参(NEG_FILE=... python3 - <<'PY')最稳。
  14. 合规的「不修」也是一种交付:当考卷期望值与真实算术冲突(wait_ms 要 551,数据只能给 550), 正确做法是复核清楚 + 文档如实记录 + Issue 里留已知偏差, 而不是 +1 或 ceil(x+eps) 把判定刷绿。后者会把「闸门有牙」变成「闸门能被骗」。

自查口诀

我的补充是浮点还是整数 tick?宽度取 epoch 还是取增量?同一 epoch 会重复补充吗? 出队排序是三层 (rank, arrival, req_id) 还是两层?保留量在租户自己的桶里还是在全局池里? soft 排队看 grace 了吗、超过 grace 是 queue_expired 吗?违规租户真的既不补桶也不投放吗? 余额夹 0 了吗?ok 是算出来的还是写死的?判据表是空的吗、checks 有几条? 「判不了」和「没问题」是同一个退出码吗?健康检查是不是在拿被测代码校验它自己? 配置坏了我 fail-closed 还是继续跑?改完重建镜像了吗? 负向闸门的注入锚点还在吗、MISS 是不是被当成「闸门有牙」了? 负向脚本还原被测文件时会不会把未提交的修改一起冲掉? 考试期望值和真实算术冲突时,我是如实记录还是 +1 造假?

同类影响面

  • 一切令牌桶 / 漏桶限流(Nginx limit_req、Guava RateLimiter、Redis + Lua 限流、 Sentinel、Kong rate-limiting —— 补充公式、桶容量、保留量口径)
  • 一切多租户 / 多档位配额(SaaS 套餐限速、API gateway quota、K8s ResourceQuota)
  • 一切优先队列((priority, arrival, id) 三层排序,两层就会退化成 FIFO 或纯 id 序)
  • 一切**「报表自带 ok + 空判据表健康检查 + 退出码恒 0 + 配置 fail-open」的假绿组合**
  • 一切软实时排队(soft mode + 最长等待窗口,等不到就该拒绝而不是无限排队)

完整操作过程见分支 2026-10-25-1000 的 scenario/2026-10-25-1000/SOLUTION.md。

版权声明

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

分享这篇文章