记录Brotli预压 + Gzip兜底 的压缩方案

摸鱼大王.
192

前言

CDN上那堆CSS、JS、字体文件,一直用的gzip实时压缩。brotli同质量下比gzip再小15%-25%,眼馋很久了。但宝塔的Nginx要上brotli得编译ngx_brotli模块,我这台机器不想折腾重编译。于是我换了个思路:本地把静态资源提前压成 .br 文件,Nginx直接发预压缩产物,不走动态压缩。gzip实时压缩留着,给没预压缩的东西兜底。折腾完踩了一堆坑,记一下。

思路:较大的静态资源交给brotli预压缩,其余交给gzip

压缩分两种时机。实时压缩在请求来的时候压,吃CPU但零部署成本;静态预压缩提前压好,请求时直接发,零CPU但要维护产物。我不想全量预压缩——那样打包和维护成本会失控。所以定了这个分工:

  • CSS、JS、字体、SVG这些高频静态资源,本地用brotli q11预压缩,压缩率拉满,运行时零开销。
  • 其余所有响应(PHP动态内容、图片、没预压缩的资源、不支持br的客户端),全交给gzip实时压缩兜底。

关键就一句:预压缩只做有限一类文件,不是全量。CPU只花在没预压缩文件的请求上。

不装模块怎么让Nginx发 .br

没有ngx_brotli模块,Nginx根本不认识 .br 文件,直接发会被浏览器当二进制乱码。我用了三步绕过去:

  • 用map把Accept-Encoding含br的请求,映射成一个 .br 后缀变量。
  • 用try_files优先发 .br,不存在或不支持br就回退原始文件。
  • 用独立location匹配 .br$,手动补Content-Encoding: br和正确的Content-Type。

blog . ai stu . c n,在其他站点看到此文字均为抄袭!caiji4全加

第一个坑:try_files不重新匹配location

我一开始用 try_files $uri$br_suffix $uri =404,心想命中 .br 后会进 .br$ 块补头。结果请求style.css返回的 .br 字节流,压根没有Content-Encoding头,浏览器直接乱码。查了半天发现:try_files命中文件后是内部静态服务,不会重新跑location匹配,所以补头的 .br$ 块根本没进去。

解决办法是把try_files换成 rewrite ... last。last标志会重新跑location匹配,命中 .br$ 块补头。这俩看着像一回事,行为差远了。

第二个坑:default_type不支持变量

发 .br 文件,Content-Type必须是原始文件的类型——style.css.br得返回text/css,不然浏览器收到brotli流不当样式表执行。我一开始想用map变量按扩展名还原类型:default_type $br_content_type。结果响应头里原样输出 content-type: $br_content_type,变量名直接出来了,没求值。宝塔这个Nginx 1.31.1是定制编译版,default_type不支持变量。

又试了 types { text/css css.br; } 多扩展名映射,也不行。Nginx的types只看最后一个点后的扩展名,style.css.br被当成 .br 查表,匹配不到css.br键,回退成octet-stream。

最后老老实实每个类型一个location、硬编码default_type。看着啰嗦,但完全不依赖变量求值和多扩展名解析,行为可预期:

location ~* \.css\.br$ {
    gzip off;
    default_type text/css;
    add_header Content-Encoding br;
    add_header Vary Accept-Encoding;
    expires 30d;
}

这里gzip off很重要,不然响应被全局gzip模块叠加一个多余的Content-Encoding: gzip头,和br同时出现。

第三个坑:宝塔编辑器吞map正则的引号

判断客户端支不支持br的map块,正则本来写的 ~* "br"。在宝塔Web编辑器里保存,的反斜杠和引号经常被吞,变成 ~br。变量永远为空,try_files永远不走 .br,我还以为是别的问题,排查了好久。

两个规避办法一起用:正则改成 "~*(?:^|,)\s*br(?:\s*,|$)",不依赖,用捕获组做边界;再把map块放到独立文件用include加载,不让宝塔编辑器碰它。这之后才稳。

打包侧:只给CDN包预压缩

.br文件从哪来?打包脚本里用 brotli -q 11 -kf 生成。Git Bash自带brotli,不用额外装。但我没对所有包都压——完整主题包要能独立运行,精简包是去CDN化的版本,只有CDN托管包需要 .br。打包脚本只对CDN包做预压缩,扩展名白名单也只含前端资源(css/js/svg/woff2/ttf这些),PHP压根不在白名单里。PHP是服务端代码,不经过浏览器,压它毫无意义。

有个批处理的坑值得说:预压缩逻辑我一开始封装成 call :子程序,结果for /f带findstr管道的子程序在exit /b返回后,cmd报"找不到批处理标签run_zip",后续打包全崩。改成内联展开就好了。cmd这返回点bug挺离谱的。

gzip不能关,它是兜底

配完brotli我一度想把源站gzip关了,幸亏没关。gzip实时压缩是整个架构的兜底半边:

  • 不支持br的客户端请求预压缩资源,回退到原始文件,靠gzip实时压。
  • 所有没有 .br 的响应,靠gzip实时压。

关了gzip这些场景就裸奔。正确关系是并存:有 .br 走brotli静态,没有就走gzip实时,自动分工。

前面套了CDN的话

我源站前面还套了阿里云ESA。配完源站用curl --resolve直连源站IP验证,确认源站确实返回Content-Encoding: br。但经ESA的请求,响应头里content-encoding: br和content-encoding: gzip同时出现——ESA边缘又叠了一层压缩头。浏览器取第一个,用br,功能正常。这是ESA的冗余行为,懒得管了。

收尾

最终链路:打包脚本生成 .br → CDN包部署到源站 → Nginx硬编码块补br头 → ESA透传并缓存 → 浏览器收到brotli。三个坑记住就行:try_files不重匹配location得用rewrite last、default_type不支持变量得硬编码、map正则被编辑器吞字符得放独立文件。剩下的就是gzip留着兜底,别关。

摸鱼小窝」 (blog.aistu.cn)版权所有,引用、转载时必须标明原文出处!

Comments | 2 条评论

    加载中

消息盒子
# 消息加载中 #