nginx 可以一边缓冲一边转发尚未完成的响应,有限存储决定它能领先客户端多远。

响应缓冲在资源预算内吸收两端速度差

响应缓冲允许 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 字节正文。脚本随后才放行后一半,并检查完整响应。这直接否定了“开启响应缓冲就一定等上游整份响应完成才交付”的说法;受控开关把响应尚未完成这一事实固定下来。 ...

十二月 7, 2027 · 3 分钟 · XB Field Notes
开启请求缓冲时上传先停留在 nginx,关闭后正文可在客户端发送结束前到达后端。

请求缓冲决定后端何时收到上传正文

一次上传经过两条连接:客户端到 nginx,以及 nginx 到应用。proxy_request_buffering on 会先读完客户端请求正文,再把该请求发送给上游。关闭它后,nginx 可以随着正文到达逐步转发。这个选择改变应用何时开始占用资源,却不会创造无限容量,也不会使慢发送方消失。应先明确应用是否需要边收边处理,再决定正文在哪一层等待。 坏掉的样子 常见错误是关错指令。proxy_buffering 控制响应,proxy_request_buffering 控制上传请求。仅关闭响应缓冲,流式上传仍可能停在代理处。反过来,如果应用原本应接收完整的小型提交,却在全站关闭请求缓冲,又没有考虑后端并发能力,慢客户端就可能长时间占住应用处理资源。两个方向的策略不能因为名称相似就混为一谈。 缓冲把资源压力转移到 nginx。请求正文超过配置的内存缓冲后,可以使用客户端正文临时存储。因此,大量并发上传会占用代理连接,并可能消耗磁盘空间或磁盘读写能力。增大某个缓冲值不能代替正文大小、上传超时、并发限制和临时文件系统容量规划。缓冲区也不需要一律扩大到能在内存中容纳完整上传。 流式转发还改变失败处理。nginx 一旦已经开始向上游发送请求正文,就不能简单把这个流式请求交给下一台服务器。应用可能已经处理前缀数据,甚至产生副作用。缓冲可以为某些重试路径保留正文,但不能让原本不安全的业务操作突然变得可安全重放。是否允许重试,还取决于实际方法、应用语义以及已经完成的工作。 快速验证 将脚本保存为 check-upload.py,先取得其中固定摘要的镜像,再用宿主 Python 和 Docker 执行。记录环境为 Linux arm64 上的 nginx 1.28.0、Python 3.11.16 容器。脚本建立专用后端网络,只把 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): 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 import hashlib,json,threading state={};lock=threading.Lock() class Handler(BaseHTTPRequestHandler): protocol_version='HTTP/1.1' def log_message(self,*args):pass def reply(self,data): data=json.dumps(data).encode();self.send_response(200) self.send_header('Content-Length',str(len(data)));self.end_headers();self.wfile.write(data) def do_GET(self): with lock:self.reply(dict(state)) def do_POST(self): length=int(self.headers['Content-Length']) with lock:state[self.path]={'entered':True,'bytes':0} first=self.rfile.read(1) with lock:state[self.path]['bytes']=len(first) body=first+self.rfile.read(length-1) with lock:state[self.path]['bytes']=len(body) self.reply({'length':len(body),'sha256':hashlib.sha256(body).hexdigest()}) ThreadingHTTPServer(('0.0.0.0',8081),Handler).serve_forever() ''' locations=''' location /on { proxy_request_buffering on; proxy_pass http://backend:8081; } location /off { proxy_request_buffering off; proxy_pass http://backend:8081; } location /state { proxy_pass http://backend:8081; } ''' with lab(config(locations,'client_max_body_size 1m; proxy_http_version 1.1;'),backend=backend) as (_,request): payload=b'x'*65536;results={} def state():return json.loads(request('/state')[2]) for mode in ('on','off'): connection=http.client.HTTPConnection('127.0.0.1',request.port,timeout=10) connection.putrequest('POST','/'+mode);connection.putheader('Content-Length',str(len(payload))) connection.endheaders();connection.send(payload[:1024]) if mode=='off':wait(lambda:state().get('/off',{}).get('bytes')==1) else: until=time.monotonic()+1 while time.monotonic()<until: assert '/on' not in state();time.sleep(.05) before=state().get('/'+mode) connection.send(payload[1024:]);response=connection.getresponse();data=json.loads(response.read());connection.close() assert response.status==200 and data=={'length':65536,'sha256':hashlib.sha256(payload).hexdigest()} results[mode]={'before_completion':before,'result':data} connection=http.client.HTTPConnection('127.0.0.1',request.port,timeout=5) connection.putrequest('POST','/off-too-large');connection.putheader('Content-Length',str(2*1024*1024)) connection.endheaders();response=connection.getresponse();response.read();connection.close() assert response.status==413 and '/off-too-large' not in state() results['oversize_status']=response.status print(json.dumps(results,sort_keys=True)) 客户端声明正文共 65,536 字节,起初只发送 1,024 字节。开启请求缓冲时,在受控暂停期间反复读取后端状态,都看不到上传处理器被进入。客户端已经开始发送,但应用还没有被要求消费不完整正文。直接观察后端状态,比只比较整个请求耗时更能说明等待发生在哪一层,也避免把一次快速响应误当成流式处理证据。 ...

四月 13, 2027 · 2 分钟 · XB Field Notes