验证请求限流的精确时间边界:设计原理
一句话总结
用假时钟检查滑动窗口和 Retry-After,并把每个用户的限额分开。
为什么需要它
流量一增加,服务器就开始把所有请求记录在同一个列表里。一个用户的连续请求,把别的用户的正常请求也拦住了。从窗口的最后时刻删除条目时,比较运算也写错了,导致限制多保持了 1 秒。而真的等上几十秒的测试,会让这种边界既慢又不稳定。
工作原理
如果把时钟作为函数参数接收,就可以不必等待而直接移动到精确的时间点。有效窗口只包含大于 now-window 的时间戳。只记录被允许的请求,被拒绝的请求不会扩大窗口。已满时,把到最旧的被允许请求过期为止的时间向上取整,作为 Retry-After 发送。最后比较同一个用户的第三次请求和另一个用户的第一次请求。
client id → 해당 키의 기록 → 만료 제거 → 여유 있음: 기록+200
└→ 꽉 참: 기록 보존+429
阅读契约并预测失败的工作表
下面不是让你把整个实现背下来的答案,而是分步骤的代码评审。每个改动片段都故意破坏了契约。请注意,改动之后,正常用例仍然可能通过。在运行之前,先预测观测哪些输入、异常和状态才能看出差异,实现之后,再把这个预测与结果进行比较。
1. 验证设置
validate_limit(limit, window) 只允许排除 bool 的正 int limit,以及正的有限 int/float window,并返回 (limit, float(window))。其余都是 ValueError。
判断依据:bool 是 int 的子类型。NaN 和无穷大也必须另外拒绝。
待评审的错误改动片段:
not isinstance(limit, int)
请与包含该片段的函数的公开契约对照。如果仅凭一个成功用例无法区分,就把应被拒绝的输入或失败之后的状态选作观测对象。
2. 排除窗口的左边界
active(history, now, window) 只把大于 now-window 的时间,按原来的顺序作为新列表返回。history 是排好序、非递减的时间。
判断依据:确认保留恰好过期的时间的 >= 与 > 的区别。
待评审的错误改动片段:
stamp >= now - window
请与包含该片段的函数的公开契约对照。如果仅凭一个成功用例无法区分,就把应被拒绝的输入或失败之后的状态选作观测对象。
3. 把等待时间向上取整
retry_after(history, now, window) 是:对已经清理过、非空的 history,取第一个时间+window-now 的 ceil 值与 0 中较大的整数。空列表为 0。
判断依据:如果只剩 0.2 秒就把 Retry-After 写成 0,客户端会立刻重新请求。
待评审的错误改动片段:
int(history[0] + window - now)
请与包含该片段的函数的公开契约对照。如果仅凭一个成功用例无法区分,就把应被拒绝的输入或失败之后的状态选作观测对象。
4. 按键分开记录
history_for(state, key) 在键不存在时返回空列表,存在时返回该记录的副本。不要仅仅因为查询就修改 state。
判断依据:如果返回共享列表,一个请求的清理就可能改变另一个请求的记录。
待评审的错误改动片段:
state.get(key, [])
请与包含该片段的函数的公开契约对照。如果仅凭一个成功用例无法区分,就把应被拒绝的输入或失败之后的状态选作观测对象。
5. 只记录被允许的请求
admit(state, key, now, limit, window) 在验证设置之后,清理该键的过期记录。有余量时追加 now 并返回 (True,0),已满时不追加,返回 (False,retry_after)。
判断依据:如果追加被拒绝的请求,每次重试都会使过期时间被推后。
待评审的错误改动片段:
len(history) > limit
请与包含该片段的函数的公开契约对照。如果仅凭一个成功用例无法区分,就把应被拒绝的输入或失败之后的状态选作观测对象。
6. 验证客户端键
client_key(value) 对 1–40 个字符的 ASCII 字母、数字、连字符的字符串原样返回,其余都是 ValueError。
判断依据:限制输入的范围,避免无限制的键大小挤占状态内存。
待评审的错误改动片段:
<= 80
请与包含该片段的函数的公开契约对照。如果仅凭一个成功用例无法区分,就把应被拒绝的输入或失败之后的状态选作观测对象。
7. 构造拒绝响应
limited_response(wait) 是一个 JSONResponse:状态为 429,正文为 {error:'rate_limited'},Retry-After 头是把 wait 转成字符串的值。
判断依据:让客户端知道重试的时间,所以状态和头一起发送。
待评审的错误改动片段:
status_code=503
请与包含该片段的函数的公开契约对照。如果仅凭一个成功用例无法区分,就把应被拒绝的输入或失败之后的状态选作观测对象。
8. 用虚拟时间完成请求流程
create_app(clock, limit=2, window=10) 在 GET /work 中检查 X-Client-ID:非法的键返回 400 {error:'invalid_client'},允许时返回 200 {ok:True},超出时返回 limited_response。state 按应用分开。
判断依据:不要真的 sleep,而要通过 clock 函数传递装在列表里的当前时间。
待评审的错误改动片段:
admit(state, "shared", clock(), limit, window)
请与包含该片段的函数的公开契约对照。如果仅凭一个成功用例无法区分,就把应被拒绝的输入或失败之后的状态选作观测对象。
在现场相遇的样子
这是一个位于进程内存中、面向单个 worker 的示例。它不保证由多个 Pod 共享的全局限额,也不保证恶意客户端的身份。X-Client-ID 是测试用的键,所以在生产环境中,应当从已认证的主体获取键。持续的时钟回拨应通过使用单调时钟来避免,本实验的 clock 是非递减的。
下一项实验要做什么
八个步骤会连成一个可运行的成果。验证设置 → 排除窗口的左边界 → 把等待时间向上取整 → 按键分开记录 → 只记录被允许的请求 → 验证客户端键 → 构造拒绝响应 → 用虚拟时间完成请求流程。
每一步检查的不是函数或文件是否存在,而是实际的返回值、异常和状态变化。看过正确答案之后,请故意改动边界比较或清理代码,确认哪些测试会失败。请说明前面的测试为什么在下一步中依然保持有效,并写出一条本实验不能保证的生产条件。