Redis通关手册(二):概率数据结构

Redis的概率数据结构提供对计数、频率和排名等统计量的近似值,而不是精确值。使用近似的优点是它们对于许多常见用途来说是足够的,但计算起来却更加高效。它们有时还有其他优点,例如模糊处理时间、位置和其他敏感数据。

Bloom filter

布隆过滤器是一种概率性数据结构,用于检查一个元素是否在集合中。

布隆过滤器是 Redis 开源中的一个概率性数据结构,它允许你使用一个非常小的固定大小的内存空间来检查一个元素是否在集合中。布隆过滤器不是存储集合中的所有元素,而是只存储元素的哈希表示。这种设计以牺牲部分精度为代价,换来了极高的空间效率和查询速度。

布隆过滤器有一个重要特性:它可以保证“元素不在集合中”的判断是绝对正确的;但对“元素在集合中”的判断,只能给出一个估计值——也就是说,每 N 个“存在”的回答中,可能会有 1 个是错误的。

虽然这种不确定性听起来有点奇怪,但在计算机科学中非常实用。因为否定回答能阻止许多昂贵的后续操作。例如:

  • 检查用户名是否已被占用
  • 检查信用卡号是否被报告为被盗
  • 检查用户是否已经看过某条广告

应用场景

检查用户名是否已被占用(SaaS、内容发布平台)

用户名、邮箱、域名、短链接是否已被使用?
为每个已注册的用户名维护一个布隆过滤器。当新用户输入想要的用户名时,系统先查询布隆过滤器:

  • 如果没有,则创建用户并将用户名添加到布隆过滤器中。
  • 如果存在,应用应该选择检查主数据库或拒绝用户名。

广告投放(零售、广告)

  • 用户是否已经看过这个广告?
  • 用户是否已经购买过这个产品?

为每位用户维护一个布隆过滤器,存储其所有购买过的产品 ID。推荐系统在推荐新产品前,先检查该产品是否已在用户的过滤器中:

  • 如果不存在,则展示该广告,并将其加入过滤器;
  • 如果“可能存在”,则跳过该产品,继续寻找下一个未展示过的产品。

使用 Redis Bloom 过滤器为此类应用提供以下好处:

  • 操作极快,计算效率高;
  • 无需投资昂贵的硬件即可支撑大规模数据。

参数说明

假阳性率 (error_rate)

该参数是一个介于 0 和 1 之间的小数。
例如:若希望假阳性率为 0.1%(即每 1000 次查询中出现 1 次误判),error_rate 应该设置为 0.001。

预期容量 (capacity)

表示你预计在过滤器中存储的元素总数。

  • 如果数据集合是大小固定的,这个值较容易设定;
  • 如果数据集合动态增长,则需谨慎预估。

容量设置需平衡,设置过大,浪费内存;设置过小,过滤器填满后,需要在其上叠加新的子过滤器,当多个子过滤器堆叠时,插入操作的延迟不变,但查询操作的延迟会增加。

当过滤器由多个堆叠在一起的子过滤器组成时,检查首先会在顶部(最新)的过滤器上执行,如果返回一个否定答案,则检查下一个,依此类推,因此查询操作的延迟会增加。

扩展因子 (EXPANSION)

向布隆过滤器添加元素永远不会失败——即使容量满了,数据结构也不会拒绝写入。但当实际元素数量超出容量时,假阳性率会逐渐升高

为了维持接近设定值的错误率,布隆过滤器会自动扩容:当容量耗尽时,它会创建一个新的子过滤器。
新子过滤器的大小 = 上一个子过滤器大小 × EXPANSION,默认扩展值为 2

  • 如果你无法预估最终数据量,建议将 EXPANSION 设为 2 或更大,以减少子过滤器的层数;
  • 如果你对总数据量有把握,建议设为 1,以节省内存。

每新增一个子过滤器,系统会为其增加更多哈希函数,以维持整体错误率不变。

为什么要在可能扩容的情况下,一开始就创建一个小容量过滤器?
因为很多业务场景需要为每个用户或每个产品单独维护一个过滤器。其中大部分过滤器会保持较小规模,只有少数活跃的才需要扩容。因此,用较小的初始容量可以节省大量内存。

非扩容模式(NONSCALING)

如果你明确知道数据量不会超出初始容量,可以使用 NONSCALING 标志。此时过滤器会少用一个哈希函数,从而节省计算资源。

但需要记住:一旦实际元素数超过 capacity,错误率就会开始上升。

内存估算

布隆过滤器的内存占用由目标错误率决定。

  • 最优哈希函数数量:ceil(-ln(error_rate) / ln(2))
  • 每个元素所需的最优比特数:-ln(error_rate) / ln(2)^2
  • 总所需比特数:capacity × 每个元素所需比特数

常用配置示例:

  • 错误率 1% → 需要 7 个哈希函数,每个元素约 9.585 比特
  • 错误率 0.1% → 需要 10 个哈希函数,每个元素约 14.378 比特
  • 错误率 0.01% → 需要 14 个哈希函数,每个元素约 19.170 比特

作为对比,如果使用 Redis 的 Set 集合来做成员检查:

memory_with_sets = capacity × (192字节 + 元素本身大小)

例如存储 IP 地址集合时,每个元素约需 40 字节(即 320 比特),远高于布隆过滤器的 19.17 比特(0.01% 错误率)。

性能

  • 插入操作:时间复杂度为 O(k),其中 k 是哈希函数的个数;
  • 查询操作:时间复杂度为 O(k × n),其中 n 是子过滤器的层数(单层时为 O(k))。

Cuckoo filter

布谷鸟过滤器也是一种概率性数据结构,用于检查元素是否存在于集合中。与布隆过滤器类似,它同样能以极快速度和极高的空间效率完成成员检查。但它额外支持删除操作,在某些场景下优于布隆过滤器。

  • 布隆过滤器是一个位数组,由多个哈希函数决定置位位置;
  • 布谷鸟过滤器则是一个桶数组,每个桶中存储元素的指纹(一段短哈希值),每个元素会映射到两个候选桶。

查询时,系统会在两个候选桶中搜索该元素的指纹:

  • 若找到匹配的指纹 → 返回存在;
  • 若找不到 → 返回不存在。

和布隆过滤器一样,布谷鸟过滤器对于元素不在集合中的判断是绝对正确的,对于元素在集合中的判断存在一定的误报率,误报率由指纹长度直接决定。

关键参数

以下是 Cuckoo 过滤器的关键参数和特性:

  • p 目标误报率
  • f 指纹长度(单位:比特)
  • α 填充率或负载因子(0≤α≤1)
  • b 每个桶的条目数
  • m 桶的总数量
  • n 预计存储的元素总数
  • C 每个元素平均占用的比特数

布谷鸟过滤器的每个桶可存放多个条目(每个条目存储一个指纹)。
若所有桶都填满,则过滤器会被标记为“已满”,无法再插入新元素。因此,我们应始终保持一定比例的空闲空间。
单个元素的实际内存成本 = 指纹长度 / 负载因子
例如,指纹长度为 f 比特,负载因子为 α,则每个元素的摊销内存为 f / α 比特。
初始化过滤器时,你需要选择容量桶大小

选择容量 capacity

Cuckoo 过滤器的容量计算为:\(capacity = n*f/α\)

其中:

  • n:预期元素数量
  • f:指纹长度(建议设为 8 比特)
  • α:填充率

你需要先选定填充率,它会直接影响数据密度和内存占用。计算出的容量会向上取整到最接近的 2 的幂次方。

需要注意的是:在布谷鸟过滤器中重复插入相同元素会尝试多次添加,可能导致过滤器过早被填满。另外,由于算法本身的特性,过滤器可能在实际元素数尚未达到容量时就报告“已满”,因此填充率很可能无法达到 100%。

选择桶大小 BUCKETSIZE

每个桶中可存放的条目数。更大的桶大小可提高填充率,但会致更高的错误率和稍微慢一些的性能。

误报率计算公式:

error_rate = (桶数量 × 哈希函数个数) / 2^指纹长度 = (桶数量 × 2) / 256

典型配置参考:

桶大小 填充率 误报率
1 55% 0.78%
3 80% 2.34%
4 95% 3.12%
桶大小为 1 时误报率最低(0.78%),但填充率也最低;桶越大,空间利用率越高,但误报率也随之上升。

选择缩放因子 EXPANSION

当过滤器报告“已满”时,它会自动创建新的子过滤器以实现扩容。这会降低性能并增加整体误报率。

新子过滤器的大小 = 上一个子过滤器大小 × EXPANSION。如果您知道您需要在某个时刻进行扩展,那么选择一个更高的扩展值会更好。默认值为 cf-expansion-factor

扩展因子将被四舍五入到下一个\(2^n\)的数字。

为什么要在可能扩容的情况下使用较小的初始容量?
因为很多场景需要为每个用户或每个产品单独维护一个过滤器。大部分过滤器不会超出初始容量,只有少数活跃的才需要扩容。小初始容量能大幅节约内存。

选择最大迭代次数 MAXITERATIONS

MAXITERATIONS 决定了寻找新指纹槽位的尝试次数。一旦过滤器满了,较高的 MAXITERATIONS 值会减慢插入速度。默认值是 cf-max-iterations 。

性能

  • 插入操作:时间复杂度为 O(1)
  • 查询操作:时间复杂度为 O(1)
  • 删除操作:时间复杂度为 O(1)

布隆过滤器与布谷鸟过滤器

  • 布隆过滤器:在插入性能上更优,且扩容更平滑。适合需要频繁、大量新增数据的场景。
  • 布谷鸟过滤器:在查询操作上更快,并且原生支持删除。适合读多写少、数据量相对固定的场景。

Top-K

Top-K 是 Redis 开源模块中的一种概率性数据结构,用于估计数据流中出现频率最高的 K 个元素。

一个典型应用是网络异常检测和 DDoS 攻击识别,例如回答这类问题:是否有大量请求突然集中到某个 IP 地址或同一目标地址?

虽然 Top-K 的功能与 Count-Min Sketch 有部分重叠,但两者适用场景不同,不应混淆使用。Redis 的 Top-K 实现基于 Junzhi Gong 等人提出的 HeavyKeepers 算法。该算法摒弃了传统的“全量计数”或“准入部分计数”方法,转而采用带指数衰减的计数策略——对低频流量更敏感,对高频流量的影响则有限。

该实现结合了两种数据结构:

  • 一个哈希表,用于存储概率计数(类似 Count-Min Sketch);
  • 一个最小堆,用于存储计数最高的 K 个元素。

这种设计保证了更短的执行时间,同时内存占用远低于有序集合(Sorted Set)。另外,它还支持实时通知——当元素进入或离开 Top-K 列表时,系统可主动感知。

应用场景

热门话题标签(社交媒体平台,新闻分发网络)

解决以下问题:

  • 过去 X 小时内,哪些话题标签被提及最多?
  • 今天阅读量/观看量最高的 K 条新闻是什么?

数据流是持续涌入的社交媒体帖子,需要从中解析出不同的标签。
TOPK.LIST 命令的时间复杂度为 O(K·log K),因此只要 K 值不大,就无需维护全量标签的 Set 或 Sorted Set,直接查询 Top-K 即可。

示例

下面的例子将展示如何跟踪在线购物场景中使用的“自行车”相关的关键词;例如,“自行车店”和“自行车把手”。

  • 使用 TOPK.RESERVE 初始化一个 Top-K 结构。若不指定 widthdepth 和 decay_constant,默认值分别为 7、8 和 0.9。
> TOPK.RESERVE key k width depth decay_constant
  • 使用 TOPK.ADD 向 结构 中添加项目,可以批量添加。
    如果添加新元素时,返回值不为空,说明该元素被降级,已不再属于前 K 名;返回 "below" 表示原 Top-K 列表中的某个元素被挤出,返回 nil 表示无变化。
    这种机制允许你动态检测哪些元素进出 Top-K 列表。
    在下面的脚本中,pedals被添加后,handlebars被挤出 Top-5,因此返回了handlebars。
    再次添加store和seat时,因它们已在列表内,返回 nil

  • 使用 TOPK.LIST 列出迄今为止输入的项目。

  • 使用 TOPK.QUERY 查看一个项目是否在 Top-K 列表中。与 TOPK.ADD 一样,可以同时查询多个项目。

> TOPK.RESERVE bikes:keywords 5 2000 7 0.925
OK

> TOPK.ADD bikes:keywords store seat handlebars handles pedals tires store seat
1) (nil)
2) (nil)
3) (nil)
4) (nil)
5) (nil)
6) handlebars
7) (nil)
8) (nil)
   
> TOPK.LIST bikes:keywords
9) store
10) seat
11) pedals
12) tires
13) handles
    
> TOPK.QUERY bikes:keywords store handlebars
14) (integer) 1
15) (integer) 0

容量估算

Top-K 的容量选择相对简单,只需设定两个参数。

若已确定目标 K 值,可按如下公式推算 width 和 depth

width = k × log(k)
depth = log(k)  # 但最小值不低于 5

对于 decay_constant ,可以使用 0.9 这个值,该值在多数场景中被验证为最优,但也可根据实际数据调整

性能

  • 插入操作:时间复杂度为 O(K + depth) ≈ O(K)
  • 查询操作:时间复杂度为 O(K)

其中 K 为 Top-K 列表大小,depth 为哈希函数个数。

Count-min sketch

Count-Min Sketch(CMS)是 Redis 开源模块中的一种概率性数据结构,用于估计数据流中事件或元素的出现频率。

它以牺牲一些事件的计数重叠为代价,允许不同元素的计数发生重叠(碰撞)。持续消费事件流,并维护每个元素的频率估计值。

⚠️ 重要提示:Count-Min Sketch 返回的结果中,低于某一阈值(由错误率决定)的计数应被忽略,通常可近似视为 0。因此,CMS 本质上适用于高频元素的计数,而极低频元素的计数应被视为噪声。

应用场景

产品(零售、在线商店)

某一天产品的销售量是多少?

可以每天创建一个 Count-Min Sketch。每售出一件商品,就更新一次 CMS。CMS 能够为销量贡献较大的商品提供相对准确的计数;而销量占比极低的商品则会被当成噪声忽略。

使用示例

假设你选择错误率为 0.1%(0.001),置信度为 99.8%(0.998)。
这意味着:

  • 你的错误概率为 0.02%(0.002);
  • CMS 会努力将计数误差控制在所有元素总计数的 0.1% 以内;
  • 有 0.02% 的可能性,误差会超过这个范围(通常发生在低频元素与高频元素发生哈希碰撞时)。

当向 CMS 中添加少量元素并查询其频率时,请注意:在这样的小样本中,碰撞极少发生,与其他概率数据结构类似。

> CMS.INITBYPROB bikes:profit 0.001 0.002
OK

> CMS.INCRBY bikes:profit "Smokey Mountain Striker" 100
(integer) 100

> CMS.INCRBY bikes:profit "Rocky Mountain Racer" 200 "Cloudy City Cruiser" 150
1) (integer) 200
2) (integer) 150
   
> CMS.QUERY bikes:profit "Smokey Mountain Striker" "Rocky Mountain Racer" "Cloudy City Cruiser" "Terrible Bike Name"
3) (integer) 100
4) (integer) 200
5) (integer) 150
6) (integer) 0
   
> CMS.INFO bikes:profit
7) width
8) (integer) 2000
9) depth
10) (integer) 9
11) count
12) (integer) 450

例子1:均匀分布的数据

若有 1000 个元素,每个元素计数约为 500,则总计数为 500,000:

阈值 = error × total_count = 0.001 × 500,000 = 500

这说明对于计数分布非常均匀的流,CMS 的效果可能不佳。若将错误率降低至 0.01%:

阈值 = 0.0001 × 500,000 = 100

这个阈值看起来已经更可接受了,但这也意味着我们需要更大的宽度 w = 2/error = 20 000 ,进而需要更多的内存。

例子2:高斯分布/长尾分布

假设有 1000 个元素:

  • 其中 800 个元素累计计数 400K,平均计数 500;
  • 另外 200 个元素累计计数 1.6M,平均计数 8000。

填充完所有 1000 个元素后,阈值计算如下:

阈值 = error × total_count = 0.001 × 2,000,000 = 2000

该阈值(2000)恰好落在两个平均计数(500 和 8000)之间,因此能有效区分高频和低频元素——说明此错误率在该场景下非常合适。

容量估算

虽然 Count-Min Sketch 与布隆过滤器有许多相似之处,但它的容量配置要复杂得多。
初始化命令只接收两个参数,但若要真正可用,必须深入理解其含义。

CMS.INITBYPROB key error probability

错误率(error)

error 决定了 sketch 的宽度 w,而 probability 决定了哈希函数的数量(深度 d)。
错误率直接决定了你能信任的计数阈值:

阈值 = error × total_count
或
error = 阈值 / total_count

其中 total_count 可通过 CMS.INFO 命令的 count 字段获取,它表示 sketch 中所有元素计数的总和。这个值是动态变化的。

在创建 sketch 时,你可以预估 total_count ≈ 平均计数 × 元素数量。

由于阈值是总计数的函数,它会随总计数增长而上升。但只要你能掌握总计数,就可以随时动态计算阈值——任何低于该值的查询结果都可视为噪声并丢弃。

概率(probability)

probability 表示:一个计数低于阈值的元素,在所有深度中与计数高于阈值的元素发生哈希碰撞的概率。

一旦发生碰撞,返回的将是碰撞后较大的那个计数,而非该元素自身的真实计数。

性能

  • 添加/更新元素:时间复杂度为 O(1)
  • 查询元素:时间复杂度为 O(1)
posted @ 2026-07-14 13:55  暮色之狐  阅读(33)  评论(1)    收藏  举报