本文基于 Cloudflare 官方博客,介绍其代理框架 Pingora 中的计数库 pingora-limits,以及背后采用的概率数据结构 Count-Min Sketch。

原文链接:https://blog.cloudflare.com/how-pingora-keeps-count/


从一个简单的问题说起

想象一条永不停歇的事件流:

red, blue, red, orange, green, brown, red, blue, ...

系统需要在任意时刻回答:某类事件出现了多少次?

这个问题看起来很基础,但当规模放大到 Cloudflare 的量级——数万台机器、每秒超过两千万请求——"基础"的解法往往撑不住。


最直觉的方案:哈希表

大多数人第一反应会是哈希表:键是事件类型,值是计数器。每来一个事件,找到对应的键,计数器加一。时间复杂度 O(1),逻辑清晰,没什么问题。

但在 Cloudflare 的实际场景里,这个方案有两个隐患:

第一,内存浪费。 Pingora 需要监控数百万个不同的源服务器,但任何时刻出现异常的只有极少数。哈希表必须为所有键维护计数器,绝大多数空间用来记录那些根本不需要关注的正常流量。

第二,锁竞争。 多线程并发更新哈希表,尤其是插入新键时,需要加锁。在高并发场景下,锁竞争会把所有并行处理强行串行化,成为性能瓶颈。

在数万台机器上部署这样一个算法,哪怕每个请求只多花几十纳秒,累积起来的代价也相当可观。Cloudflare 决定换一条路。


Count-Min Sketch:概率换资源

Pingora 采用的是一种叫做 Count-Min Sketch(CM Sketch) 的概率数据结构,它在空间和速度上都远优于哈希表,代价是计数结果是估算值,可能略有高估,但绝不会低估。

结构长什么样

CM Sketch 用一个矩阵来存储计数,矩阵有 H 行、N 列。每一行对应一个独立的哈希函数。

以 H=3、N=4 为例,初始状态:

| 0 | 0 | 0 | 0 |
| 0 | 0 | 0 | 0 |
| 0 | 0 | 0 | 0 |

计数过程

当事件 "red" 到来时,三行各自用自己的哈希函数计算出一个列索引,然后对应格子的计数器加一。不同行选中的列可能不同,这是设计意图,不是缺陷。

一次 "red" 之后,矩阵可能变成:

| 0 | 1 | 0 | 0 |   ← 第1行,red 映射到第2列
| 0 | 0 | 1 | 0 |   ← 第2行,red 映射到第3列
| 1 | 0 | 0 | 0 |   ← 第3行,red 映射到第1列

接着 "blue" 到来,假设它在第2行和 "red" 发生了碰撞,都映射到第3列:

| 1 | 1 | 0 | 0 |
| 0 | 0 | 2 | 0 |   ← 第3列被 red 和 blue 共用,产生碰撞
| 1 | 0 | 0 | 1 |

再经过 "blue, red, red, red, blue, red" 这一轮,目前为止共有 5 次 "red"、3 次 "blue",矩阵最终变为:

| 3 | 5 | 0 | 0 |
| 0 | 0 | 8 | 0 |   ← red 和 blue 在此行始终碰撞,计数为 5+3=8
| 5 | 0 | 0 | 3 |

查询:取最小值

查询某个事件的计数时,算法取它在各行对应格子中的最小值

  • "red" 对应的三个格子值为 5、8、5,取最小值 = 5,正确。
  • "blue" 对应的三个格子值为 3、8、3,取最小值 = 3,正确。

碰撞导致某些格子被多个事件共享,数值偏高,但只要有一行没有发生碰撞,最小值就能反映真实计数。两个事件在所有行都碰撞的概率是 1/N^H,以本例的参数为 1/64,参数选得越大,碰撞概率越低。

关键性质:只会高估,不会低估——因为算法从未丢弃任何计数。


Rust 实现:无锁,几行代码

CM Sketch 的操作本质上只有:哈希、数组寻址、原子加法。这意味着它可以完全无锁实现。

Pingora 的核心实现大致如下:

pub struct Estimator {
    estimator: Box<[(Box<[AtomicIsize]>, RandomState)]>,
}

impl Estimator {
    pub fn incr<T: Hash>(&self, key: T, value: isize) -> isize {
        let mut min = isize::MAX;
        for (slot, hasher) in self.estimator.iter() {
            let hash = hash(&key, hasher) as usize;
            let counter = &slot[hash % slot.len()];
            let current = counter.fetch_add(value, Ordering::Relaxed);
            min = std::cmp::min(min, current + value);
        }
        min
    }
}

每一行用 AtomicIsize 存储计数器,用 fetch_add 做原子加法,整个过程不需要任何互斥锁。


性能对比:数字说话

Cloudflare 用 100 万个不同的键、1 亿个事件做了基准测试,对比三种方案:

  • naiveMutex<HashMap<u32, usize>>,每次操作都要抢锁
  • optimizedDashMap<u32, AtomicUsize>,分片哈希表,减少锁粒度
  • pingora-limits:CM Sketch,无锁

速度(每次 incr 操作耗时,越低越好)

pingora-limitsnaiveoptimized
单线程10ns51ns43ns
八线程212ns1505ns212ns

单线程下没有锁竞争,CM Sketch 仍然比 naive 快 5 倍,比 optimized 快 4 倍——快的原因是操作更简单,路径更短。多线程高竞争下,CM Sketch 和 optimized 速度相当,都比 naive 快 7 倍,因为两者的热路径都是原子操作,瓶颈相同。

内存(越低越好)

峰值内存总分配次数总分配字节数
pingora-limits26,184 字节9 次26,184 字节
naive53,477,392 字节20 次71,303,260 字节
optimized36,211,208 字节491 次71,307,722 字节

内存差距极为悬殊:pingora-limits 峰值内存仅为 naive 的 1/2000,optimized 的 1/1300。哈希表必须为每一个键维护一条记录,而 CM Sketch 的空间由参数 H×N 完全决定,和键的数量无关。


生产中怎么用

pingora-limits 在生产环境中最典型的使用场景是连接数限制

当 Cloudflare 的服务器向某个源服务器发起的连接数过多时,为了防止源服务器和自身基础设施过载,系统会开始拒绝新请求并返回 503 错误。

具体做法:每个进入的请求,都会根据客户 ID + 服务器 IP + 主机名组合成一个计数键,调用 incr 增加计数;请求结束后再减回来(incr 支持负值)。若当前计数超过阈值,请求被拒绝。

在参数选择上,Cloudflare 将两个不相关客户之间的理论碰撞概率控制在约 1/2^52 的量级。加上拒绝阈值本身远高于正常流量水位,即便碰撞导致计数轻微高估,也不足以触发误判。


两阶段过滤:精度与效率兼顾

对于那些要求精确计数、完全不能接受误判的场景,CM Sketch 也有用武之地——用作第一阶段过滤器

低频事件被 CM Sketch 拦截,不进入后续处理;只有超过阈值的事件才进入精确的哈希表计数。这样,哈希表只需要处理真正需要关注的少数热点事件,内存占用极小,同时精度不受任何损失。


小结

CM Sketch 的思路本质上是一种工程上的权衡:用少量可控的不确定性(可能高估,不会低估),换取空间和速度上的极大优势。

这种权衡在 Cloudflare 的场景下是划算的:被保护的目标(源服务器)数量极多,但任意时刻需要重点关注的只是少数;哈希表为所有目标维护计数的做法,既浪费内存,又在高并发下引入锁竞争。CM Sketch 绕开了这两个问题,同时在生产参数下将误判概率压到了可以忽略不计的程度。

pingora-limits 目前已在 GitHub 开源,感兴趣的读者可以直接查阅代码和基准测试:

https://github.com/cloudflare/pingora/tree/main/pingora-limits


标签: none

添加新评论