响应缓冲允许 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 的预期策略似乎没有生效,应检查应用是否发出了这个控制信号。请求正文缓冲是另一项设置,不会被这个响应控制改变;定位问题时必须先确定受到影响的是上传方向,还是返回客户端的方向。

对于普通下载,应同时考虑后端占用、代理磁盘使用和客户端完成时间。对于渐进事件,应测量每个事件什么时候可见,并加入慢读客户端。一次快速本地传输,既不能证明已经隔离慢客户端负载,也不能证明流式延迟满足应用契约。这里的受控暂停和完成计数用于揭示边界,生产容量仍应依据真实响应大小与并发分布确定。

延伸阅读

来源