Long-form — Sep 20, 2026

长文:Cloudflare 用数学省下 100TB 内存——一致性哈希的参数推导、Rust 内存布局与双环灰度迁移

Long-form 6 min read ·

有些优化的迷人之处在于,它不需要新架构、不需要新硬件,只需要有人愿意把「显然正确」的默认值重新算一遍。

Cloudflare 在 2026 年 9 月 18 日发布的那篇技术复盘就是这种。性能团队收到一张工单,标题很朴素:pingora-ketama 内存占用异常。顺着这张工单查下去,最后从全球负载均衡服务里回收了超过 100TB 内存——而上个月,DNS 团队刚从另一条路径上省下同样量级的 100TB。

一、问题现场:6GB 是怎么堆出来的

出问题的是 PBR(Pingora Backend Router),一个基于 Pingora 构建的内部负载均衡服务,用一致性哈希把缓存请求按 URL 路由到对应节点。关键在于「内部」和「多环」这两个词。

为了满足不同功能组合——合规要求、缓存特性开关、不同业务线的配置——系统需要为每一种组合维护独立的哈希环。组合是指数增长的,所以环的数量是「2 的若干个数量级」,实际上就是几十个环同时在内存里。

每个环的成本又被两个乘数放大:

  • 每个服务器默认 160 个哈希点。这个数字直接继承自 NGINX 的默认值,很少有人追问为什么是 160。
  • 权重系数。为了支持异构服务器,哈希点数会按权重(比如磁盘容量)成比例放大。权重给到 625 的时候,单个服务器的点位数就是 160 × 625 = 100000。

两个乘数叠上几十个环,某些数据中心里单个 PBR 实例的内存占用冲到了 6GB。放到全球数千台机器的尺度上,这就是那个 100TB。

💡 值得抄走的思维方式

这不是一个「某个函数写错了」的 bug,而是一个默认值在规模放大后被重新定价的问题。160 这个数字在单机场景下几乎无感,乘上权重、乘上环数量、乘上机器数量之后,就变成了 TB 级支出。

二、把「160」放回公式里

要判断 160 是多了还是少了,先得能算。一致性哈希把服务器和缓存键都映射到 32 位无符号整数空间(一条数字线,首尾相接成环),每个服务器占据若干区间,区间总长度占比就是它的负载占比。

对于 N 台服务器,如果每台只有 1 个哈希点,理论上的统计结果是:

Exp   = 1/N
CV    = sqrt((N-1)/(N+1))

变异系数 CV 是标准差与期望之比,直接度量「负载有多不均衡」。N=100 时,CV ≈ 99%——意思是负载几乎完全不可预测,某台机器可能分到远超平均的流量。单哈希点的一致性哈希之所以在工程上必须配多哈希点,根因就在这里。

给每台服务器加 k 个哈希点后,作者自己完整推导了一遍精确表达式(而不是用常见的近似):

Exp_k = 1/N
SD_k  = sqrt((k+1)/(N*(k*N+1)) - 1/N^2)
CV_k  = sqrt((N-1)/(N*k+1))

代入 N=100、k=160,CV 降到约 8%。这个数字说明默认值并非无理由——它确实把不均衡压到了一个可用区间。

但公式也同时暴露了收益的形状。CV 随 k 增长是按 1/sqrt(k) 量级衰减的:从 1 个点到 160 个点,收益巨大;从 160 到 1600,收益已经很小;到了十万量级的点位数,几乎什么都不剩。

三、涨到 10 万之后就反向劣化了

有一段实测数据最能说明问题。当权重系数为 625 时,单个服务器的点位是 10 万个。作者算了一下最后增加的 9 万个哈希点什么也没换到——只带来 0.7% 的误差降低。也就是说 90% 的内存花在了不到 1% 的收益上。

更麻烦的是,继续加点会反向劣化。原因在 32 位哈希空间的物理边界。如果在 2048 台服务器的数据中心里把点位数从 1 万加到 10 万,整个系统占用的哈希空间密度大幅上升,按生日悖论的规律,随机碰撞概率急剧增长。碰撞意味着点位重叠、区间语义错位,最终表现为分配误差不降反升。

这是一个很典型的陷阱:参数在理论公式里单调变好,在物理实现里却是先降后升的曲线。任何依赖有限哈希空间的方案,都要先算清楚「还剩多少余量」,而不是默认加参数一定更好。

结论很清楚:每个服务器的哈希生成数量可以砍掉约 90% 而不引入明显误差。

四、Rust 的 8 字节:改字段类型是没用的

算法改完是第一步,数据结构还能再压一轮。

原来的点位结构是标准的:

struct Point {
    hash: u32,   // 4 bytes,不可避免
    index: u32,  // 4 bytes,指向服务器数组
}

团队成员提出一个观察:PBR 不可能超过 65536 台服务器,所以索引其实只需要 u16。看起来一个字段就能从 4 字节变 2 字节,结构体从 8 字节降到 6 字节。

但直接改字段类型一个字节都省不下来——Rust 的对齐规则要求结构体尺寸按最大字段的对齐单位取整,u32 + u16 仍会被补齐到 8 字节。

真正的做法是改变存储形态,用一个定长字节数组手工打包,再通过访问器还原:

struct Point([u8; 6]);

impl Point {
    fn hash(&self) -> u32 {
        u32::from_ne_bytes(self.0[0..4].try_into().unwrap())
    }
    fn index(&self) -> u16 {
        u16::from_ne_bytes(self.0[4..6].try_into().unwrap())
    }
}

这样哈希相关的内存直接降低 25%,同时避免了未对齐访问的安全隐患——这也是为什么作者强调「安全,且编译结果等价于打包属性」。

五、v2 环:兼容优先的演进

算法与结构两处改动被收进 pingora-ketama 的一个未公开 cargo feature,称为 v2 环,包含三件事:紧凑的字节数组存储格式、更快的排序方法、以及可缩放的每节点基础哈希数。

关键设计在于:v1 环保持完全兼容,新旧环可以同时在进程里运行。 这不是为了优雅,而是为了迁移。

六、真正难的部分:环变了,缓存就没了

改哈希环在缓存系统里等同于一键清空缓存。旧环上某个 URL 落在 A 机器,新环上可能落到 B 机器,而 B 没有这份缓存,只能回源。全量同时切换,等于让所有请求同时穿透到源站。

Cloudflare 的迁移架构有三层保护:

第一层,双环共存。 PBR 同时在内存里保留旧大环与新小环。内存峰值会短暂上升,但换来的是随时可退。

第二层,请求级路由决策。 借助已有的迁移框架按请求哈希稳定地选择环——同一个键在整个迁移期间必须始终走同一个环,否则会出现「一会儿命中一会儿穿透」的抖动。请求级(而非连接级)的稳定性是这里的技术要点。

第三层,按数据中心分批推进。 先在小规模验证点跑,再逐步扩大数据中心组,最后全球铺开。按数据中心整批推进而不是按流量百分比,是因为爆炸半径可控:每个区域自己承受一次缓存重建,源站看到的回源增量是有限且可预期的。反过来说,如果按百分比全球放量,同一个键会在所有区域各自回源一次。

配套的观测指标也列得很实在:后端选择追踪、环版本计数器、PBR 连接错误、进程内存、启动时间、缓存行为、源站流量。启动时间这一项常被忽略——紧凑格式带来更快的排序,环重建变快,是回滚和重启时的实际收益。

七、六条可以带走的经验

  1. 规模会重新定价默认值。 单机无感的参数,乘上组合数再乘机器数就是 TB 级成本。
  2. 先写出公式,再动参数。 变异系数把「加多少哈希点」从经验判断变成了可计算的权衡曲线。
  3. 注意曲线会掉头。 有限哈希空间下的碰撞会让指标先降后升,理论单调不等于实践单调。
  4. 内存布局是语言问题,不是字段问题。 缩小字段宽度不等于缩小结构体,要对齐规则动手就得改存储形态。
  5. 算法与系统必须协同设计。 加权 ketama 解决异构机器、多哈希点解决均衡,但两者都会推高哈希空间压力,必须一起算。
  6. 迁移设计比优化本身更重要。 改变哈希环的失败模式是缓存穿透与源站雪崩,双环共存、请求级稳定路由、按区域分批,缺一不可。

这篇文章最后那句话其实挺朴素的:出去认真算一算那些看起来「显然」的决策。Rust 是工具,数学是通用语言,而 100TB 是从一个默认值里省出来的。

Frequently asked questions

为什么改哈希环会被形容成一件危险的事?
因为一致性哈希的环决定了每个缓存键落到哪台服务器。你一旦改了环上的点位分布,同一个键的归属就可能从 A 变成 B,而 B 上并没有这个键的缓存副本。结果是一次全量缓存穿透,所有请求同时打回源站。规模小的时候这只是性能抖动,规模大的时候足以把源站打挂。所以这类优化的难点从来不是算法本身,而是如何在不丢缓存的前提下把新环铺开,迁移架构的设计比数学推导更耗精力。
变异系数这个指标到底在衡量什么?
衡量服务器之间负载的不均衡程度,定义是标准差除以期望值。期望值固定为服务器数量分之一,所以变异系数只反映分散程度:越接近零越均衡。它的实用价值在于把「要不要多加哈希点」变成了一个可以算的决策——你能直接看到从 k 个点增加到 2k 个点,变异系数降了多少,再对比内存开销涨了多少,权衡立刻量化。这也解释了为什么 NGINX 时代凭经验定下的默认值并不一定适合每一套系统。
为什么哈希点加多了反而会变差?
这是 32 位哈希空间的物理边界造成的。一致性哈希把服务器和缓存键都映射到有限的整数空间里,当每个服务器生成的哈希点数量涨到一万甚至十万量级,整个系统需要的点位数就逼近这个空间能容纳的密度,随机碰撞概率按生日悖论的规律急剧上升。碰撞意味着一个点位被覆盖或语义错位,负载分配的误差不再下降,反而开始反弹。论文之外的经验是:任何依赖哈希空间的方案都要先算清楚「还剩多少余量」,而不是默认加参数一定更好。
Rust 里把索引字段从 u32 换成 u16 为什么省不下内存?
因为结构体的实际大小受对齐规则支配。一个 u32 加一个 u32 恰好是 8 字节;如果改成 u32 加 u16,总字段宽度是 6 字节,但编译器会按最大字段的对齐要求补齐,结构体仍然是 8 字节,一个字节都没省。要实现真正的压缩,必须改变存储形态——用定长字节数组把两个字段手工打包,再通过访问器函数在读取时还原。Cloudflare 的做法是存一个 6 字节数组,配上两个读取函数,这样既省下 25% 的内存,又不用担心未对齐访问带来的安全问题。
为什么迁移要按数据中心推进而不是按流量百分比?
因为按百分比全局放量会让缓存失效的爆炸半径无法控制。同一个缓存键在旧环上落在某台机器,只要有一部分流量切到新环,这个键就会在新环的归属节点上重新回源一次;如果新旧环在全球范围内同时生效,源站会收到来自所有数据中心的重复回源。按数据中心整批推进的好处是爆炸半径被限制在一个区域内部,每个区域自己承受一次缓存重建,源站看到的增量是可预测、可等待的,出问题也能整批回滚。
// next.txt ›

Some outbound links in this post are affiliate links — see disclosure.