请求包含 gzip 压缩的任务结果 JSON

2 min read
Zekari
HTTP性能优化数据压缩API 设计网络传输

信息的两种形态

数据在网络中传输时,存在两种形态:原始的和压缩的。

原始数据直观、易读、占用空间大。压缩数据抽象、需要解码、占用空间小。

这不是技术细节,而是一个根本性的选择:你愿意用计算时间换取传输时间吗?

当一个 API 返回的 JSON 响应有几百 KB,甚至几 MB,这个选择就变得关键了。传输 1 MB 的数据可能需要几秒钟,但如果压缩到 100 KB,传输时间就缩短到原来的十分之一。代价是压缩和解压缩的计算时间,但这通常只需要几毫秒。

压缩是用 CPU 换带宽。 在大多数场景下,这是值得的。

💡 Click the maximize icon to view in fullscreen

为什么 JSON 适合压缩

JSON 的本质是文本。文本的特点是重复和冗余。

同样的键名会重复出现。"id", "name", "created_at" 这些字符串在一个包含成百上千条记录的响应中会出现无数次。

空格、换行、引号,这些格式化字符占据了大量空间,但它们的信息密度很低。

gzip 的压缩算法擅长处理这种重复。它会找到数据中重复出现的模式,用更短的符号来代替。结果是,一个典型的 JSON 响应可以压缩到原始大小的 10% 到 20%。

这不是魔法,而是利用了信息的冗余性。

压缩的效果取决于数据的冗余程度。

JSON 数据重复度高,压缩效果好。已经压缩过的数据(如图片、视频),再次压缩几乎没有效果。

随机数据无规律可循,压缩率接近零。

可压缩性反映了数据的结构化程度。

HTTP 中的压缩协商

gzip 压缩在 HTTP 协议中不是自动的,而是需要协商的。

客户端通过 Accept-Encoding 请求头告诉服务器:"我支持这些压缩格式"。服务器收到后决定是否压缩,以及使用哪种压缩格式。

如果服务器决定压缩,它会在响应头中加上 Content-Encoding: gzip,告诉客户端:"我发送的是压缩数据,你需要解压"。

这个机制优雅而实用。它允许老旧的客户端继续使用未压缩的数据,同时让现代客户端享受压缩的好处。

# 客户端请求
GET /api/tasks HTTP/1.1
Host: example.com
Accept-Encoding: gzip, deflate, br

# 服务器响应
HTTP/1.1 200 OK
Content-Type: application/json
Content-Encoding: gzip
Content-Length: 12458

[gzip 压缩的二进制数据]

这里有一个微妙的细节:Content-Length 是压缩后的大小,不是原始大小。客户端收到数据后,先检查 Content-Encoding,发现是 gzip,然后解压缩,最后解析 JSON。

实现的权衡

实现 gzip 压缩响应时,有几个关键的权衡点。

第一个是压缩级别。 gzip 支持 1 到 9 的压缩级别,1 最快但压缩率最低,9 最慢但压缩率最高。

大多数场景下,使用级别 6 或 7 就足够了。更高的级别增加的压缩时间远多于节省的带宽。

第二个是压缩阈值。 不是所有响应都值得压缩。如果一个 JSON 只有几百字节,压缩的开销可能大于收益。

通常建议只压缩大于 1 KB 的响应。小于这个阈值的数据,压缩和解压缩的成本可能抵消传输时间的节省。

第三个是动态压缩还是预压缩。 你可以在每次请求时动态压缩响应,也可以提前压缩好静态内容。

动态压缩灵活但消耗 CPU。预压缩节省计算资源但只适用于静态内容。选择哪个取决于你的内容特点和服务器资源。

压缩会隐藏原始响应的大小。

如果你的 API 返回了一个巨大的 JSON,压缩后传输很快,但客户端解压后可能发现内存不足。

压缩解决了传输问题,但没有解决数据量问题。

如果响应数据真的太大,应该考虑分页、字段过滤、流式传输等策略,而不是仅仅依赖压缩。

客户端的解压缩

大多数现代 HTTP 客户端会自动处理 gzip 解压缩。

浏览器的 fetch API、Node.js 的 axios、Python 的 requests,它们都会自动发送 Accept-Encoding: gzip,并在收到压缩响应时自动解压。

这意味着开发者通常不需要手动处理解压缩。你请求一个接口,得到的就是解压后的 JSON 数据。

但偶尔你会遇到需要手动处理的情况。可能是某个老旧的库不支持自动解压,可能是你在做底层的网络调试,可能是你在实现自己的 HTTP 客户端。

这时你需要检查响应头的 Content-Encoding,如果是 gzip,就手动解压:

// Node.js 示例
const zlib = require('zlib');

if (response.headers['content-encoding'] === 'gzip') {
  const buffer = Buffer.from(response.data, 'binary');
  const decompressed = zlib.gunzipSync(buffer);
  const json = JSON.parse(decompressed.toString('utf-8'));
}

这段代码揭示了压缩传输的完整流程:接收压缩数据 → 解压缩 → 解析 JSON。

不只是节省带宽

gzip 压缩的价值不仅仅是节省带宽。

它还缩短了用户的等待时间。一个需要 5 秒加载的页面,压缩后可能只需要 1 秒。这 4 秒的差距,决定了用户是继续等待还是离开。

它还降低了服务器的流量成本。对于高流量的 API,压缩可以让流量费用降低到原来的几分之一。

它还提升了移动端的体验。移动网络的带宽有限,延迟更高。压缩数据意味着更快的加载速度,更少的流量消耗。

压缩是对用户时间的尊重。 你用服务器的一点计算资源,换来用户几秒钟的等待时间。这个交易划算。

压缩的局限

压缩不是银弹。

它不能解决 API 设计不合理的问题。如果你的接口一次返回几千条数据,压缩后可能依然很大。

它不能替代分页。如果用户只需要看前 20 条记录,返回 1000 条并压缩,不如直接只返回 20 条。

它不能弥补网络的不稳定。如果网络频繁中断,压缩数据传输到一半失败,重试时还是要重新传输。

它也不能完全消除延迟。压缩和解压缩需要时间,虽然通常很短,但在极端性能要求的场景下,这几毫秒也可能是瓶颈。

压缩优化的是数据传输,而不是数据本身。 如果数据设计有问题,压缩只是缓解症状,不是治疗病因。

选择的本质

gzip 压缩是一个关于权衡的决定。

你在 CPU 和带宽之间选择。你在压缩时间和传输时间之间选择。你在实现复杂度和性能收益之间选择。

大多数时候,这个选择是明显的:启用 gzip,使用默认的压缩级别,让客户端自动处理解压缩。

但关键不在于遵循最佳实践,而在于理解权衡的本质。

当你的 API 响应很小时,压缩可能不值得。当你的服务器 CPU 资源紧张时,可能需要降低压缩级别。当你的客户端很老旧时,可能需要提供未压缩的备选方案。

技术选择没有绝对的对错,只有在特定场景下的适用性。

理解 gzip 压缩,不是记住它的配置选项,而是理解数据传输的本质:信息总是在某种编码和某种形态下存在,而我们永远在空间、时间、复杂度之间做选择。


为什么 brotli 压缩比 gzip 更高效,但使用得更少?新的压缩算法带来的收益是否值得额外的复杂度?

在什么情况下,不压缩反而是更好的选择?

流式传输和压缩如何结合?压缩是否会影响流式数据的实时性?

压缩后的数据还能做增量更新吗?如果 API 响应的一部分内容没有变化,是否可以只传输变化的部分?

除了 gzip,HTTP 协议还支持哪些压缩格式?它们各自的特点和适用场景是什么?

对于图片、视频等已压缩的内容,HTTP 层面的再次压缩有意义吗?