【Bug已解决】5h limits totally broken 解决方案
【Bug已解决】5h limits totally broken 解决方案原始报错线索5h limits totally broken系统设定的「每 5 小时」用量上限完全没生效用户可以在 5 小时内无限使用。一、现象长什么样系统说「每 5 小时最多用 100 次」结果用户在 5 小时内用了 500 次没被拦限制像不存在或偶尔在奇怪的时间点才生效排查发现要么「窗口永不滚动」计数器只增不减要么「窗口按本地时区午夜算」跨时区错乱要么「时间基准用错了时钟」限额成了摆设资源被刷爆。 根因是时间窗口限流的实现在「窗口如何定义、何时重置、用什么时间基准」上写错了。二、背景两种窗口限流2.1 固定窗口Fixed Window按「整点 / 整 5 小时边界」切分如 0:00、5:00、10:00。窗口内计数到边界清零。简单但有「边界突刺」问题23:59 和 0:01 各用满额度 双倍。2.2 滑动窗口Sliding Window以「当前时刻往前推 5 小时」为窗口任意连续 5 小时内不得超过 N。更平滑、更严谨但实现稍复杂需记录每次时间戳。2.3 时间基准绝对时钟epochtime.time()自 1970 起的秒数跨时区一致本地墙钟受时区/夏令时影响不适合做限流基准限流必须用绝对时钟绝不用本地墙钟。三、为什么限制失效根因3.1 计数器只增不减窗口不滚动count 1但从不按窗口重置count 一直涨限制阈值被永久超过却不拦——或永远不拦因为判断写成if count N但 N 是累计上限而非窗口上限。3.2 时间基准用本地墙钟 / 时区按「本地时区午夜」切窗口用户跨时区或夏令时切换时窗口错乱。3.3 固定窗口边界突刺被利用攻击者在窗口末尾 下一窗口开头连续打满实际 2 分钟内用了 2 倍额度。3.4 多实例不同步限流状态存在单进程内存多实例各自计数总和远超限制。四、最小可运行复现窗口不滚动导致失效下面演示「只增不减」如何使限额失效class BadLimiter: def __init__(self, limit): self.limit limit self.count 0 self.window_start 0 def allow(self, now): # 错误从不检查窗口是否该滚动重置 count self.count 1 return self.count self.limit if __name__ __main__: lim BadLimiter(limit3) # 即便过了 5 小时窗口count 也没重置反而一直累加 for i in range(10): print(f第{i1}次允许?, lim.allow(now1000 i * 10000)) # 全 True限制失效count只增不减限制名存实亡根因 3.1。五、解决方案一固定窗口 滚动重置绝对时钟import time class FixedWindowLimiter: def __init__(self, limit, window_sec5 * 3600): self.limit limit self.window_sec window_sec self.count 0 self.window_start int(time.time()) def allow(self, nowNone): now now or int(time.time()) # 关键跨过窗口边界就重置计数用绝对时钟 if now - self.window_start self.window_sec: self.window_start now - (now % self.window_sec) # 对齐窗口 self.count 0 if self.count self.limit: return False self.count 1 return True if __name__ __main__: lim FixedWindowLimiter(limit3, window_sec5 * 3600) base int(time.time()) for i in range(5): print(f允许?, lim.allow(nowbase i * 60)) # 前3次 True后2次 False窗口边界一到就重置count限额真正生效解决 3.1。六、解决方案二滑动窗口严谨限流记录每次事件时间戳任意连续窗口内超限即拦import time class SlidingWindowLimiter: def __init__(self, limit, window_sec5 * 3600): self.limit limit self.window_sec window_sec self.events [] def allow(self, nowNone): now now or int(time.time()) # 丢弃窗口外的旧事件 cutoff now - self.window_sec self.events [t for t in self.events if t cutoff] if len(self.events) self.limit: return False self.events.append(now) return True if __name__ __main__: lim SlidingWindowLimiter(limit3, window_sec5 * 3600) base int(time.time()) for i in range(5): # 都在同一 5h 窗口内 - 第4次起拒绝 print(f允许?, lim.allow(nowbase i * 60))滑动窗口保证「任意连续 5 小时」不超 N彻底严谨解决 3.3 边界突刺。七、解决方案三多实例共享计数集中式单进程内存计数在分布式下失效必须集中存储如 Redisdef allow_central(key, limit, window_sec, now, backend): 集中式限流计数存在共享后端多实例一致。 cutoff now - window_sec # 真实环境用 Redis ZREMRANGEBYSCORE ZCARD ZADD原子 backend[events] [t for t in backend.get(events, []) if t cutoff] if len(backend[events]) limit: return False backend.setdefault(events, []).append(now) return True if __name__ __main__: backend {} base int(time.time()) results [allow_central(u1, 3, 5 * 3600, base i * 60, backend) for i in range(5)] print(各次结果:, results) # [True,True,True,False,False]集中后端让多实例共用同一窗口计数总额不被绕过。八、跨时区 / 时钟注意点只用 epoch 绝对时钟time.time()绝不用本地墙钟切窗口NTP 偏差各实例时钟可能差几秒集中式限流以服务端时间为准夏令时本地时区切夏令时会让「按本地小时」的窗口跳变绝对时钟无此问题时间回拨手动改系统时间可能导致窗口错乱集中式用单调/可信时间源。九、排查清单「时间窗口限额失效」按下面排查计数器是否随窗口重置还是只增不减第五节根因3.1时间基准对吗是否用绝对时钟而非本地墙钟固定窗口边界突刺是否需要滑动窗口第六节多实例是否共享计数单进程内存会被绕过窗口对齐逻辑对吗now % window对齐是否正确阈值判断是窗口上限还是累计上限第四节错误时钟是否可信NTP/时间回拨影响第八节日志是否记录每次事件的窗口归属。十、小结「5 小时限制完全失效」的根因是窗口限流的计数不随窗口滚动重置、或时间基准错乱、或单实例内存被多实例绕过。通用修复滚动重置固定窗口跨边界即清零用绝对时钟第五节解决 3.1滑动窗口严谨按「任意连续窗口」限流消除边界突刺第六节集中式计数多实例共用共享后端总额不被绕过绝对时钟基准只用 epoch绝不用本地墙钟/时区。 一句话时间窗口限流 「正确的窗口边界 随窗口滚动的计数 可信的绝对时钟 跨实例共享」少任何一环限额就会悄悄失效。把限流做成「窗口对齐 滚动重置 集中存储」5 小时限制就真的能在任意连续 5 小时内拦住超额——这与第 97 篇配额重置、第 110 篇幂等记账、第 120 篇配额恢复共同体现「时间相关配额必须窗口化、滚动化、基准统一」。