bigywater.tech
爬虫工程 · 7

响应压缩:gzip、br、zstd 与自动解压

Accept-Encoding 看起来是个“自动的事”,但它决定了你从服务器拿到什么、省多少流量、遇到压缩炸弹时会不会把内存撑爆。这篇在本机用 gzip、deflate、zstd、br 各传一遍同一个页面,再试试没装库的客户端和一个 64 MiB 的压缩炸弹。

3.2%
br 传输同一个 HTML 页面的字节数占原始大小的比例(gzip 7.3%,zstd 4.9%)
9196
没装 brotli 的客户端拿到的 r.content 字节数——还是压缩数据,不报错
180 MiB
65250 字节的 gzip 响应用普通 get 解压后的进程峰值内存(stream 读 1 MiB 仅 34 MiB)

Accept-Encoding 取决于装了什么

实测 · 本机执行输出
  没装 brotli:Accept-Encoding: gzip, deflate, zstd
  装了 brotli:Accept-Encoding: gzip, deflate, br, zstd
  (zstd 不需要另外装:Python 3.14 标准库自带 compression.zstd;这里装的 brotli 版本 1.2.0)

requests 通过 urllib3 声明自己能解哪些压缩:没装 brotli 时是 gzip, deflate, zstd,装了 brotli 后多出 br。zstd 来自 Python 3.14 标准库的 compression.zstd。因此换一台机器、装一个依赖,爬虫发给服务器的请求头就变了,这对排查“为什么这台机器上拿到的页面不一样”很重要。

不同压缩方式的体积

实测 · 本机执行输出
  编码            线上字节     占原始比例  解压后字节  解压结果与原文一致
  identity    291470    100.0%     291470  True
  gzip         21308      7.3%     291470  True
  deflate      21296      7.3%     291470  True
  zstd         14369      4.9%     291470  True
  br            9196      3.2%     291470  True

对 29 万字节的重复 HTML,gzip 和 deflate 都压到约 7.3%,zstd 4.9%,br 3.2%。这些数字依赖我选的压缩参数(gzip 6、zstd 默认、br quality 5)和这份高度重复的数据,不要当成通用比例。服务端实际用什么级别,取决于它自己的配置。

已压缩的内容不会再变小

实测 · 本机执行输出
  编码            线上字节     占原始比例
  identity    291470    100.0%  一致=True
  gzip        291578    100.0%  一致=True
  deflate     291566    100.0%  一致=True
  zstd        291488    100.0%  一致=True
  br          291475    100.0%  一致=True

随机字节(也近似已经压缩过的图片、视频)用任何方式压缩都不会变小,反而多出几十到一百多字节的头尾。对文本页面启用压缩有价值,对图片下载没有。

客户端没装解压库

实测 · 本机执行输出
  没装 brotli:Content-Encoding=br 线上 9196 字节,r.content 长度 9196,与原文一致=False
  装了 brotli:Content-Encoding=br 线上 9196 字节,r.content 长度 291470,与原文一致=True

服务器本不该发客户端没声明的编码,但如果发了,没装 brotli 的客户端不会抛异常,r.content 是原样的压缩字节(9196 字节),和原文不一致。如果你的解析代码直接处理这些字节,得到的只是乱码。检查 Content-Encoding 与实际内容长度是否匹配,可以尽早发现。

压缩炸弹

实测 · 本机执行输出
  线上传输的字节数(压缩后)= 65250
  普通 get:r.content 67108864 字节,耗时 49 ms,进程峰值内存 180 MiB
  stream + 读满 1 MiB 就停:读到 1048576 字节,耗时 2 ms,进程峰值内存 34 MiB

一个 65250 字节的 gzip 响应能解压成 64 MiB。普通 requests.get 把它全部放进内存,进程峰值 180 MiB;stream=True 读到上限就停,峰值只有 34 MiB。对不可信的站点,把读取量限制在合理范围内(这里只演示了按解压后的字节数截断,没有测别的防护方式)。

分步演示

响应压缩
本步的对象

    本文用到的工具与边界

    • 本机 macOS 27 arm64 / Python 3.14.8 / requests 2.34.2 / aiohttp 3.14.3 / lxml 6.1.1(系统 Python);所有请求只发给本机自己起的测试服务器(127.0.0.1,端口 9960 以上,不访问任何真实网站);服务器可以按实验需要制造延迟、断连、压缩、重定向等行为,并记录收到的原始请求;计时用 time.perf_counter,数字每次运行会有小幅抖动。brotli 1.2.0 用 pip 装进实验专用的临时目录,客户端分别在装了和没装的环境里运行(用 PYTHONPATH 控制);zstd 用 Python 3.14 标准库 compression.zstd;服务器按路径强制返回指定编码;“线上字节”用 stream=True 加 r.raw.read(decode_content=False) 读取;内存是进程的 ru_maxrss;gzip 压缩级别 6、br quality 5、zstd 默认级别
    • 没有验证:真实站点(nginx、CDN)按 Accept-Encoding 协商压缩的行为;不同压缩级别与不同内容类型下的比例;aiohttp 的解压行为;Transfer-Encoding 与 Content-Encoding 叠加的情形;限制解压后大小的其它办法(如 urllib3 的相关参数,没有试);brotli 的流式解压内存占用
    京ICP备2026067502号-1京公网安备11010502063487号