ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

限速器的窗口边界:固定窗口为什么会放行两倍

限速器的窗口边界:固定窗口为什么会放行两倍

版权与内容来源声明
本文为原创整理。文中涉及官方文档、开源仓库、论文与公开报道的内容,均在附表 A 中标注来源;引用官方原文保持原样,不作改写。文中命令、版本号与界面截图以本文成文时的实测/核验结果为准,标注「待验证」的部分请以你本地环境实际输出为判断依据。本文不推荐任何不合规的软件获取方式,也不对任何收益结果作承诺。转载请注明出处。

一、一个看起来合规的限速器

限速器(rate limiter)是挡在业务前面的一道闸:在一个时间范围里只放行固定数量的请求,超出的拒绝、排队或者降级。它不负责让系统变快,只负责让系统在被灌爆之前先自己认输。上游返回 HTTP 429 时(这个状态码的含义就是「请求过多」,按 IETF 的 RFC 6585 定义,用于表示用户在给定时间内发送了过多请求),通常是对面那道闸在起作用。

1.1 最省事的那种写法

固定窗口(fixed window),指的是把时间切成等长的格子,每格独立计数,格子一换计数清零。它只需要一个整数,不需要保存任何请求的时间戳,是三种实现里最省内存、也最好解释的一种。

下面这段摘自本次实跑脚本,可以直接复跑:

classFixedWindow:"""按自然整除的窗口切分:window_id = floor(t / 60)"""def__init__(self):self.bucket={}defallow(self,t):wid=int(t//WINDOW)used=self.bucket.get(wid,0)ifused<QUOTA:self.bucket[wid]=used+1returnTruereturnFalse

其中WINDOW = 60.0、QUOTA = 10。逻辑没有任何毛病:t // 60把时间轴切成 [0,60)、[60,120) 这样的格子,每格最多放 10 个。这段代码在任何一次评审里都能过。

1.2 它看自己,永远合规

问题不在代码,在它能证明什么。

这个计数器能回答的是「第 0 格里我放了 10 个、第 1 格里我放了 10 个」;它回答不了「任意连续 60 秒里我最多放了多少个」。而配额这句话真正的含义,是后者。

把最坏情况摆出来:第 59 秒进来 10 个,全部放行;第 61 秒又来 10 个,也全部放行。这 20 个请求只隔了 2 秒,却一次规则都没违反——落在任何一格里的数量都没超。每一分钟都合规,两秒之内却打了双倍。

判据一:限速器自己报的「本分钟已用 N 次」不能当作守约证据。它能证明计数没写错,证明不了放行口径。要证明守约,得换一个统计量。

二、三种实现的判定差异

2.1 一张对照表

同样是「每分钟 10 次」,三种常见实现的判定依据完全不同。

实现判定依据需要记录什么冷启动初始状态最坏情况(本文实测)
固定窗口当前格子的已用计数 < 配额一个整数,按格子存格子一开就是满额跨边界时 2.00x
滑动窗口最近 60 秒的放行条数 < 配额每次放行的时间戳,队列长度不超过配额队列为空,从 0 累积1.00x
令牌桶桶里令牌 ≥ 1令牌数 + 上次补充时刻桶是满的(容量 = 配额)1.90x(突发额度)

这张表里最该盯的是第三列。「需不需要记录时间戳」直接决定了这个限速器在分布式部署下能不能对齐:只存一个整数的实现,多副本之间各算各的;存时间戳的实现,至少还有可能把记录共享出去。

2.2 滑动窗口:把时间戳排成队

滑动窗口(sliding window log),指的是记录每一次放行的时间,判定时先剔除已经滑出窗口的记录,再看「剩下的还有几条」。

classSlidingLog:"""记录每次放行时间戳,检查最近 60 秒内放行数"""def__init__(self):self.log=deque()defallow(self,t):whileself.logandself.log[0]<=t-WINDOW:self.log.popleft()iflen(self.log)<QUOTA:self.log.append(t)returnTruereturnFalse

关键在while self.log[0] <= t - WINDOW这一句:判定之前先把「已经滑出 60 秒」的记录从队头弹掉,于是队列长度天然等于「最近 60 秒内放行过几次」。它不需要「格子」这个概念,所以也没有格子边界。

代价是内存:队列长度最长等于配额,看起来不多,但这笔账是跟着每个被限速的 key 走的。按账号限速、按 IP 限速、按接口限速叠加起来,就是配额乘以 key 的数量。

2.3 令牌桶:把速率和容量分开

令牌桶(token bucket),指的是桶里存令牌,请求来了先取一个,取不到就拒。桶按固定速度补充,攒满就不再补。

classTokenBucket:"""容量 = QUOTA,补充速率 = QUOTA/WINDOW"""def__init__(self):self.tokens=float(QUOTA)self.rate=QUOTA/WINDOW self.last=0.0defallow(self,t):self.tokens=min(QUOTA,self.tokens+(t-self.last)*self.rate)self.last=tifself.tokens>=1.0:self.tokens-=1.0returnTruereturnFalse

它把两件事拆开了:容量决定「能攒多少、能突发多少」(突发指极短时间内集中通过的一批请求),补充速率决定「长期平均多快」。这是固定窗口和滑动窗口都没有的自由度——也是它能被写成「每分钟 10 次」却放出更多请求的原因。

注意self.tokens = float(QUOTA):桶一开始是满的。这不算偷懒,桶从空开始会让冷启动阶段的请求全部被拒(冷启动,指服务刚起来、计数器和桶都处于初始状态的那一段)。但满桶启动也意味着一件事——它开局就允许一次性通过 10 个请求。这一点在第 4 章会变成一个具体数字。

三、实测:对齐与偏移两种相位

3.1 实验设置与统计口径

先把话说清楚:这是离散时间轴上的模拟,不是真实网关压测。脚本把 120 秒切成 1200 个 0.1 秒的步长,按恒定速率生成请求,逐个喂给三个实现,记录每次判定结果。

实验参数:配额 10 次/分钟;恒定 5 rps 灌入;共 600 个请求、120 秒;时间精度 0.1 秒;Python 3.13.14,只用标准库。

统计量只有一个:任意 60 秒滑窗内的最大放行次数。做法是把每个放行时刻当作右端点,往回数 60 秒,看这段区间里有几次放行,取所有位置里的最大值。

defrun(policy,reqs):passed=[tfortinreqsifpolicy.allow(t)]# 任意 60s 滑窗内的最大放行数mx=0j=0foriinrange(len(passed)):whilepassed[i]-passed[j]>=WINDOW:j+=1mx=max(mx,i-j+1)first_sec=sum(1fortinpassedift<1.0)return{"总放行":len(passed),"任意60s窗口最大放行":mx,"超额倍数":round(mx/QUOTA,3),"首秒放行":first_sec}

注意是>=而不是>:恰好 60 秒之前的那次放行不算进当前窗口,也就是左闭右开。这个细节会影响恰好卡在边界上的计数,复算时不要改。

3.2 对齐相位:三种实现看不出差别

请求从 t=0 开始,正好与窗口起点对齐。三种实现的「任意 60 秒最大放行」分别是 10、10、19——固定窗口和滑动窗口都等于配额,1.00x;令牌桶是 1.90x。120 秒总放行分别是 20、20、29。

如果只做这一组实验,很容易得出结论「固定窗口和滑动窗口都对,令牌桶因为允许突发所以多一点」。这个结论有一半是错的:固定窗口在这里看起来守约,不是因为守约,而是因为相位正好。

这也正是大部分限速器上线前的验证方式——从服务启动开始压,压一段时间,看被拒的数量对不对。这种压法恰好避开了固定窗口唯一会出问题的地方。

3.3 偏移 30 秒:固定窗口翻倍

把请求整体后移 30 秒,让请求落在窗口中间、跨过边界。相位(phase)指的就是「请求到达的时间起点相对于窗口起点的偏移」。完整的两组数据放在同一张表里:

实现对齐·任意 60 秒最大放行对齐·超额倍数对齐·120 秒总放行偏移 30 秒·任意 60 秒最大放行偏移·超额倍数
固定窗口101.00x20202.00x
滑动窗口101.00x20101.00x
令牌桶191.90x29191.90x

固定窗口从 10 变成 20,滑动窗口纹丝不动,令牌桶也纹丝不动。同一批请求、同一个配额,只挪了 30 秒。首秒放行三种实现都变成 0——请求从第 30 秒才开始,本来就没有首秒。

为什么恰好是 2 倍?第 30~32 秒这段先放满 10 个,第 60~62 秒又放满 10 个,两次密集放行相隔约 30 秒,都落得进同一个 60 秒窗口里。两格各自合规,合起来就是 20 个。

# 边界场景:把请求相位平移半个窗口,看固定窗口的最坏情况shifted=[t+30.0fortinreqs]forname,polin[("固定窗口",FixedWindow()),("滑动窗口",SlidingLog()),("令牌桶",TokenBucket())]:r=run(pol,shifted)print(f"{name:8s}任意60s最大放行{r['任意60s窗口最大放行']:3d}超额{r['超额倍数']:.2f}x 首秒{r['首秒放行']}")

实测里最值得记的一句是:固定窗口的超额只在请求跨窗口边界时出现。对齐时它完全合规(1.00x),偏移半个窗口就变成 2.00x。也就是说,这个缺陷不会在「从零开始压测」里暴露,只会在「真实流量的相位恰好」里暴露——而真实流量的相位不受你控制。

四、令牌桶的冷启动账

先看首秒。三种实现的首秒放行都是 5,这一点第一眼很奇怪:桶里攒着 10 个令牌,为什么首秒也只放 5 个?

因为输入速率就是 5 rps,一秒只来了 5 个请求,桶里的额度根本没被吃满。这是输入侧的限制,不是限速器的功劳。

实现初始可用额度实测首秒放行这个数说明什么
固定窗口10(格子开满)5被输入速率卡住,额度没用完
滑动窗口0(队列为空)5同上,但它是真的在累积
令牌桶10(满桶)5同上,突发能力被低速率掩盖了

要看见桶的突发能力,灌入速率必须高过补充速率。用低速率灌入,永远看不出这件事——这也是「我这边的限速器很稳」这类结论最常见的来源:压测的输入太温柔。

再看总量。初始 10 个令牌,加上 120 秒按 10/60 每秒补充的量,到最后一个请求(第 119.8 秒)为止累计约 29.97 个令牌,只够放行 29 个整令牌。实测就是 29。另两种实现都只有 20。同样一句「每分钟 10 次」,令牌桶在 120 秒里多放了 9 个请求。

这 9 个不算 bug。令牌桶的定义就是「允许突发、守住平均」,多放的正是它被设计出来的行为。问题是:如果你在文档里承诺的是「每分钟不超过 10 次」,这 9 个就是违约——不是实现错了,是措辞和实现对不上。

顺便把上限算清:容量 10 加上一个 60 秒窗口能补的 10 个,任意 60 秒的理论上限是 20 次。实测 19 次,差的那一次是离散步长和到达时刻凑出来的,量级不变(这一句是推算,不是实测)。

判据二:先想清楚要的是「任意 60 秒不超过 N」还是「长期平均不超过 N/60 每秒」。前者只能用滑动窗口,后者才轮得到令牌桶。

五、为什么「每分钟计数」不可信

5.1 这句话本身就有歧义

你看到的说法至少还要问清什么不问清会怎样
每分钟 10 次按自然分钟算,还是任意 60 秒算边界处可能放行两倍
每秒 5 次是硬上限,还是允许突发的平均值平均守住,瞬时翻倍
允许突发 10 次突发额度多久回填一次回填太快等于没限
按 key 限速key 是账号、IP 还是 API key换个维度就绕过去了

RFC 6585 在定义 429 时明确写了一件事:规范不规定服务器怎么识别用户,也不规定怎么计数请求(可以按单个资源、整个服务器或一组服务器统计)。也就是说,计数口径从标准层面就是留给实现方决定的。你看到的「每分钟 N 次」,只是一句措辞,不是一份可执行的契约。

5.2 计数窗口和统计窗口不是同一个东西

限速器内部的计数器,统计的是「它自己的窗口」;你能观测到的流量,统计的是「你的观测窗口」。两者只有在相位对齐时才重合。

固定窗口在偏移时露出 2.00x,本质就是这两套窗口错位。所以「限速器日志里每分钟都是 10 次」这句话什么都证明不了——它只是把自己的窗口又念了一遍。

判据三:验收限速器要用与实现无关的窗口,也就是从放行记录里自己算任意 60 秒的最大值。这个统计量三种实现都能算,不依赖任何内部计数器。

这份资料是什么:一份按「先跑通再排查」顺序整理的大模型学习路线图,里面有一节专讲服务侧稳定性与调用治理该怎么拆步骤。放在资料包里,扫码即可获取:

六、一次压测怎么证伪

三个动作,每个对准一种失败模式。目标不是「证明它没问题」,是让它有机会出问题。

动作具体做法看哪个数出现什么说明没守约
错相位把压测起点从整分挪到半分钟(或分多轮在不同时刻启动)任意 60 秒最大放行比对齐时明显变大(本文 10 → 20)
看滑窗最大记录每个放行时刻,自己算任意 60 秒区间最大值滑窗最大值,不是每分钟计数超过配额(本文 2.00x)
看冷启动首秒冷启动后立刻以高于「配额/60」的速率灌入前若干秒的放行量一次放行接近配额上限(满桶)

6.1 三个动作各自对准什么

  • 错相位:对准固定窗口的边界缺陷。本文里 10 → 20 这一步,是这一个动作单独造成的。
  • 看滑窗最大:对准「计数对了但口径错了」。它既不需要知道对面用了哪种实现,也不需要对端配合。
  • 看冷启动首秒:对准令牌桶的满桶突发。本文 5 rps 的输入太低,测不出这一项——这也是第一组数据里令牌桶看起来「首秒很乖」的原因。

6.2 结果怎么读

错相位后最大放行翻倍 → 对面是固定窗口,或者固定窗口的变体(按自然秒切分的计数也算)。
怎么错相位都在配额内、但冷启动瞬间能过一大批 → 对面是令牌桶,且容量接近配额。
两种都守得住 → 才谈得上「任意 60 秒不超过 N」。

还有个容易踩的细节:观察窗要长于一个窗口。只压 30 秒,任何实现都不会超额,因为一个窗口都还没走完。

6.3 离散与连续:复算时先对齐时钟

真实网关跑在连续时间上,不是本文这种 0.1 秒的离散步长。结论的量级不变,但具体次数会随步长和到达分布变化,得按自己的时间精度复算一遍。

计时要用单调时钟。按 Python 官方文档,time.monotonic()返回一个「不能倒退、且不受系统时钟调整影响」的时钟;用墙上时钟的话,一次校时或手工改时间就可能让窗口错乱,甚至把一整格记录作废。这是模拟和真实实现之间第一个要改的地方。

七、落到 AI 应用:双口径与窗口边界

7.1 上游通常不止一个口径

调用大模型 API 时,常见的是双口径:每分钟请求数(RPM,数请求条数)和每分钟 token 数(TPM,数 token 总量,token 指模型把文本切成的片段)。两本账各记各的,任何一本超了都会被拒。具体限额各家不同、也会调整,本文不写数值——截至 2026-10-22 我没有逐家核验过,写了就是猜。

上游口径客户端固定窗口的失配点边界处会怎样
每分钟请求数边界处两个窗口各放满一次两秒内打出接近 2 倍的请求量
每分钟 token 数请求条数守住了,长 prompt 的 token 没算请求数没超,token 先超
并发连接数限速器管速率,不管同时在飞几个响应变慢时并发堆高,速率没超也被拒

固定窗口在这里的麻烦是叠加的。平时请求稀疏,边界效应看不出来;一旦并发上来、失败后又有重放(重试策略本身不在本文讨论范围,只说它对流量的影响:一次失败会变成两次请求),请求就会在某个时刻堆起来。如果客户端限速器是固定窗口,这堆请求正好压在窗口边界上,就会在极短时间内打出接近 2 倍的量。

结果是:你自己的账显示合规,上游的账显示你超了。然后连坐——不只超出的那几个被拒,后面一段时间的请求也可能一起被拒。这时候如果按「我这边明明没超」去排查,方向从一开始就是错的。

7.2 三条能落地的改法

  1. 把判定换成滑窗口径。要么直接用滑动窗口,要么用令牌桶但把容量压到你能接受的最大突发量——容量等于配额,就等于把一次配额全押在突发上。
  2. token 口径单独记。按字符数或 tokenizer 估出来的量,和上游实际计数会有偏差,留余量比精确计算更实际。
  3. 被拒之后看Retry-After。429 响应可以带这个头(RFC 6585 里写的是可以,不是必须);带了就按它等,没带也别用固定间隔硬顶。

7.3 收尾:把限速写成滑窗口径

回到标题那个问题:固定窗口为什么会放行两倍?因为它守的是「每一格不超过 10 个」,而你以为它守的是「任意 60 秒不超过 10 个」。这两句话在相位对齐时数值一样,在相位偏移时差一倍。

三种实现没有哪一个「错」,它们只是三份不同的契约。判断一个限速器是否守约,不看它自己每分钟的计数,看任意滑窗内的最大放行数。本文的实测把这句话压成了两组数字:10 / 10 / 19,和 20 / 10 / 19。同一批请求,只挪了 30 秒。

写文档时也一样:把「每分钟 10 次」换成「任意 60 秒内不超过 10 次放行,冷启动不预置额度」,这句承诺才可验收。

这份资料是什么:在线可查的大模型知识库,里面有 Agent Skills 与工程化落地的专题目录,适合对照本文这种「一句话承诺怎么变成可验收指标」的写法。放在资料包里,扫码即可获取:

附表 A:本文引用事实与出处对照表

事实出处本文位置
三组放行数字、超额倍数、首秒放行(10/20、10/20、19/29;偏移后 20/2.00x、10/1.00x、19/1.90x)本机实测(work/2026-10-22_限速实测.py,日志work/2026-10-22_限速实测.log,Python 3.13.14,仅标准库 json 与 collections)第 3、4 章
实验参数:配额 10 次/分钟、恒定 5.0 rps、600 个请求、120 秒、时间粒度 0.1 秒同上脚本常量(TICK=0.1、DURATION=120.0、ARRIVAL_RATE=5.0、QUOTA=10、WINDOW=60.0)3.1
相位偏移实验为「请求整体后移 30.0 秒」同上脚本shifted = [t + 30.0 for t in reqs]3.3
三种实现的判定逻辑(按格子整除计数 / 时间戳队列 / 令牌补充)同上脚本 FixedWindow、SlidingLog、TokenBucket 三个类2.1、2.2、2.3
令牌桶 120 秒累计约 29.97 个令牌、放行 29 个;理论上限 20(容量 10 + 一窗口补充 10)为本文推算由脚本常量推算(10 + 119.8 × 10/60),推算过程写在 4 章第 4 章
429 的名称与用途:Too Many Requests,表示用户在给定时间内发送了过多请求(速率限制)RFC 6585《Additional HTTP Status Codes》,IETF,2012 年 4 月,https://www.rfc-editor.org/rfc/rfc6585第 1 章、5.1
该规范不规定服务器如何识别用户、也不规定如何计数请求;Retry-After为可选同上5.1、7.2
time.monotonic()返回不能倒退、不受系统时钟调整影响的时钟Python 3 文档 time 模块,Python Software Foundation,https://docs.python.org/3/library/time.html6.3
本文为离散时间轴模拟,不是真实网关压测;结论是口径差异的量级,不是某家产品的实测结果作者说明,对应脚本 3.1 节的步长设定3.1、6.3
上游按「每分钟请求数 + 每分钟 token 数」双口径限速属通用做法,本文不引用任何厂商的具体限额数值作者说明:截至 2026-10-22 未逐家核验限额,故只写机制不写数值第 7 章
「每分钟 N 次」可对应多种实现,措辞需补齐口径才能验收本文实测(第 3、4 章)结合 RFC 6585 未定义计数方式这一条第 5 章、7.3

附表 B:术语速查表

术语一句话解释在本文哪里用到
限速器在一个时间范围里只放行固定数量请求的组件第 1 章开头
固定窗口把时间切成等长格子、每格独立计数的限速实现1.1
滑动窗口记录每次放行时间戳、只数最近一段时间的限速实现2.2
令牌桶按固定速度往桶里补令牌、请求取到令牌才放行的实现2.3
相位请求到达的时间起点相对于窗口起点的偏移量3.3
滑窗最大放行把每个放行时刻当右端点往回数一段窗口,取其中的最大条数3.1
超额倍数滑窗最大放行数除以配额,1.00x 表示刚好守住3.2
突发极短时间内的集中放行,令牌桶用容量换来的能力2.3、第 4 章
冷启动服务刚起来、计数器与桶都处于初始状态的那一段2.1、第 4 章
429HTTP 状态码,含义是请求过多,通常由对端限速触发第 1 章
RPM每分钟请求数,按请求条数计的口径7.1
TPM每分钟 token 数,按 token 总量计的口径7.1
单调时钟只往前走、不受系统校时影响的计时方式6.3

写在最后:这篇用到的资料

写这篇文章时,把相关的官方文档和源码又翻了一遍,顺手也整理了几份配套的东西:

  • 大模型学习路线图:从零基础到能自己动手做 Agent,按阶段说明每一步该学什么、哪些可以先跳过
  • 《LangChain + LangGraph + MCP 智能体开发实战》视频课:7 个模块,从私有化部署、Embedding+RAG 到 MCP+Agent 全流程
  • AI 大模型知识库(在线可查):Agent Skills 从入门到落地、Claude Skills 完全指南等专题,按目录浏览即可
  • 640 套 AI 大模型行业报告 + 经典 PDF 书籍:看行业落地案例和别人怎么做的时候用得上
  • 大模型零基础到精通教学视频:跟着敲一遍,比只读文档快得多

资料是我自己整理的,放在下面这个码上,扫码即可获取:






添加时备注「AI」,优先通过。

资料按「先路线、再动手、最后查漏」的顺序整理好了,建议先看学习路线那一份,照着它挑一条适合自己当前基础的路径再往下看。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进