响应缓冲在资源预算内吸收两端速度差
响应缓冲允许 nginx 以一种速度读取上游,同时以另一种速度向客户端交付数据。开启 proxy_buffering on 后,nginx 使用配置的内存缓冲区,也可以把大响应的一部分写入临时文件。它不必先收齐完整响应才发送第一批字节。两条连接能在多大程度上独立推进,取决于可用缓冲容量,而不是“开启”这两个字就能无限解耦。 坏掉的样子 “慢客户端永远不会阻塞后端”是错误承诺。内存缓冲、临时文件上限、磁盘吞吐和套接字缓冲都有限。当 nginx 无法继续吸收数据,上游仍然可能受到反压。禁用临时文件缓冲,也不等于关闭响应缓冲;它只是移除了一个等待数据的存储位置,使容量边界更容易到达。应明确哪个容量耗尽,而不是把所有等待都叫作后端慢。 缓冲还可能影响端点的延迟预期。虽然不要求收完整份响应,小事件仍可能先累积,之后才对客户端可见。流式端点应明确选择响应缓冲策略,并通过完整链路验证事件可见时间。即使关闭 nginx 响应缓冲,应用自身缓冲、压缩、TLS 和其他中间层仍会影响交付。因此,修改一条指令不能代替实际观察用户什么时候收到每个事件。 资源交换发生在代理层。把响应写入临时文件,可以让后端较早结束工作,但在客户端慢慢读取期间会占用临时存储和磁盘读写能力。为每条连接扩大内存缓冲,则可能在高并发下产生另一类容量问题。真正需要决定的是:面对实际工作负载,代理应该吸收多少数据、承担多少等待,而不是只凭单个缓冲值看起来更大来判断性能。 快速验证 将脚本保存为 check-response-buffering.py,准备 Python、Docker、固定镜像和足够的临时磁盘空间后运行。记录环境为 Linux arm64 上的 nginx 1.28.0。专用后端提供受控响应开关和进度计数,nginx 仅通过宿主回环监听。实验会传输三份 32 MiB 正文,完成后清理自己的资源,不使用现有业务后端: 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): 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') docker('run','-d','--name',containers[0],'--network',name,'-p','127.0.0.1::8080',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 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' import hashlib backend=r''' from http.server import BaseHTTPRequestHandler,ThreadingHTTPServer from urllib.parse import urlsplit,parse_qs import json,socket,threading lock=threading.Lock();state={};gates={};chunk=b'x'*65536;total=32*1024*1024 class Handler(BaseHTTPRequestHandler): protocol_version='HTTP/1.1' def log_message(self,*args):pass def do_GET(self): parsed=urlsplit(self.path);path=parsed.path if path=='/state': with lock:data=json.dumps(state).encode() self.send_response(200);self.send_header('Content-Length',str(len(data)));self.end_headers();self.wfile.write(data);return if path=='/release': target=parse_qs(parsed.query)['path'][0] with lock:gates[target].set() self.send_response(204);self.end_headers();return gated=path.startswith('/early-');size=2*len(chunk) if gated else total with lock:state[path]={'written':0,'done':False};gates[path]=threading.Event() self.connection.setsockopt(socket.SOL_SOCKET,socket.SO_SNDBUF,65536) self.send_response(200);self.send_header('Content-Length',str(size));self.end_headers() for offset in range(0,size,len(chunk)): self.wfile.write(chunk) with lock:state[path]['written']=offset+len(chunk) if gated and offset==0:assert gates[path].wait(15) with lock:state[path]['done']=True ThreadingHTTPServer(('0.0.0.0',8081),Handler).serve_forever() ''' locations=''' location /early-on { proxy_buffering on; proxy_pass http://backend:8081; } location /early-off { proxy_buffering off; proxy_pass http://backend:8081; } location /large-on { proxy_buffering on; proxy_pass http://backend:8081; } location /large-off { proxy_buffering off; proxy_pass http://backend:8081; } location /large-nodisk { proxy_buffering on; proxy_max_temp_file_size 0; proxy_pass http://backend:8081; } location / { proxy_pass http://backend:8081; } ''' extra=''' proxy_buffer_size 4k; proxy_buffers 4 4k; proxy_busy_buffers_size 8k; proxy_temp_file_write_size 8k; proxy_max_temp_file_size 64m; proxy_temp_path /tmp/proxy; ''' with lab(config(locations,extra),backend=backend) as (proxy,request): results={} def state():return json.loads(request('/state')[2]) for mode in ('on','off'): path='/early-'+mode;connection=http.client.HTTPConnection('127.0.0.1',request.port,timeout=10) connection.request('GET',path);response=connection.getresponse();first=response.read(1024) snapshot=state()[path];assert response.status==200 and first==b'x'*1024 and not snapshot['done'] assert request('/release?path='+path)[0]==204 rest=response.read();connection.close();assert first+rest==b'x'*(2*65536) results[path]={'first_bytes_before_backend_completion':len(first),'backend_written_at_first_read':snapshot['written']} total=32*1024*1024;expected=hashlib.sha256(b'x'*total).hexdigest() for mode in ('on','off','nodisk'): path='/large-'+mode;sock=socket.socket();sock.setsockopt(socket.SOL_SOCKET,socket.SO_RCVBUF,65536) sock.settimeout(20);sock.connect(('127.0.0.1',request.port)) connection=http.client.HTTPConnection('127.0.0.1',request.port,timeout=20);connection.sock=sock connection.request('GET',path);response=connection.getresponse();assert response.status==200 wait(lambda:state().get(path,{}).get('written',0)>0) if mode=='on':wait(lambda:state()[path]['done']) else: previous=-1;stable_since=time.monotonic();deadline=time.monotonic()+10 while time.monotonic()<deadline: current=state()[path];assert not current['done'],current if current['written']!=previous:previous=current['written'];stable_since=time.monotonic() if time.monotonic()-stable_since>1:break time.sleep(.05) else:raise AssertionError('backend did not reach observable backpressure') before=state()[path] if mode!='on':assert 0<before['written']<total sock.setsockopt(socket.SOL_SOCKET,socket.SO_RCVBUF,1024*1024) digest=hashlib.sha256();received=0 while block:=response.read(1024*1024):digest.update(block);received+=len(block) connection.close();wait(lambda:state()[path]['done']) assert received==total and digest.hexdigest()==expected results[path]={'before_client_drain':before,'received_bytes':received,'sha256':digest.hexdigest()} log=docker('exec',proxy,'cat','/tmp/error.log').stdout spills=[line for line in log.splitlines() if 'buffered to a temporary file' in line] assert any('/large-on ' in line for line in spills),log assert not any('/large-off ' in line or '/large-nodisk ' in line for line in spills) results['temporary_file_used_for_large_on']=True print(json.dumps(results,sort_keys=True)) 首先,后端为一个 131,072 字节响应写出 65,536 字节,然后等待显式放行。无论响应缓冲开启还是关闭,客户端都能在后端尚未完成时读到 1,024 字节正文。脚本随后才放行后一半,并检查完整响应。这直接否定了“开启响应缓冲就一定等上游整份响应完成才交付”的说法;受控开关把响应尚未完成这一事实固定下来。 ...