用 Zstandard 和 Pingora 为缓存腾出更多空间

2026-09-01 44 预计阅读时间: 1 分钟
来源: blog.cloudflare.com AI 摘要 Original link

Disclaimer: This article is an AI-assisted summary. Read it together with the original source when precision matters. The summary may omit context, version differences, or edge cases and is not official documentation.

预计阅读时间:10 分钟

缓存容量往往不是由单个大对象决定的,而是被海量 HTML、JSON、JavaScript、CSS 和 API 响应一点点吃掉。当硬件预算固定时,一个直接的问题是:能不能让同样的磁盘存下更多缓存对象?

Cloudflare 围绕这个问题,在缓存层原型验证了压缩方案:把 Zstandard 引入缓存存储,并结合 Pingora 这样的高性能代理层,在命中缓存时完成解压,在写入缓存时保存压缩后的内容。这个思路的价值不只是“压缩率更高”,而是把存储、CPU、延迟和缓存命中率放在同一个系统里权衡。

缓存压缩改变了什么

传统缓存通常直接保存响应体。响应进入缓存后,磁盘占用大致等于响应的原始大小;如果客户端本身支持 gzip 或 Brotli,代理层也可能保存不同编码版本,从而进一步增加缓存副本数量。

在缓存内部使用 Zstandard,可以把“客户端看到的表示”和“缓存实际保存的表示”分开:

  1. 上游响应进入缓存时,代理层判断对象是否适合压缩。
  2. 缓存写入压缩后的字节,并记录压缩算法、原始大小和校验信息。
  3. 请求命中时,代理层从缓存读取压缩数据。
  4. 根据客户端能力解压,或继续转换为客户端接受的编码。

这会带来几个可能的收益:

  • 相同磁盘容量可以保存更多对象。
  • 热门对象的缓存生命周期可能变长,减少淘汰和回源。
  • 磁盘读写量可能下降,因为落盘数据更小。
  • CPU 消耗会上升,尤其是在高并发解压或高写入速率场景。

压缩并不等于所有对象都应该压缩。JPEG、PNG、WebP、MP4、ZIP 和已经使用 gzip 或 Brotli 的内容通常收益有限,继续压缩还可能浪费 CPU。

Zstandard 适合缓存层的原因

Zstandard 的核心吸引力是可以在压缩比和速度之间调节。缓存系统更关心整体成本,而不是单次压缩的极限比率:

  • 写入路径通常可以使用较低压缩级别,尽快完成缓存填充。
  • 热点对象可以在后台重新压缩,以换取更小的存储占用。
  • 读取路径应优先保证低延迟,解压通常比压缩轻量,但仍需要纳入容量规划。
  • 小响应不一定值得压缩,因为元数据和固定开销可能抵消收益。

一个实用的判断函数可以从响应类型、大小和已有编码入手:

should_compress(response):
    if response.status is not 200:
        return false
    if response.content_length < 1024:
        return false
    if response.content_encoding is not empty:
        return false
    if response.content_type is image/video/archive:
        return false
    return true

这里的阈值只是可以这样实践的起点,不是所有业务都适用的固定配置。真正上线前,应使用生产流量样本测量压缩后的大小、CPU 时间和端到端延迟。

Pingora 放在哪里

Pingora 可以承担代理层中的缓存读写和响应处理职责。一个清晰的实现边界是:缓存索引仍然使用稳定的对象键,而缓存值增加一层内部封装信息,例如:

cache-key:   scheme + host + path + normalized-query + vary-fields
cache-value: magic | version | codec | original-size | compressed-body | checksum

codec 至少要区分 identityzstdversion 用来支持未来修改封装格式;checksum 则帮助发现截断、错误复用或存储损坏。

需要特别注意 Vary、内容协商和缓存键的一致性。如果客户端请求带有不同的 Accept-Encoding,不能把一个只适用于某种客户端的表示错误地复用给另一种客户端。比较稳妥的做法是明确区分两层:

  • 内部存储编码:例如 zstd,只服务于缓存磁盘或对象存储。
  • 传输编码:例如客户端声明接受的 Brotli、gzip 或 identity。

这样,内部使用 Zstandard 不必强迫客户端理解 Zstandard,也不会把存储实现细节泄露到 HTTP 协议层。

可以直接改造的压缩实验

下面的脚本使用系统中的 zstd 命令,对一个响应文件进行压缩、解压和校验。它适合先用真实缓存样本做离线估算。运行前准备一个名为 response.bin 的响应体,并安装 Zstandard 命令行工具。

#!/usr/bin/env bash
set -euo pipefail

input="${1:-response.bin}"
output="${input}.zst"
roundtrip="${input}.roundtrip"
level="${ZSTD_LEVEL:-3}"

[[ -f "$input" ]] || {
  printf 'input file not found: %s\n' "$input" >&2
  exit 1
}

zstd -q -f -"$level" "$input" -o "$output"
zstd -q -d -f "$output" -o "$roundtrip"

cmp "$input" "$roundtrip"

original_bytes=$(wc -c < "$input")
compressed_bytes=$(wc -c < "$output")
ratio=$(awk -v original="$original_bytes" -v compressed="$compressed_bytes" \
  'BEGIN { if (original == 0) print "0.00"; else printf "%.2f", compressed / original }')

printf 'input=%s original_bytes=%s compressed_bytes=%s compressed_ratio=%s level=%s\n' \
  "$input" "$original_bytes" "$compressed_bytes" "$ratio" "$level"

可以用不同级别比较 CPU 与空间的关系:

for level in 1 3 6 9; do
  ZSTD_LEVEL="$level" ./measure-zstd.sh response.bin
 done

离线结果至少应记录原始大小、压缩后大小、压缩耗时、解压耗时和内容类型。不要只拿平均压缩率做决定,因为缓存容量通常由长尾对象、热点分布和淘汰策略共同决定。

上线前需要验证的指标

原型验证成功,并不意味着可以立刻对所有缓存对象启用压缩。建议把实验拆成小范围流量,并同时观察以下指标:

  • 存储收益:压缩前后字节数、有效容量、每个缓存节点的对象数。
  • 命中效果:缓存命中率、回源请求数、热点对象被淘汰的次数。
  • 性能成本:压缩和解压 CPU 时间、代理进程 CPU 利用率、P50/P95/P99 延迟。
  • 稳定性:压缩失败、解压失败、校验失败、格式版本不匹配和缓存损坏数量。
  • 行为一致性Content-LengthETagRangeVary 以及流式响应是否仍然正确。

还要设计降级路径。压缩失败时可以回退到原始响应;解压失败时应删除损坏的缓存条目并回源,而不是把缓存错误直接返回给客户端。缓存格式升级也应支持旧格式读取和逐步淘汰,避免一次发布造成大规模缓存失效。

采用建议

Zstandard 加入缓存层的最大价值,是用可控的 CPU 预算换取更高的有效缓存容量。它特别适合文本和结构化响应占比高、磁盘容量紧张、回源成本明显的系统;对已经压缩的媒体和归档文件,收益通常较小。

可以按下面的顺序推进:

  1. 抽取一段真实缓存样本,按内容类型和大小分组测量。
  2. 先在离线工具中确认压缩比与 CPU 成本。
  3. 在 Pingora 缓存写入路径增加版本化的内部封装格式。
  4. 只对文本类、超过最小大小的对象启用,并保留快速回退。
  5. 使用小流量灰度观察容量、命中率和尾延迟。
  6. 只有当容量收益覆盖 CPU 与运维复杂度后,再扩大范围。

最终目标不是让每个字节都尽可能小,而是在相同硬件上保存更多有价值的缓存,同时让代理层的延迟和故障边界保持可控。


相关推荐