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

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。

按契约手算的正解(一张表定全部期望):

租户各窗口用量最新窗口起点已用保留量committedlimit判定
t-alpha1330 / 900 / 50 / 1201765507200120120+999=111912391000deny, over 239
t-beta150 / 301765507200304070100allow, remaining 30
t-gamma70 / 4517655072004518022550deny, 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 的典型形态。

  1. 窗口锚点读错来源(loader.load_window_anchor):先读 conf/engine.env 的 ENGINE_WINDOW_EPOCH_OFFSET_S(=0),命中就直接 return,startup_marker.txt 永远读不到。 → 所有事件被算成落在 [0, 3600) 起的窗口,epoch_anchor 报 0。 引擎的构建期默认值不是契约,更不是事实。
  2. 「最新窗口」取错对象(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)。
  3. 保留量按 expires_at 过滤:契约明写保留量是「已经扣掉的额度,恒有效,expires_at 只用于上游对账、 不得用来过滤」。过滤掉 999 那条 → t-alpha 只算 120。
  4. 额度允许为负:committed 不再 max(0, …) → over_units = -30、remaining_units 出现负数。
  5. 汇总上限用了 max:budget.ceiling_units 是「各租户天花板之和」(1000+100+50=1150), 写成 max 就变成 1000。「单租户上限」和「所有租户上限之和」长得几乎一样,是两个聚合层级。
  6. counts.events 报了租户数:len(sums)(3)而不是 len(events)(12), 与同一份报告里的 totals.events=12 自相矛盾 —— 同一个事实被算了两遍。
  7. 重置提醒反了:判据只写 seconds_to_reset <= reserve_before_s, 没排除 =0(已到重置时刻)也没排除 deny。t-beta 的 seconds_to_reset=0 被当成「快重置了」。
  8. ok 只看计数不看预算:ok = allow+deny == len(windows),budget.over_units>0 也照样 true。
  9. 一层假绿(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

六、经验与避坑

通用教训(配额 / 限流 / 计费类系统)

  1. 「锚点」「上限」「窗口」这类口径参数,先问「权威来源是哪个文件」。 引擎的构建期默认值、环境变量默认值、FALLBACK 字典都不是契约。 同一个字段存在多份来源时,立刻写下「谁是唯一权威」,其余全部删掉。
  2. 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 ... ),别让元组整体参与比较。
  3. 「保留 / 预留 / 已扣」的量恒有效。 expires_at、ttl、renew_at 是上游对账用的, 不要顺手拿来做 if 过滤 —— 一条「逻辑上已过期」的预留,钱也真的已经扣了。
  4. 两个容量参数长得几乎一样时,先确定聚合顺序再写 min/max/sum: ceiling_units 是逐租户求和,max 得到的是「最大的那个租户」。
  5. 额度类字段一律 max(0, …):over、remaining、seconds_to_reset 出现负数就是符号错了。
  6. 同一份报告里同一事实只算一次。 counts.events 与 totals.events 必须是同一个数字 —— 两处各算一遍,迟早有一处算错,而且报告自己会自相矛盾(这份报告就是)。
  7. 提醒类逻辑要两头都卡:0 < seconds_to_reset <= threshold。 只写 <= threshold 会让「已经到点(=0)」和「已经过点(负数,若没 clamp)」都进提醒。

关于「假绿」与闸门

  1. 空判据表是最隐蔽的假绿:BAD_FIELDS = () 时 for 循环体执行 0 次, 代码看着像有检查、其实在空转。任何遍历判据表的检查器都要对「判据表为空」显式报错。
  2. 「判不了」必须和「没问题」用不同退出码:0 干净 / 2 有真问题或拿不到事实 / 3 契约不可用。 契约一丢就 except OSError: pass 回退默认值,等于静默骗过所有人(fail-open)。
  3. 健康检查绝不能复用被测代码重算。我第一版 health.py 调了 run_round, 回滚题眼后依旧 degraded=false rc=0 —— 「自己治自己」永远不会失败。
  4. 「复用纯算术」和「复用判定语义」要分开看。把 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`。

版权声明

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

分享这篇文章