允许的超额请求可以等待或立即通过,超过突发额度的请求才被拒绝。

nginx 突发容量决定哪些请求等待、哪些被拒绝

nginx 请求限流使用漏桶方法,依据配置速率计算超额请求。burst 决定可以接受多少超额,nodelay 和 delay 则影响已经被接受的超额请求何时继续处理。这里涉及准入与调度,并不是所有超过名义速率的请求都会失败,也不是直接测量应用里有多少并发工作。理解这几个不同维度,才能解释一次突发里为何同时出现成功、等待和拒绝。 坏掉的样子 把 nodelay 解释成“立即丢弃超额”,正好说反了它的作用。在突发额度以内,它允许超额请求不经过通常的延迟,直接继续处理;超过额度才拒绝。已经接受的突发仍消耗需要随时间恢复的容量。后端很快完成,并不会让下一批突发自动获得全新额度,因此不能把 nodelay 理解成只管单次批次、不累计速率债务。 被延迟的请求也不会自动变成一串 503。突发范围内获准等待的请求,可以在等待之后得到成功应用响应;超过配置额度才会按所选状态拒绝。客户端自己的截止时间可能在等待中到期,所以服务器能接受的排队,不一定满足客户端延迟契约。讨论结果时应区分服务端拒绝、获准等待和客户端主动超时。 配置语法同样需要验证。在 nginx 1.28.0 中,显式写 burst=0 会被拒绝,尽管省略 burst 的默认超额额度就是零。无效配置不是一种更严格但正常工作的限流策略。应同时做语法检查和真实请求验证,并确认受保护 location 确实进入限流阶段,而不是在更早的 rewrite 阶段通过 return 提前结束。 快速验证 将脚本保存为 check-burst.py,准备 Python、Docker 和固定镜像后执行。记录环境使用 nginx 1.28.0 和真实 HTTP 后端。每个案例采用独立的公开测试 key、每秒一个请求的速率,并同时放行六个客户端。nginx 在响应头中暴露本次限流决定,入口只发布到宿主回环,结束后清理自己的实验资源: from contextlib import contextmanager from pathlib import Path from tempfile import TemporaryDirectory import http.client,json,socket,subprocess,time,uuid NGINX='nginx@sha256:552e7481ca93ffccd046aa658dbbed22caefbc09c66fa7cd247cbb90b8a5c609' PYTHON='python@sha256:528257d48c1da0dcecc2e725d1ae34498d60c965f1241e39cd6a85a8859bdf84' def docker(*args,check=True): result=subprocess.run(['docker',*args],capture_output=True,text=True,timeout=120) if check:assert result.returncode==0,result.stderr return result def wait(predicate): deadline=time.monotonic()+15 while time.monotonic()<deadline: try: if predicate():return except OSError:pass time.sleep(.1) raise AssertionError('fixture did not become ready') @contextmanager def lab(configuration,files=None,backend=None,extra_ports=()): name='xbcatm-nginx-'+uuid.uuid4().hex[:10];tags=[name+':proxy',name+':backend'];containers=[name+'-proxy',name+'-backend'] try: with TemporaryDirectory(prefix=name+'-') as directory: root=Path(directory);(root/'files').mkdir();(root/'nginx.conf').write_text(configuration) for path,content in (files or {}).items(): target=root/'files'/path;target.parent.mkdir(parents=True,exist_ok=True) target.write_bytes(content if isinstance(content,bytes) else content.encode()) docker('network','create',name) if backend is not None: (root/'backend.py').write_text(backend) (root/'backend.Dockerfile').write_text(f'FROM {PYTHON}\nCOPY backend.py /backend.py\nCMD ["python", "/backend.py"]\n') docker('build','--network','none','-f',str(root/'backend.Dockerfile'),'-t',tags[1],str(root)) docker('run','-d','--name',containers[1],'--network',name,'--network-alias','backend',tags[1]) wait(lambda:docker('exec',containers[1],'python','-c','import socket;s=socket.create_connection(("127.0.0.1",8081),timeout=.5);s.close()',check=False).returncode==0) (root/'Dockerfile').write_text(f'FROM {NGINX}\nCOPY nginx.conf /etc/nginx/nginx.conf\nCOPY files /srv\n') docker('build','--network','none','-t',tags[0],str(root)) docker('run','--rm','--network',name,tags[0],'nginx','-t') published=[arg for value in (8080,*extra_ports) for arg in ('-p','127.0.0.1::'+str(value))] docker('run','-d','--name',containers[0],'--network',name,*published,tags[0]) details=json.loads(docker('inspect',containers[0]).stdout)[0] binding=details['NetworkSettings']['Ports']['8080/tcp'][0];assert binding['HostIp']=='127.0.0.1';port=int(binding['HostPort']) def ready(): connection=socket.create_connection(('127.0.0.1',port),timeout=.5);connection.close();return True wait(ready) def request(path,method='GET',headers=None,body=None): connection=http.client.HTTPConnection('127.0.0.1',port,timeout=5) try: connection.request(method,path,body=body,headers=headers or {}) response=connection.getresponse();return response.status,{k.lower():v for k,v in response.getheaders()},response.read() finally:connection.close() request.port=port;request.network=name request.ports={int(key.split('/')[0]):int(value[0]['HostPort']) for key,value in details['NetworkSettings']['Ports'].items() if value} yield containers[0],request finally: docker('rm','-f',*containers,check=False);docker('image','rm',*tags,check=False);docker('network','rm',name,check=False) def config(locations,extra=''): return 'worker_processes 1;\nerror_log /tmp/error.log notice;\nevents {}\nhttp {\naccess_log off;\n'+extra+'\nserver { listen 8080; root /srv;\n'+locations+'\n}\n}\n' backend=r''' from http.server import BaseHTTPRequestHandler,ThreadingHTTPServer import json class Handler(BaseHTTPRequestHandler): protocol_version='HTTP/1.1' def log_message(self,*args):pass def do_GET(self): body=json.dumps({'path':self.path,'method':self.command}).encode() self.send_response(200);self.send_header('Content-Length',str(len(body)));self.end_headers();self.wfile.write(body) ThreadingHTTPServer(('0.0.0.0',8081),Handler).serve_forever() ''' from concurrent.futures import ThreadPoolExecutor import threading extra='limit_req_zone $arg_k zone=budget:1m rate=1r/s; add_header X-Limit $limit_req_status always;' locations='location / { return 404; }\n' for path,policy in [('delay','burst=2'),('nowait','burst=2 nodelay'),('partial','burst=2 delay=1'),('strict','')]: locations+=f'location /{path} {{ limit_req zone=budget {policy}; limit_req_status 429; proxy_pass http://backend:8081; }}\n' locations+='location /default { limit_req zone=budget; proxy_pass http://backend:8081; }' invalid=config('location / { limit_req zone=b burst=0 nodelay; proxy_pass http://backend:8081; }', 'limit_req_zone $binary_remote_addr zone=b:1m rate=1r/s;') with lab(config(locations,extra),{'invalid.conf':invalid},backend) as (proxy,request): results={} for mode in ['delay','nowait','partial','strict']: barrier=threading.Barrier(7) def client(): barrier.wait();start=time.monotonic();status,headers,_=request('/'+mode+'?k='+mode) return {'status':status,'limit':headers.get('x-limit'),'elapsed':round(time.monotonic()-start,3)} with ThreadPoolExecutor(max_workers=6) as pool: futures=[pool.submit(client) for _ in range(6)];barrier.wait();observed=[f.result() for f in futures] good=[r for r in observed if r['status']==200];bad=[r for r in observed if r['status']==429] assert len(good)==(1 if mode=='strict' else 3) and len(good)+len(bad)==6,observed assert all(r['limit']=='REJECTED' for r in bad) assert all(r['limit'] in {'PASSED','DELAYED'} for r in good) delayed=sum(r['limit']=='DELAYED' for r in good) assert delayed=={'delay':2,'nowait':0,'partial':1,'strict':0}[mode],observed if mode=='delay':assert sorted(r['elapsed'] for r in good)[-1]>=1.7 if mode=='partial':assert max(r['elapsed'] for r in good)>=.7 results[mode]=observed if mode=='nowait': assert request('/nowait?k=nowait')[0]==429 time.sleep(1.1);assert request('/nowait?k=nowait')[0]==200 results['nodelay_debt_then_recovery']=[429,200] first=request('/default?k=default');second=request('/default?k=default') assert first[0]==200 and second[0]==503 and second[1]['x-limit']=='REJECTED' results['default_rejection']=503 rejected=docker('exec',proxy,'nginx','-t','-c','/srv/invalid.conf',check=False) assert rejected.returncode!=0 and 'invalid burst value' in rejected.stderr results['literal_burst_zero_valid']=False print(json.dumps(results,sort_keys=True)) 设置 burst=2 时,一个请求立即通过,另外两个获准超额请求被延迟。记录中的成功响应分别在接近零、一、两秒时完成;其余三个请求返回 429。响应头区分 PASSED、DELAYED 和 REJECTED。这证明获准等待的工作会正常完成,不能把每个延迟请求都描述为最终错误。具体完成时间是本次观察,状态分类才是主要断言。 ...

一月 5, 2028 · 3 分钟 · XB Field Notes
有界令牌额度按经过时间回填,只允许成本能够被当前额度支付的工作。

令牌桶平滑请求速率

令牌桶限制获准进入的工作量,同时允许积累的额度在短时间内集中使用。它不会自动把请求均匀隔开,不保证成功完成的吞吐,也不限制仍在执行的操作数量;这些分别属于调度和容量问题。桶的两个参数描述的是预算,不是服务一定能够履行的处理能力承诺。 先补充额度,再扣除工作成本 设桶容量 B 为最多能保存的令牌数,回填速率 r 为每秒新增令牌数。每次请求到来,先将额度更新为 min(B, 上次额度 + r × 经过时间)。只有工作成本不超过当前额度才准入,然后扣除成本。本文采用的严格规则下,被拒绝的工作不消耗令牌。 这种计算可以在请求到达时按需进行,不需要后台定时器不断实际投放令牌。长时间空闲也不能积累无限额度,因为容量会截断回填结果。初始额度是一项策略选择:本例从满桶开始,因此允许立即突发;从空桶开始则会得到不同的初始行为,不能漏写这个条件。 突发额度不是峰值速率 对于这个理想严格模型,在长度为 t 的时间区间内,获准工作的总成本不超过 B + r × t。时间越长,突发项的相对影响越小。这不意味着每个一秒窗口都最多允许 r 个请求,也没有额外给出一个独立的瞬时峰值速率上限。 例如容量为三、每秒回填二时,三个单位成本请求可以在同一时刻全部通过,第四个被拒绝;半秒之后又有一枚令牌可用。如果已获准的工作必须均匀离开入口,还需要实际调度器、有界等待队列或其他节奏控制,不能指望令牌桶自动将同时到达的请求排成匀速流。 准入速率也不等于执行并发量。慢请求尚未结束时,令牌仍会继续回填,允许更多工作进入。需要通过合适的并发限制和超时策略保护工作线程、数据库等稀缺资源。同样,已经准入的请求仍可能在下游失败,所以回填速率不能被写成保证成功完成的吞吐。 预算单位和作用范围要一致 一枚令牌不一定对应一个请求,也可以用加权成本代表字节或预计工作量,但容量与回填速率必须使用相同单位。一个成本超过 B 的请求,无论等待多久都装不进这个严格模型的桶。应明确处理这种情况,而不是给出一个实际上永远不能兑现的重试时间。 还要明确预算按谁区分、由谁持有。四个互相独立的满桶,每个保存三枚令牌,可以同时允许十二个请求;它们不是一个全局三令牌桶。共享限流器必须把回填、比较和扣减协调成一次原子决定,仅共享一个计数器而分开执行这些步骤,并不足以保证配额正确。 本地回填应依据经过时间的时钟,不能让墙上时钟校准凭空增加额度,或使记账时间倒退。Python 的单调时钟适合比较两次读取的差值,但其参考原点不是可以在无关机器之间交换的通用时间戳。分布式时钟和状态归属仍需要自己的明确契约。 坏掉的样子 即使长期速率看起来保守,满桶释放的一次突发仍可能压垮脆弱的下游。无界等待队列会把超额需求转化为内存增长与延迟;直接拒绝和延迟整形是两种不同结果,接口应说明调用者会得到哪一种,不能把所有行为都笼统叫作“平滑”。 应一起观察准入成本、拒绝成本、排队、并发量和下游延迟。重试需要遵守限流策略,而不是立即重新制造被拒绝的负载。按尝试工作计费还是按完成工作计费,也是需要明确的选择;失败后退还令牌会改变模型,并引入额外记账要求,不能悄悄当成同一套公式。 托管产品的限制还可能弱于理想算法。Amazon API Gateway 明确说明限流和配额是尽力而为的目标,可能被超过。因此,不能把这里的数学上界宣传成该服务已经验证的硬保证、费用上限,或者任何未经测试部署的严格承诺。 快速验证 保存为 bucket_check.py,用 Python 3.10 以上版本执行 python3 bucket_check.py。本例已在 Python 3.14.4 上运行。标准库 Fraction 与注入的非递减时间戳让边界计算保持精确,不依赖真实等待。这是单线程准入模型,不是网络限流服务或分布式存储实现。 ...

十二月 1, 2027 · 2 分钟 · XB Field Notes
相同 key 共享预算,改变 key 会选择另一份预算,空 key 则不被计数。

请求限流保护的是 key 所命名的预算

请求速率限制作用于 key 表达的身份,不会自动理解人、账户或端点。limit_req_zone 为这些 key 值保存共享状态,limit_req 则在处理请求时应用对应区域。两个 location 使用相同区域和 key,可以消耗同一份预算。因此,选择变量实际上是在定义公平性与执行边界,并不是只给配置找一个方便填写的字符串。 坏掉的样子 客户端可控标识可能把一个表面上的限制变成许多独立额度。如果修改查询参数就能改变 key,不可信客户端就能选择另一份预算。空 key 更直接:nginx 不会统计 key 求值为空的请求。身份变量缺失时,请求可能无声地离开原本计划的限流范围。只测试正常携带身份的访问,会漏掉这条重要边界。 IP key 衡量的是地址,它可能代表 NAT 或中间层后面的很多用户。转发地址头要先经过明确信任策略,才能成为身份输入。相信任意 X-Forwarded-For,会让调用者制造身份;忽略真实可信代理的地址链,又可能把所有用户放进代理自身的一份预算。两种错误都不能靠提高速率解决,因为问题首先在于限制对象选错了。 共享内存的范围也很重要。区域在同一 nginx 实例的 worker 之间共享,不会自动形成跨无关进程或机器的全局计数器。兼容的平滑 reload 可以保留区域状态。如果假定每次重载配置都会重置全部额度,就可能在测试或恢复操作中,把继续出现的拒绝误判为部署坏了。应区分配置换代、进程换代与共享状态生命周期。 快速验证 将脚本保存为 check-limit-keys.py,准备 Python、Docker 和固定镜像后执行。记录环境使用带两个 worker 的 nginx 1.28.0,以及可丢弃 HTTP 后端。额度刻意设为每分钟一个请求,使各次观察落在同一预算区间;不同公开测试 key 隔离案例。监听只发布到宿主回环,结束后清理实验资源,不修改现有站点配置: ...

八月 28, 2027 · 3 分钟 · XB Field Notes
客户端先根据截止期限和重试政策检查等待要求,再安排下一次尝试。

Retry-After 告诉你何时再试

Retry-After 告诉客户端,在发起后续请求之前应当等待多久。它是调度信号,不证明下一次请求一定成功,也不证明重放原操作是安全的。客户端仍然需要截止期限、尝试次数政策,以及哪些操作可以重试的判断,不能把这些责任都交给一个响应头。 结合响应语境解释字段值 RFC 9110 允许非负十进制整数秒数,或者 HTTP 日期。秒数从收到响应之后开始计算,不是从发出原请求时算起;日期则表示一个绝对 UTC 时刻,因此正确解释它依赖客户端墙上时钟足够准确,不能把两个形式都当成“从请求开始算多久”。 发送方必须使用首选的 IMF-fixdate 格式。解析 HTTP 日期的接收方还必须接受两种旧格式,其中旧 RFC 850 格式的两位年份需要遵守五十年解释规则。负数、小数秒和任意文字,都不是合法的 delay-seconds;零和已经过去的日期,则不要求增加服务器指定的等待时间。 配合 503 时,该字段估计服务还会对客户端不可用多久;配合 3xx 响应时,它要求在发送重定向请求之前至少等待相应时间。RFC 6585 另行允许 429 携带 Retry-After,但这里是可选字段,不是每一条限流响应都必须包含的内容。 尊重等待时间,但不无限等待 一个合法等待值可能长于调用方剩余期限。客户端可以停止重试并报告失败,或者在另有授权的流程中安排后续处理;不能只是为了符合本地上限,就缩短服务器要求的等待,然后提前发起请求。很长的值并不自动等于格式错误,也可能是真实的维护安排。 把绝对日期换算成等待时长后,执行调度和经过时间记账应使用单调时钟。真正开始下一次尝试之前,还应重新检查期限,并为这次请求本身保留完成时间。时钟偏差、响应传输和调度延迟,都说明计算出的计划时间与实际请求时间不是同一份证据。 如果字段缺失或无法使用,有界客户端政策可以采用自己的退避规则。但那是客户端的选择,不是服务器给出的恢复估计。必须明确最多尝试几次、总共允许耗费多久,避免一个可选字段让暂时失败变成没有终点的重试循环。 相同等待值仍可能让调用者同时回来 如果成千上万的客户端几乎同时收到相同等待值,它们仍可能在同一个时刻返回。Retry-After 本身不会把这些请求自动摊开到恢复窗口。可以在尊重要求的等待后增加非负抖动,或者用其他不早于该边界的调度方式,但不能向前减掉等待来制造错峰。 组合本地退避和服务器提示时,需要同时保留两者约束。示例取本地退避与解析出的等待值中较大者,再添加正向抖动。注入的偏移量展示的是调度关系,不是实际分布式流量的测量结果,也不是对某个生产环境抖动范围的推荐。 坏掉的样子 当原请求结果未知时,盲目重放写操作可能重复产生副作用。429、503 和 Retry-After 都不会赋予操作幂等保证;重复非幂等工作之前,需要依据应用自己的重试契约,包括操作标识或去重规则,不能只检查响应头是否存在。 ...

五月 4, 2027 · 4 分钟 · XB Field Notes
已验证主体与网络地址把请求划分成不同的配额组。

限流需要一个稳定的客户端标识

限流键定义了哪些请求共用一份预算。账号、API 凭据、地址、租户和已认证会话是不同身份,不是“客户端”的几种可互换写法。选错归组方式,可能让无关用户相互挤占额度,也可能让同一份工作只改一个字段,就拿到新的使用机会。 使用政策真正承诺的身份 如果规则是按账号限流,就应从认证结果中取得账号,而不是相信调用方提交的账号请求头或查询参数。仅仅看见一个凭据,并不能证明它有效;认证层必须先验证凭据、解析主体,再让账号级准入决定使用这个结果。限流键的可信程度不能高于它的来源。 按凭据限流也可以是合理的产品契约,但行为与按账号限流不同。轮换凭据或再创建一份凭据,可能得到新的凭据键,却仍属于同一个账号。应先决定这是否应当带来新额度,而不是等出现滥用或投诉后,才发现实现按的身份与产品描述并不一致。 一个账号不一定只对应一个人或一个应用,一个人也可以拥有多个账号。稳定账号键改善的是归组的一致性,并不证明公平,不阻止批量养号,也不限制整个服务的总需求。其他层级的限制应由明确需求支持,不能认为“已经按账号计数”就涵盖所有容量风险。 地址表示的是网络路径 企业 NAT、运营商网络或 VPN 后面的多个账号,可能共用一个公网地址;按 IP 的桶会把它们的流量合在一起。反过来,更换地址的调用方可能进入一个新桶。这两种结果并不说明地址解析错误,而是说明网络地址与业务主体本来就在表示不同对象。 没有已验证账号的公开接口或认证前入口,仍可以用地址级限制保护资源。但应按地址政策评估阈值,包括正常共享网络的流量,不能把它描述成精确的个人限制,也不能假设一个地址规则一定拦得住分散在许多网络上的抓取者。 地址在成为键之前需要语法规范化。同一个 IPv6 地址的不同合法写法,不应创建多份额度。是否按网段聚合,或把特定地址族形式视为同一个对象,是另外的政策选择,需要权衡误伤范围;规范化文本本身并不能替你回答这些业务问题。 转发身份必须有信任边界 随意提交的 Forwarded、X-Forwarded-For 或类似字段,不是客户端来源的证明。RFC 7239 明确指出,客户端和沿途节点都能改变转发信息。是否可信取决于直接连接的对端、配置的代理链,以及这条链如何移除或安全处理用户原先提交的字段。 NGINX 的真实 IP 模块需要明确配置可信来源地址和递归处理规则。“永远取转发字段里的第一个地址”不是适用于所有网络的正确方法。如果代理接收后原样转发了不可信声明,看似稳定的身份键就可能变成由调用者控制的新预算生成器。 下面的实验只模拟一个已经配置的可信代理,它会覆盖字段,并只发送一个 IP 字面值。其他对端提交的替换声明被忽略,来自这个代理的多地址列表则被拒绝。它不是通用代理链解析器,也不是已经部署并验收的 NGINX 配置,不能直接扩大其覆盖范围。 坏掉的样子 键里漏掉租户维度,会合并不同租户中标识相同的账号。按原始 URL 计数,又可能因为查询参数不同,把同一个操作拆成多个桶;如果政策按操作限流,应使用路由解析出的稳定操作身份。直接拼接字符串还可能产生歧义,应在编码中保留各组件边界。 不要把原始 bearer 秘密作为预算键或诊断标签。稳定的内部凭据标识或主体标识通常更合适,日志也仍需要隐私控制。无限创建身份会消耗限流器内存;评估过期或状态淘汰时,还应考虑桶被重置后产生的新额度,不能只看到内存回收这一面。 选定阈值前,应测试共享地址、漫游客户端、凭据轮换、租户隔离和伪造声明等真实身份组合。还要沿数据来源检查所谓“已验证主体”是否真的来自认证结果。如果它本身是从不可信请求直接复制出来的,后面再精巧的字典键也修复不了这个信任错误。 快速验证 保存为 identity_check.py,用 Python 3.10 以上版本执行 python3 identity_check.py。实验已在 Python 3.14.4 上运行,只使用标准库。记录代表已经完成认证和路由解析的测试请求,并不是认证实现;计数器只模拟一个测试窗口,不启动服务,不发送网络请求,也不写文件。 ...

十月 29, 2026 · 2 分钟 · XB Field Notes
调用方共享同一份准入预算,额度比较和更新必须一起完成。

限流是共享预算

限流是一份关于获准工作量的预算,需要明确单位、身份、作用范围和时间规则。只说“五次请求”还不够:要说明是谁的请求、覆盖哪些入口与工作进程、按什么区间计数,以及耗尽后如何处理。即使用了共享计数器,也只有所有相关请求都经过同一个权威准入决定,才能落实共享预算。 先定义政策,再开始计数 一次请求可以消耗一个单位,也可以按预计工作量或字节数加权。按准入尝试、成功执行或退还额度分别记账,必须由契约明确规定,不能在实现中悄悄混用。获得预算只意味着允许继续执行,不保证后续业务操作一定成功,也不等于为成功结果预留了全部资源。 窗口算法并不相同。固定窗口可能在边界附近允许突发,滚动窗口衡量的是另一段区间;令牌桶允许迅速花掉已经积累的额度,并不会自动把请求均匀发出。这些算法本身也不限制仍未结束的慢操作数量,准入速率不能替代执行并发上限。 账号、路由、地域和整个服务的预算分别回答不同问题。剩余额度只是某个时刻的快照,并不是为调用方预留的位置:其他工作进程可能先把它用掉。各进程各自维护同样大小的本地计数器,会放大实际额度,除非存在明确的分配规则,将总预算拆给不同参与者。 把准入决定做成原子操作 把数据放进同一个 Redis 键,不会让客户端的一串命令自动变成原子操作。两个调用方可能都读到尚有额度,各自决定继续,再分别递增。每个 INCR 都是原子的,但之前的比较发生在它外面,所以最后总量仍可能超过承诺,不能只检查单条命令的原子性。 下面的示例将比较、首次创建计数器并附加过期时间、后续递增放进一个很短的 Lua 执行。窗口从首次获准请求开始;拒绝既不增加计数,也不延长截止时间。所有调用方传入同一个键和固定的正参数,实验独占这个数值计数器,不与其他业务共享它的写入权限。 这是从首次到达开始计时的窗口,不是持续滑动的配额。脚本执行不被其他命令交错,也不等于自动回滚、持久预留,或与应用业务操作构成同一个事务。Redis 执行脚本时会阻塞其他服务活动,因此应保持脚本简短;连接丢失应视为结果未知,不能直接断言额度从未被扣除。 准确解释拒绝结果 RFC 6585 用 HTTP 429 表示请求速率过高,但把身份选择和计数政策留给服务器决定。响应应解释条件,可以附带 Retry-After;429 响应不得被缓存保存。整个服务暂时不可用,与某个调用方超过自身额度,是两种不同情况,不应仅为了统一返回格式就抹掉区别。 Retry-After 可以表达建议等待时间,却不保证届时一定有额度。如果公开剩余量或重置时间等元数据,接口契约必须定义单位和范围。历史上不同 API 使用的 X-RateLimit 字段,并不由 RFC 6585 赋予一套通用解释;不能因为名字相似,就把某个平台的字段规则当成统一标准。 坏掉的样子 拆开的检查与更新会在竞争中超额放行。把创建计数器和设置过期时间分成两个命令,还可能因为客户端在中间停止,留下不会过期的计数器。键淘汰、重启或异步故障转移也可能丢失预算状态;一台正常服务器上的原子执行,不能证明这些故障中仍有严格硬上限。 应明确预算存储不可用时,请求是否还能获准。静默把错误当成空计数器,就会凭空创造额度。也要保护限流器本身的容量与响应时间,一起观察允许和拒绝的工作量,并区分必要的资源保护,与没有契约依据地提前拒绝仍有权使用预算的请求。 快速验证 准备 POSIX 环境、C 编译器、make、curl、tar 和 Python 3.10 以上版本。本实验已在 macOS、Redis 8.10.1、redis-py 8.1.0、Python 3.14.4 上运行。安装步骤只构建 Redis 核心,并把客户端装入临时环境,不修改系统级安装。 ...

八月 22, 2026 · 3 分钟 · XB Field Notes