gzip 是响应内容编码:nginx 可以发送压缩表示,由接受该编码的客户端解码。启用它需要 gzip on;gzip_types 扩展有资格压缩的 MIME 类型,却不会单独激活压缩。最终结果取决于响应、请求方能力和实际生效配置。只有压缩后仍能正确还原目标内容,较小正文才有价值,不能把“传输字节变少”与“功能已经正确”直接画等号。
坏掉的样子
在 gzip off 下列出 MIME 类型,不会让响应变成 gzip。另一个常见遗漏是 gzip_vary on。nginx 并不会仅因为发生压缩,就无条件添加 Vary: Accept-Encoding;该行为受对应指令控制。共享缓存需要知道哪些请求头维度影响表示选择,否则不同解码能力的客户端可能拿到不合适的已存响应。压缩成功和缓存选择正确是两个需要分别检查的结果。
最小长度设置的契约比名称暗示的更窄。gzip_min_length 根据响应 Content-Length 字段确定长度,不一定知道未指定长度响应的最终大小。因此,一个很短的动态流式响应仍可能被压缩,而同样短的静态文件会因为已知长度低于阈值而跳过压缩。不能从阈值配置推断所有小响应都不会进入压缩路径。
测量方法也可能误导。解压后输出文件的大小,不是压缩传输正文大小;压缩正文字节也不包含全部 HTTP 分帧、响应头和 TLS 流量。.gz 等文件扩展名本身不能证明实际编码。解释任何表面上的带宽节省之前,都应检查真实 Content-Encoding,并解码核对字节,否则可能只是把两种不同计量对象放在一起比较。
快速验证
下文集成实验的结果保留自历史运行记录;本次修订没有重新执行该实验,不能将这些记录当成本次环境的新实测。
将脚本保存为 check-gzip.py,准备 Python、Docker 和固定镜像后运行。记录环境为 Linux arm64 上的 nginx 1.28.0,以及一个小型 Python 后端。nginx 显式开启三级 gzip,在内置 HTML 资格之外允许 JSON,设置 1,024 字节下限,并显式启用 Vary。服务只发布到宿主回环,实验完成后清理自己的资源:
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 gzip,hashlib
backend=r'''
from http.server import BaseHTTPRequestHandler,ThreadingHTTPServer
class Handler(BaseHTTPRequestHandler):
protocol_version='HTTP/1.1'
def log_message(self,*args):pass
def do_GET(self):
self.send_response(200);self.send_header('Content-Type','application/json')
self.send_header('Connection','close');self.end_headers();self.wfile.write(b'{"ok":true}\n');self.close_connection=True
ThreadingHTTPServer(('0.0.0.0',8081),Handler).serve_forever()
'''
payload=json.dumps({'message':'compressible nginx response '*1000}).encode()
locations='''
location = /disabled.json { gzip off; gzip_types application/json; }
location = /novary.json { gzip_vary off; }
location = /stream { proxy_pass http://backend:8081; }
'''
extra='''
types { application/json json; text/html html; application/octet-stream bin; }
gzip on; gzip_types application/json; gzip_vary on; gzip_min_length 1024; gzip_comp_level 3;
'''
files={name:payload for name in ['large.json','other.bin','page.html','disabled.json','novary.json']};files['tiny.json']=b'{"ok":true}\n'
with lab(config(locations,extra),files,backend) as (_,request):
results={}
for encoding in ['gzip','identity','gzip;q=0','br']:
status,h,b=request('/large.json',headers={'Accept-Encoding':encoding});assert status==200
if encoding=='gzip':assert h['content-encoding']=='gzip' and gzip.decompress(b)==payload and len(b)<len(payload)
else:assert 'content-encoding' not in h and b==payload
assert h['vary']=='Accept-Encoding'
results[encoding]={'encoding':h.get('content-encoding','identity'),'body_bytes':len(b)}
for path,encoded in [('/tiny.json',False),('/other.bin',False),('/page.html',True),('/disabled.json',False),('/novary.json',True),('/stream',True)]:
status,h,b=request(path,headers={'Accept-Encoding':'gzip'});assert status==200
assert (h.get('content-encoding')=='gzip')==encoded,(path,h)
decoded=gzip.decompress(b) if encoded else b
assert decoded==(files['tiny.json'] if path=='/stream' else files[path[1:]])
if path=='/novary.json':assert 'vary' not in h
results[path]={'encoding':h.get('content-encoding','identity'),'body_bytes':len(b),'vary':h.get('vary')}
results['decoded_sha256']=hashlib.sha256(payload).hexdigest()
print(json.dumps(results,sort_keys=True))
大型合成 JSON 表示共有 28,015 字节。请求携带 Accept-Encoding: gzip 时,记录到的响应传输正文为 246 字节,Python gzip 解码器能逐字节还原原内容。这份负载刻意包含大量重复文本,用于验证压缩确实发生且内容完整;它的压缩比不能当成生产 JSON、JavaScript 或任意文本的收益预测,更不能直接换算成固定页面加速倍数。
接受 identity、设置 gzip;q=0,或只声明 br 的请求,都得到没有 Content-Encoding 的原始字节。质量值为零表示不接受 gzip;声明 Brotli 也不会在此 nginx 配置中自动安装 Brotli 压缩器,而该请求仍允许 identity。对于具备压缩资格的大响应,包括未压缩变体在内,都能看到 Vary: Accept-Encoding,使选择维度得到明确表达。
同一份合成数据按不具压缩资格的二进制类型提供时保持原样;按 HTML 类型提供时,即使没有在 gzip_types 列出 HTML,仍具备压缩资格。另一个 location 显式设置 gzip off,尽管列出了 JSON,也不会压缩。这些对照把类型资格与总开关区分开来,避免把每次没有 Content-Encoding 都误判为客户端协商失败。
12 字节的静态 JSON 响应因为已知长度低于下限而保持未压缩。后端返回相同的 12 字节,但不提供 Content-Length,而以关闭连接结束响应;nginx 将其压缩为 32 字节。因此,下限并不保证所有微小响应都跳过压缩,这份具体的小负载反而增长了。选择策略时需要考虑长度是否已知,不能只根据最终文件大小反推配置一定如何工作。
最后一个 location 保持 gzip 开启,却关闭 gzip_vary。正文仍以 gzip 到达,但没有 Vary 字段。这个对照让缺失缓存选择维度的问题可以直接观察,无需先让真实共享缓存错误交付一次内容。实验核对的是响应头策略;部署后的缓存是否按该维度正确选择,还应通过实际缓存路径验证。
用有代表性的响应调整策略
选择实际字节适合压缩的内容类型。已经压缩的媒体或归档通常很难从再次编码中获益,而重复文本可能显著缩小。这里的字节计数实验没有测量 CPU 成本。忙碌服务选择压缩等级前,应比较代表性大小、不同设置、服务器利用率和客户端延迟,不应把合成数据的高压缩率当成通用容量结论。
经过代理时,响应元数据也要保持准确。Content-Encoding 告诉接收方怎样解码表示,Vary 告诉缓存哪些请求字段影响选择。如果应用已经发送编码响应,应单独审阅那条路径,而不是给未编码字节补一个会误导客户端的头。最终有判定力的验证始终是:协商结果正确、响应头与正文一致,并且实际解码后逐字节匹配目标内容。
延伸阅读
来源
- nginx gzip module,启用 gzip、MIME 资格、Content-Length 阈值与 Vary,已核验 2026-10-15。
- RFC 9110 HTTP Semantics,Accept-Encoding、Content-Encoding 与表示选择,已核验 2026-10-15。