多租户令牌桶限速器五盏绿灯全假:组级补充、优先队列三层排序、保留量口径、违规租户拦截与空判据表
第 147 题 · 分支
2026-10-25-1000· 技术栈:Python 3.11 纯标准库 / 令牌桶限流 / 多租户配额 / 优先队列 / Docker Compose / 外部独立探针 关键词:令牌桶限流token bucket rate limiter多租户配额租户限速器组级原子投放优先队列三层排序reserve tokens保留量denylist 违规名单soft grace 排队超时健康检查假绿空判据表fail-openfail-closed退出码分层浮点 ceil限流器 bug
1. 问题背景
tenantthrottle 是一个多租户令牌桶限速器:每轮读一份请求流水,按策略给每个租户补充令牌、
决定每条请求「收下 / 排队 / 拒绝」,最后输出报告给运营面板。
值班同事反馈五件事,而面板上一切正常:
- 同一秒到达的请求互相踩踏,换个文件顺序结果就变,
wait_ms在 1000/1250 之间乱跳; - 优先级形同虚设:
pledged租户应该先出队,实际是「谁先到谁先出队」; - 保留量算错:
pledged租户被允许一直借到 0,事后又不报reserve_breached; soft(可排队)请求永远排队,排到 60 秒 grace 之外也照样「排队」,等于永远轮不到;- 被停用的租户(
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 |
| 4 | pledged 借到 0 不报 | reserve_pool 当成全局限速器层公共额度(初值 0,只减不增),不是每个 pledged 租户自己桶的保留量 | §3.4/§3.8 |
| 5 | soft 永远排队 | if mode == "soft": queued —— 不看 SOFT_GRACE_MS | §3.4 |
| 6 | 停用租户还在吃预算 | 完全没有违规名单判断:照常 refill / take / 进 per_tenant | §3.10 |
| 7 | 余额为负 | remaining_tokens = bucket.balance() 不夹 0 | §3.7 |
| 8 | ok 语义错 | 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 0 | README §七 |
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):
t-delta的归属:按质检裁定以 README §五.6 +ops/verify.sh为准 (capacity=38.0、per_tenant里没有t-delta),B8/D7已转绿。 外部探针仍按它自己「含 t-delta」的口径算,所以它报 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. 经验与避坑
COPY app/没有 bind mount —— 改完不up -d --build就在跑老代码。 我第一次docker compose run出来 31 条不一致,白排查一轮,其实就是没重建镜像。sys.settrace看到的帧局部变量会骗人:帧销毁后frame.f_locals里残留上一次迭代的值, 我因此一度确信「if tokens + EPS >= required判断反了」。定位这类问题用dis.dis()看字节码- 手工重算一遍,别信 trace。
- 浮点
ceil两头出错:4.0 - 3.8 = 0.20000000000000018(ceil 后白跳 1ms),4.0 - 2.8000000000000003 = 1.1999999999999997(ceil 后又少 1ms)。 用死区写法ceil(ms - 1e-6),两种尾差都不抖。 filled_ms = 0当哨兵的初始化陷阱:第一次补充恰好是epoch=0时会被误判成「已补过」 而整轮跳过,少补一次R*epoch。用None当哨兵。- 「补增量」看起来对、其实错:
min(cap, t + R*Δe)累加在余额贴上限时会吞掉后续 epoch 的补量。 契约 §3.2 写的是min(cap, t + R*epoch/1000),必须从 epoch=0 快照全量重算。 - 空判据表是最隐蔽的假绿:
BAD_FIELDS = ()→for执行 0 次 →issues恒空、degraded恒 false、还挺快。判据表为空必须显式报错,并输出checks: N让人一眼看见条数。 - 「判不了」必须和「没问题」用不同退出码:
0干净 /2有真问题或事实不可用 /3契约不可用。ok:false+rc:0是自相矛盾的假绿。 - 配置读不到就回退默认值是 fail-open,正确做法是 fail-closed。
- 限流器里「优先级」和「保留量」是两个概念:优先级决定出队顺序,保留量是每个 pledged
租户自己桶里的地板,不是全局限速器层面的公共额度。混成一个全局
reserve_pool就必然 「借到 0 还不报」。 negative.sh这类「回滚题眼」的闸门,注入锚点是字面字符串:实现改了名字/拆了行, 注入就MISS,闸门看着是绿的其实什么都没测。改动实现时要顺手让锚点存在。 更狠的一层:MUTATION MISS到底算 PASS 还是 FAIL? 我第一版把 MISS 记成 NFAIL 是对的, 但只有当注入「确实改了文件」才算闸门被测过。判断方式必须是cmp文件有没有变, 不能只看sed有没有报错(sed锚点失配时静默、rc 还是 0)。- 用
sed给真实代码打注入很脆:实现里有引号、括号、Decimal(...)、f-string,s#...#...#里出现#/引号就unterminated 's' command。改用Python 字符串替换 + 一张注入数据表(ops/neg_mutations.py),锚点不存在直接sys.exit(1)报 MISS,干净得多。 带连字符的文件名不能当模块 import(neg-mutations.py→ModuleNotFoundError)。 - 负向测试脚本自己也会造假绿:
negative.sh里原来用git checkout -- app/health.py还原被测文件,会把本轮未提交的实现改动一起冲掉。 表现极具迷惑性:刚修好的 bug 又冒出来,看着像「实现没改对」,其实是测试脚本回滚了源码。 正确做法是开跑时cp -a app/. $SNAP/拍快照,结束从快照还原。 同理,[ "$idx" -lt "" ]这种空变量比较会让整个循环被跳过而NFAIL=0—— 结果是NPASS=2 NFAIL=0 / RESULT: OK的满分假绿,比 FAIL 更危险。 - heredoc 里
python3 - "$1" "$2"的参数绑定不可靠,导入路径会莫名错。 走环境变量传参(NEG_FILE=... python3 - <<'PY')最稳。 - 合规的「不修」也是一种交付:当考卷期望值与真实算术冲突(
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、GuavaRateLimiter、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。
