响应缓冲允许 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 字节正文。脚本随后才放行后一半,并检查完整响应。这直接否定了“开启响应缓冲就一定等上游整份响应完成才交付”的说法;受控开关把响应尚未完成这一事实固定下来。
接着,客户端收到 32 MiB 响应头后暂停消费正文。开启响应缓冲、临时文件上限设为 64 MiB 时,后端在客户端继续读取前记录自己已经写完整份响应。nginx 错误日志同时确认该响应使用了临时文件。后端进度和真实写盘日志共同支持结论,不能只根据某次请求耗时短,就推断一定发生了磁盘缓冲。
关闭响应缓冲时,客户端保持暂停,后端完成写入的计数停在 32 MiB 以下。实验要求计数在受控观察区间内保持不变,然后恢复客户端读取;后端随后完成,完整摘要也匹配。另一个对照保留响应缓冲,但设置 proxy_max_temp_file_size 0,同样在客户端恢复消费前出现可观察反压,说明开启内存缓冲也不提供无限吸收能力。
中途停住的具体字节数会随套接字和平台行为变化。它们是本次观察结果,不是推荐缓冲大小,也不是跨平台固定阈值。关键差别是:客户端暂停时,有一条路径完成了响应生产,另外两条受限路径没有。最终三条路径都交付恰好 33,554,432 字节,并通过同一个预期 SHA-256 摘要检查,证明恢复读取后内容完整。
为缓冲分配预算,并验证可见进度
proxy_buffer_size 处理上游响应的第一部分,通常包含响应头;proxy_buffers 配置其他响应缓冲区;临时文件指令控制这里未缓存响应的磁盘使用。不要把这些限制误当成最大可接受响应长度。客户端读取后释放容量,较大响应可以继续传输,并不是只要超过内存缓冲区,就必然被拒绝或要求一次性保存到内存。
上游可以通过 X-Accel-Buffering 请求改变响应缓冲,除非 nginx 配置为忽略该响应头。当 location 的预期策略似乎没有生效,应检查应用是否发出了这个控制信号。请求正文缓冲是另一项设置,不会被这个响应控制改变;定位问题时必须先确定受到影响的是上传方向,还是返回客户端的方向。
对于普通下载,应同时考虑后端占用、代理磁盘使用和客户端完成时间。对于渐进事件,应测量每个事件什么时候可见,并加入慢读客户端。一次快速本地传输,既不能证明已经隔离慢客户端负载,也不能证明流式延迟满足应用契约。这里的受控暂停和完成计数用于揭示边界,生产容量仍应依据真实响应大小与并发分布确定。
延伸阅读
来源
- nginx proxy module,响应缓冲区、临时文件与 X-Accel-Buffering 控制,已核验 2026-10-15。
- nginx reverse proxy guide,缓冲转发与向客户端同步转发,已核验 2026-10-15。