Nginx gzip 与预压缩文件:一次解压核验编码和版本一致性
同一个 app.js,不支持压缩的客户端拿到新内容,支持 gzip 的客户端却仍拿到旧内容。先检查部署目录里是不是还留着旧的 app.js.gz。预压缩文件不是 Nginx 每次从原文件现算出来的缓存,它是一份需要随构建产物同步发布的独立文件。
动态 gzip 与 gzip_static 可以同时配置。关键是区分一次请求究竟选中了哪份文件,响应如何标明编码,以及更新时两份内容是否属于同一版本。只看到磁盘文件变小,无法证明这些条件都成立。
让同一个 URL 对应两种传输形式
实验使用 Nginx 1.27.4,且二进制启用了 http_gzip_static_module。该模块不是默认构建项,先运行 nginx -V 查看编译参数;不支持时,配置检查会报 unknown directive,不能以为写上 gzip_static 就能自动获得能力。
在空目录准备文件:
mkdir -p logs site
python3 - <<'PY'
from pathlib import Path
import gzip
body = b'console.log("first version");\n' * 200
Path('site/app.js').write_bytes(body)
Path('site/app.js.gz').write_bytes(gzip.compress(body))
PY
保存为 nginx.conf:
pid logs/nginx.pid;
error_log logs/error.log;
events {}
http {
access_log logs/access.log;
server {
listen 127.0.0.1:18084;
root site;
types { application/javascript js; }
gzip on;
gzip_types application/javascript;
gzip_vary on;
gzip_static on;
location / { try_files $uri =404; }
}
}
端口空闲时,检查并启动本实例:
nginx -p "$PWD/" -c nginx.conf -t
nginx -p "$PWD/" -c nginx.conf
curl -D plain.headers http://127.0.0.1:18084/app.js -o plain.js
curl -H 'Accept-Encoding: gzip' -D gzip.headers \
http://127.0.0.1:18084/app.js -o wire.gz
请求始终是 /app.js。客户端用 Accept-Encoding 表示能够接收 gzip,服务器才选择相应表示,并用 Content-Encoding: gzip 说明正文的传输编码。不要把所有脚本标签改成直接请求 .gz 文件,文件后缀本身不会替浏览器声明响应编码。
第一次请求得到原始脚本;第二次得到压缩内容,响应包含 Content-Encoding: gzip,且本实验返回 Vary: Accept-Encoding。用一次解压核对原文:
python3 - <<'PY'
from pathlib import Path
import gzip
assert gzip.decompress(Path('wire.gz').read_bytes()) == Path('plain.js').read_bytes()
print('one decompression matches the plain response')
PY
这里没有使用 curl 的 --compressed,所以保存的是实际压缩正文。该选项会请求支持的编码并自动解压,若拿自动解压后的文件再运行 gzip 解压,就会把客户端行为误判成服务端没压缩。
两项开关一起打开,不代表连续压两遍
gzip_static on 检查可用的预压缩文件;动态 gzip 则在适用时对响应进行压缩。对于已经带有 gzip 内容编码的响应,动态过滤器不会再把它当一份普通脚本重复压缩。本实验同时启用两者,解压一次就与原文一致,实际验证了这条路径。
判断时看响应头与还原内容,不要只根据文件大小猜。两次 gzip 压缩的大小可能变化有限,下载成功也不代表浏览器能正确解释。重复的 Content-Encoding、错误的媒体类型或客户端已经解压,都会制造相似表象。
预压缩文件缺失时,动态 gzip 可以为符合条件的响应提供另一条路径。是否压缩还会受到请求协议、媒体类型、相关模块配置等影响。不能只用一个本地成功请求,就声称每一种代理链和客户端都必定走同样分支。
gzip_static always 也不是“更积极地优化”。它会不检查客户端是否支持压缩就使用压缩文件,适合有相应整体设计的场景;普通静态站点不能为了压缩率随意把 on 换成 always。保留未压缩产物,通常也让不接受该编码的客户端拥有明确退路。
更新原文件以后,再做同一组请求
保持旧 .gz 不动,只改变原始文件:
python3 - <<'PY'
from pathlib import Path
Path('site/app.js').write_bytes(b'console.log("second version");\n' * 200)
PY
curl http://127.0.0.1:18084/app.js -o after-plain.js
curl -H 'Accept-Encoding: gzip' \
http://127.0.0.1:18084/app.js -o after-wire.gz
这次本地实验中,普通响应是 second version,压缩响应解开仍是 first version。请求地址完全相同,差异来自被选中的构建文件,而不是浏览器 JavaScript 缓存必然出了问题。
修正应让原文件与压缩文件来自同一构建输入。重新生成预压缩文件以后,核对解压结果与原文一致,再发布整套版本。不要在 Nginx 配置里随机加 no-cache,试图让一份磁盘上的旧文件变成新内容。
生产构建适合使用内容哈希名称,把原始文件与压缩变体成对生成、校验和上传。入口切换以前确认两者可用,回滚也使用同一组旧产物。若先上传原文,过一会儿才上传压缩变体,发布窗口里两种客户端仍可能拿到不同内容。
文件时间也应作为构建元数据保持一致,避免缓存验证产生难以解释的差异。但修改时间一致不能替代内容校验:把旧压缩文件 touch 成新时间,并没有更新其中的字节。
缓存层需要知道响应存在多种表示
Vary: Accept-Encoding 告诉缓存,该请求头参与表示选择。中间 CDN 或代理还可能采用自己的缓存键与压缩策略,应分别请求其公开入口,核对有没有错误复用另一种编码的正文。源站正确不自动等于边缘节点正确。
如果 CDN 负责压缩,应先明确源站与边缘各自拥有哪一层编码转换,再决定源站是否保留预压缩。问题不是“压缩发生几次越少越好”,而是每一层接收和返回的编码是否与响应头一致,缓存是否保存了正确表示。
对已经压缩的图片、视频或归档,再动态 gzip 通常也不是本例要解决的问题。这里选择 JavaScript 这类文本产物,观察的是发布一致性与编码协商,没有给出任何虚构吞吐或压缩率结论。
测试结束:
nginx -p "$PWD/" -c nginx.conf -s quit
保留那条简单的构建断言就足够有用:每一个即将发布的 .gz,解压以后必须等于同版本的原文件。服务器能正确协商编码以后,下一次“只有部分客户端拿到旧脚本”,就有了一条能直接验证的路径。