Python 固定窗口配额重置器 bug 全解:窗口锚点读错、只取最新窗口、保留量按 expires_at 过滤、汇总 ceiling 用 max、额度为负、空判据表假绿健康检查
日期:2026-10-18 · 环境:Docker / Python 3.11 · 技术栈:固定窗口限流(fixed window rate limiter)、配额重置器、Python、JSON 报告、健康检查、退出码分层 关键词:固定窗口配额限流、window_anchor 窗口锚点、startup_marker、最新窗口 latest window、reserved 保留量 expires_at、ceiling_units sum 还是 max、remaining_units 负数、reset_reminder 重置提醒、counts.events、空判据表、假绿健康检查、fail-closed、退出码 0/2/3、配额超额 over_quota
一、问题背景
一个 API 网关背后的固定窗口配额重置器:读用量事件流 usage/events.tsv、已扣掉的保留量 state/reserved.json、
引擎时钟 state/engine_clock.txt、启动时记下的窗口锚点 usage/startup_marker.txt,
按窗口归并用量 → 逐租户判额度 → 汇总预算 → 出重置提醒,写 state/quota-report.json。
窗口是 W=3600 的固定窗口,锚点 window_anchor=1765500000(边界 …500000/…503600/…507200/…510800),
引擎时钟 now=1765508500。
线上四个绿灯,没有一件指向真相:
app.run rc=0
{"degraded": false, "engine": "quota-reset", "issues": []} health rc=0
报告字段齐全、totals={"allow":1,"deny":2,...}
症状:「每小时的额度从来没归零」(每个租户报的都是第一个窗口的用量)、
近 2000 单位用量被整窗丢掉(budget.used_units 报 245,正解 195)、
租户额度表越限(totals.over_units 报 464,正解 414)、reset_reminder 报了不该报的租户。
唯一说真话的是外部独立探针:RESULT: BAD(不一致 18 条)。
二、复现步骤
cd scenario/2026-10-18-1601
docker compose up -d --build
docker compose exec -T quota bash # 非交互环境必须加 -T
bash ops/repro.sh ; echo "repro rc=$?" # 用 ; 接住!问题态 rc=1 是预期信号
bash ops/verify.sh ; echo "verify rc=$?"
python3 -m app.health ; echo "health rc=$?"
问题态输出:
totals {"allow":1,"deny":2,"events":12,"over_units":464,"units_sum":2695}
budget {"ceiling_units":1150,"over_units":0,"reserved_units":1339,"used_units":245}
counts {"events":3,"total":3}
reset_reminder [{"seconds_to_reset":0,"tenant_id":"t-beta"}]
contract.epoch_anchor = 0
t-alpha win=[1765504800,1765508400) used=170 deny/over_quota
t-beta win=[1765504800,1765508400) used=30 allow/within_limit
t-gamma win=[1765508400,1765512000) used=45 deny/over_quota
{"degraded": false, "engine": "quota-reset", "issues": []} health rc=0
RESULT: BAD(不一致 18 条) probe rc=1
ops/verify.sh:PASS=14 FAIL=26 / RESULT: BAD。
按契约手算的正解(一张表定全部期望):
| 租户 | 各窗口用量 | 最新窗口起点 | 已用 | 保留量 | committed | limit | 判定 |
|---|---|---|---|---|---|---|---|
| t-alpha | 1330 / 900 / 50 / 120 | 1765507200 | 120 | 120+999=1119 | 1239 | 1000 | deny, over 239 |
| t-beta | 150 / 30 | 1765507200 | 30 | 40 | 70 | 100 | allow, remaining 30 |
| t-gamma | 70 / 45 | 1765507200 | 45 | 180 | 225 | 50 | deny, over 175 |
seconds_to_reset = 1765510800 - 1765508500 = 2300;
budget = {ceiling 1150, used 195, reserved 1339, over 0};
totals = {events 12, units_sum 2695, allow 1, deny 2, over 414};reset_reminder = []。
三、根本原因
九个根因,前八个「计算公式本身是对的、喂给它的东西是错的」——这是配额类 bug 的典型形态。
- 窗口锚点读错来源(
loader.load_window_anchor):先读conf/engine.env的ENGINE_WINDOW_EPOCH_OFFSET_S(=0),命中就直接return,startup_marker.txt永远读不到。 → 所有事件被算成落在[0, 3600)起的窗口,epoch_anchor报 0。 引擎的构建期默认值不是契约,更不是事实。 - 「最新窗口」取错对象(
reset.evaluate):latest = max(mine, key=lambda k: k[1]), 而mine是(tenant, window_start)元组列表 ——k[1]看着像窗口起点, 但max(..., key=...)的k是整个元组,于是按「(租户名, 起点) 的字典序」比较, 实际落在首窗。正确写法start = max(start for (name, start) in used if name == tenant)。 - 保留量按
expires_at过滤:契约明写保留量是「已经扣掉的额度,恒有效,expires_at只用于上游对账、 不得用来过滤」。过滤掉999那条 →t-alpha只算 120。 - 额度允许为负:
committed不再max(0, …)→over_units = -30、remaining_units出现负数。 - 汇总上限用了
max:budget.ceiling_units是「各租户天花板之和」(1000+100+50=1150), 写成max就变成 1000。「单租户上限」和「所有租户上限之和」长得几乎一样,是两个聚合层级。 counts.events报了租户数:len(sums)(3)而不是len(events)(12), 与同一份报告里的totals.events=12自相矛盾 —— 同一个事实被算了两遍。- 重置提醒反了:判据只写
seconds_to_reset <= reserve_before_s, 没排除=0(已到重置时刻)也没排除deny。t-beta的seconds_to_reset=0被当成「快重置了」。 ok只看计数不看预算:ok = allow+deny == len(windows),budget.over_units>0也照样true。- 一层假绿(
app/health.py+app/contract.py):BAD_FIELDS = ()—— 空判据表,for field in BAD_FIELDS:循环体执行 0 次,issues恒空 →degraded恒false;main()无条件return EXIT_OK,「判不了」被当成「没问题」;load_contract()用FALLBACK+except OSError: pass,契约读不到就用默认值继续跑(fail-open)。
核心认知:前 8 条里任何一条单独存在,报告都会「看起来很正常」; 第 9 条才让它变成「四个绿灯」。修 bug 只解决这一次,修闸门才解决下一次。
四、解决办法
只改 app/,conf/、docs/、usage/、state/reserved.json、client/、ops/ 一字未动
(改考卷或探针让判定变绿 = 造假)。
1. 锚点只从启动快照读
def load_window_anchor():
path = os.path.join(usage_dir(), "startup_marker.txt")
with open(path) as fh:
for raw in fh:
line = raw.strip()
if not line or line.startswith("#") or "window_anchor=" not in line:
continue
return int(line.split("window_anchor=", 1)[1].split()[0])
raise FactsError("startup_marker.txt 里没有 window_anchor")
engine.env 的 ENGINE_WINDOW_EPOCH_OFFSET_S 彻底不参与;窗口长度也改成读契约 conf["window_seconds"]
(原来用 engine["ENGINE_WINDOW_SECONDS"],默认值碰巧一样 → 隐藏坑)。
2. 取最新窗口 + 保留量恒有效 + 额度不许为负
for tenant in sorted({e["tenant_id"] for e in events}):
start = max(start for (name, start) in used if name == tenant) # 只取该租户最新窗口
used_units = used[(tenant, start)]
limit = conf["tenant_limits"].get(tenant, conf["default_limit"])
committed = used_units + reserved.get(tenant, 0)
over = max(0, committed - limit)
remaining = max(0, limit - committed)
for item in reservations: # 不过滤 expires_at
reserved[item["tenant_id"]] = reserved.get(item["tenant_id"], 0) + item["units"]
3. 汇总:上限求和、提醒加下界、counts 同源、ok 看预算
reminder = [
{"tenant_id": row["tenant_id"], "seconds_to_reset": row["seconds_to_reset"]}
for row in windows
if row["remaining_units"] > 0
and 0 < row["seconds_to_reset"] <= conf["reserve_before_s"]
]
budget = {
"ceiling_units": sum(row["limit"] for row in windows), # 求和,不是 max
"used_units": used_sum,
"reserved_units": sum(item["units"] for item in reservations),
"over_units": max(0, used_sum - sum(row["limit"] for row in windows)),
}
counts = {"total": len(windows), "events": len(events)} # 与 totals.events 同一个事实
ok = (allow + deny == len(windows)) and budget["over_units"] == 0
4. 契约 fail-closed
class ContractError(Exception): ...
for key in REQUIRED: # 必填字段缺失直接抛
if raw.get(key, "") == "":
raise ContractError("必填字段缺失:%s" % key)
if policy not in ("cap", "warn"):
raise ContractError("QUOTA_CAPACITY_POLICY 非法:%r" % policy)
if window_seconds <= 0 or default_limit < 0:
raise ContractError("口径非法")
app/run.py:except ContractError / FactsError → return 3。
5. 健康检查:独立重算 + 非空判据表 + 退出码分层
EXIT_OK, EXIT_PROBLEM, EXIT_CONTRACT = 0, 2, 3
BAD_FIELDS = ("windows", "totals", "budget", "reset_reminder", "counts", "contract", "ok")
if not BAD_FIELDS: # 判不了 ≠ 没问题
print(... "no_checks: 判据表为空" ...); return EXIT_PROBLEM
exp = _reference(conf) # 自己读 facts 按契约重算,不 import reset/loader
_diff(exp, report, issues) # 逐字段对照(windows 按 tenant_id 对行)
return EXIT_PROBLEM if issues else EXIT_OK
关键:app/health.py 不 import app/reset.py、也不 import app/loader.py。
复用被测代码的检查器会「自己给自己发合格证」——我把题眼回滚后它照样 degraded=false rc=0。
五、验证
docker compose up -d --build # app/ 是 COPY 进镜像、compose 无 bind mount,改完必须重建
docker compose exec -T quota bash
bash ops/repro.sh ; echo rc=$?
bash ops/verify.sh ; echo rc=$?
bash ops/negative.sh ; echo rc=$?
python3 ops-extra/negative-per-bug.py ; echo rc=$?
修复后:
repro RESULT: OK / probe rc=0 / repro rc=0
verify PASS=50 FAIL=0 / RESULT: OK (问题态 PASS=14 FAIL=26)
health {"degraded": false, "engine": "quota-reset", "issues": []} rc=0
修复后报告要点:
{"windows":[{"tenant_id":"t-alpha","window_start":1765507200,"window_reset_at":1765510800,
"seconds_to_reset":2300,"limit":1000,"used_units":120,"reserved_units":1119,
"committed_units":1239,"allowed_units":120,"remaining_units":0,"over_units":239,
"verdict":"deny","reason":"over_quota"}, ...],
"totals":{"events":12,"units_sum":2695,"allow":1,"deny":2,"over_units":414},
"budget":{"ceiling_units":1150,"used_units":195,"reserved_units":1339,"over_units":0},
"reset_reminder":[], "counts":{"total":3,"events":12},
"contract":{"epoch_anchor":1765500000,...}, "ok":true}
闸门有牙(负向验证):逐题眼按语义回滚 → 探针/健康检查必须转红,
ops-extra/negative-per-bug.py PASS=16 FAIL=0:
| 回滚项 | 期望/实际 |
|---|---|
| 锚点取引擎默认值 0 | 探针 rc=1、health rc=2 |
保留量按 expires_at 过滤 | 探针 rc=1 |
| 取最旧窗口 | 探针 rc=1 |
remaining/over 允许为负 | 探针 rc=1 |
ceiling 用 max | 探针 rc=1 |
counts.events 报租户数 | 探针 rc=1 |
| 窗口长度用引擎默认值 1800 | 探针 rc=1 |
ok 不看预算(构造事实下) | 探针 rc=1、health rc=2 |
| 判据表挖空 | health rc=2(no_checks,不许假绿) |
| 契约缺失 / 口径非法 | app.run、health 均 rc=3 |
六、经验与避坑
通用教训(配额 / 限流 / 计费类系统)
- 「锚点」「上限」「窗口」这类口径参数,先问「权威来源是哪个文件」。
引擎的构建期默认值、环境变量默认值、
FALLBACK字典都不是契约。 同一个字段存在多份来源时,立刻写下「谁是唯一权威」,其余全部删掉。 max(x, key=...)的key拿到的是整个元素。 元素是元组(tenant, start)时,key=lambda k: k[1]是对的、max(mine, key=lambda k: k[1])里k也是元组 —— 但一旦有人写成max(mine)或max(mine, key=lambda k: k),就退化成字典序。 取「最新」永远显式写max(start for ... ),别让元组整体参与比较。- 「保留 / 预留 / 已扣」的量恒有效。
expires_at、ttl、renew_at是上游对账用的, 不要顺手拿来做if过滤 —— 一条「逻辑上已过期」的预留,钱也真的已经扣了。 - 两个容量参数长得几乎一样时,先确定聚合顺序再写
min/max/sum:ceiling_units是逐租户求和,max得到的是「最大的那个租户」。 - 额度类字段一律
max(0, …):over、remaining、seconds_to_reset出现负数就是符号错了。 - 同一份报告里同一事实只算一次。
counts.events与totals.events必须是同一个数字 —— 两处各算一遍,迟早有一处算错,而且报告自己会自相矛盾(这份报告就是)。 - 提醒类逻辑要两头都卡:
0 < seconds_to_reset <= threshold。 只写<= threshold会让「已经到点(=0)」和「已经过点(负数,若没 clamp)」都进提醒。
关于「假绿」与闸门
- 空判据表是最隐蔽的假绿:
BAD_FIELDS = ()时for循环体执行 0 次, 代码看着像有检查、其实在空转。任何遍历判据表的检查器都要对「判据表为空」显式报错。 - 「判不了」必须和「没问题」用不同退出码:
0干净 /2有真问题或拿不到事实 /3契约不可用。 契约一丢就except OSError: pass回退默认值,等于静默骗过所有人(fail-open)。 - 健康检查绝不能复用被测代码重算。我第一版
health.py调了run_round, 回滚题眼后依旧degraded=false rc=0—— 「自己治自己」永远不会失败。 - 「复用纯算术」和「复用判定语义」要分开看。把
build_round()整个拿来当「独立重算」, 连ok都是它算的 —— 而ok恰恰是被测代码自己的判定。实证:把ok回滚成不看预算、 构造used_sum 3089 > ceiling 1150的事实,health rc=0(假绿),只有外部探针rc=1报「ok:期望 False 实际 True」。修法:ok必须由 health 按契约自己写表达式:
def contract_ok(expected):
windows = expected["windows"]
counted = expected["totals"]["allow"] + expected["totals"]["deny"]
used = sum(row["used_units"] for row in windows)
ceiling = sum(row["limit"] for row in windows)
return (counted == len(windows)) and max(0, used - ceiling) == 0
expected = dict(expected, ok=contract_ok(expected)) # 覆盖掉复用的值
```
**口诀:纯算术可以复用,判定语义必须自己写。**
11. **把题眼回滚,看闸门会不会转红**。这是唯一能证明「修好了」而不是「看起来修好了」的方法。
### 踩坑清单(可复现)
12. **`ops/repro.sh` 的退出码 = 探针退出码**,问题态返回 `1` 是**预期信号**,
用 `; echo rc=$?` 接住,**别用 `&&`**(否则后面步骤全被中断)。
13. **`app/` 是 `COPY` 进镜像、compose 无 bind mount**:改完宿主机文件必须
`docker compose up -d --build`,否则「改完毫无变化」。`__pycache__` 也会掩盖改动。
14. **`docker compose exec` 非交互必须加 `-T`**。
15. **`docker compose cp` 复制目录不会删除目标里的旧文件** —— 我一度在容器里跑到旧脚本、
结果对不上宿主机文件。改单文件请 `cp 文件:绝对路径`,或直接重建镜像。
16. **负向用例绑死源码文本 = 脆弱的闸门**。官方 `ops/negative.sh` 用字符串替换注入 bug,
实现重写后 4 处失效:`MUTATION MISS`、注入后代码直接 `KeyError` 崩溃(探针返回 2 而不是 1)、
以及一条用例的期望值本身是「fail-open 时的错误行为」(我修成 fail-closed 后期望就不成立)。
**正确做法是按语义注入、用真实退出码判定,而不是把代码写回 bug 的形状。**
17. **`docker compose cp` 复制目录不会删除目标里的旧文件** —— 我一度在容器里跑到旧脚本、
结果和宿主机文件对不上,白排查了一轮。改单文件请 `cp 文件:容器绝对路径`,或直接重建镜像。
18. **`git archive` / 新 clone 出来的仓库没有 `state/`**(被 `.gitignore` 忽略),
`docker build` 会报 `failed to compute cache key: "/state": not found`。
必须按题面补齐 `state/reserved.json`、`state/engine_clock.txt`;
`budget.reserved_units=1339` 是校验事实是否还原正确的钩子(多塞一条会变 1364)。
19. **写「负向验证脚本」时别把正则/替换写得太随意**:我用
`re.search(r'"ok":\s*(.+?,\s*\n)')` 只取到表达式部分(不含 `"ok": ` 前缀),
却又拼上了 `"ok": ` 前缀,生成出 `"ok": "ok": allow + deny == ...` 的语法错误代码,
结果是「注入失败」被误判成「闸门不红」。**注入后先 `app.run` 跑一次、看 rc 和 stderr。**
20. **有些 bug 形状在默认数据下不可观测**:`totals.over_units` 写成「只累加 deny 租户」,
因为 `verdict=allow ⇔ over=0`,与「全累加」**恒等**;`ok` 不看预算时,
默认数据 `budget.over_units` 恒 0 也测不出来。**必须构造事实**
(`used_sum 3089 > ceiling 1150`)才能让闸门转红。
### 自查口诀
**锚点从哪个文件读?这个 `max()` 比的到底是哪个字段?保留量该不该看 `expires_at`?
`remaining`/`over` 会不会为负?「上限」是单个租户的还是要求和?
`seconds_to_reset=0` 该提醒吗?`counts.events` 和 `totals.events` 是同一个事实吗?
判据表是不是空的?拿不到契约时我拒跑还是用默认值?
把题眼回滚之后,我的闸门会不会转红?默认数据测不出来的语义,我构造事实测了吗?**
### 同类影响面
一切**固定窗口 / 滑动窗口限流**(`nginx limit_req`、Redis `INCR`+`EXPIRE`、Sentinel 固定窗口、
Kong/Envoy rate limit、`window_start = anchor + (ts-anchor)//W*W`);
一切**配额/余额计算**(保留量、预留、已扣、`committed = used + reserved`、`remaining = max(0, limit-committed)`);
一切**周期性重置的提醒与告警**(`seconds_to_reset`、`reset_reminder`);
以及一切**「自述字段自证 + 空判据表 + 退出码恒 0 + 配置读不到静默回退默认值」的假绿健康检查**。
后者**比没有检查更危险**,因为它静默地骗人。
完整操作记录见 `scenario/2026-10-18-1601/SOLUTION.md`。
