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。这证明获准等待的工作会正常完成,不能把每个延迟请求都描述为最终错误。具体完成时间是本次观察,状态分类才是主要断言。
设置 burst=2 nodelay 时,三个请求成功,而且限流器没有把它们标记为 DELAYED,另外三个返回 429。立即再用同一个 key 请求,也会被拒绝;等待 1.1 秒后,又可以成功。因此实验不仅验证了立即放行突发,还验证了持续存在的速率债务,避免误以为一次快速响应就能让下一批无限重复通过。
设置 burst=2 delay=1 时,两个获准请求无需延迟,第三个延迟约一秒,另外三个被拒绝。省略 burst 时,六个紧密到达的请求只接受一个,其余五个拒绝。测试检查决定类别和较宽的延迟下界,不把毫秒测量写成跨机器性能基准;系统调度和真实后端耗时仍会影响精确完成时间。
几个比较 location 显式选择了 429。另一个 location 保持默认拒绝状态,第二个立即请求得到 503。最后,nginx 对含有显式 burst=0 的可丢弃配置执行语法检查,并拒绝它。正在运行的有效配置与失败候选分开,因此负向语法检查不会打断其他实验,也不能把候选文件存在误当成它已经生效。
让等待策略匹配端点需求
如果希望平滑已接受请求的到达速度,且等待能落在请求截止时间内,可以使用延迟超额。如果后端可以承受有界的立即突发,可以使用 nodelay;部分延迟则在开始节流前提供一个立即通过区间。这些设置本身都不限制已经执行中的长请求数量,不能把到达速率与并发资源占用混成同一个预算。
key 的选择应与速率同样认真。这里用查询参数让不同案例可复现,它有意由客户端控制,不是生产身份方案。客户端能改变 key,就能改变自己消耗哪个预算。按 IP 计数有另一组边界,包括多人共享地址和可信代理处理。应根据真正要限制的主体确定 key,并核实该主体在限流阶段是否已经可信可用。
观测信息应足以区分应用错误、限流拒绝和延迟请求。429 可以说明限流策略,在能提供有意义重试时间时也可以附带 Retry-After,但这个示例不会自动生成该头。最终要验证实际状态、调度和后端行为,而不是认为一个指令名称已经描述完整客户端体验。限流配置是否有效,应由真实请求结果证明。
来源
nginx request limit module,漏桶计数、burst、delay、nodelay 与拒绝状态,已核验 2026-10-15。
RFC 6585 additional HTTP status codes,429 Too Many Requests 的语义,已核验 2026-10-15。
nginx 1.28.0 request-limit source,显式非正 burst 值会被拒绝的解析逻辑,已核验 2026-10-15。