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。这证明获准等待的工作会正常完成,不能把每个延迟请求都描述为最终错误。具体完成时间是本次观察,状态分类才是主要断言。
...