Redis之“不靠谱“的hyperLogLog,误差实测,实战中碰到的BUG深度解析

文章讲述了作者在项目中使用HyperLogLog在Redis中收集UV时遇到的性能问题,随着用户数量增加,pfadd的效率降低且误差增大。通过深入分析,发现pfadd的返回结果受HLL大小和基数影响,pfcount误差率较低,而pfadd的实际添加成功率下降。作者揭示了pfadd的原理和其在大数据量下的行为,强调了pfadd不总是返回成功的事实以及其背后的算法机制。

摘要生成于 C知道 ,由 DeepSeek-R1 满血版支持, 前往体验 >

最近做一个需求用于收集一本测试书的uv,首先考虑使用集合去做但是用户多了集合会非常大、效率不太好,所以考虑使用更佳轻盈的hyperLogLog,代码大致是这样的

 $pfKey = "member_test_first_read";
 $book_id = "book_id";
 $pfadd=  $redis->pfAdd($pfKey,[$member_id.'_'.$book_id]);
 if($pfadd) {
  #uv收集成功+1,数据库+1,记录映射关系等相关业务逻辑
 }

乍看之下没有任何问题,
这段代码一开始收集2000个用户只需要半天
但是随着上线时间越久发现收集用户更慢、一开始以为只是单纯的用户习惯问题?
这段代码收集2000个用户从最开始的半天到后面需要5天,问题开始变得明显了,于是排查代码怀疑是HyperLogLog的问题,去redis的cli执行一下相关命令发现果然是hll的问题
在这里插入图片描述
发现pfcount到达10万左右几乎没法pfadd了,pfadd返回都是0,失败了??
这个问题困扰了我挺久, 我把这段代码的key值改加了之后问题就修复了
修复之后的代码:

 $book_id = "book_id";
 $pfKey = "member_test_first_read".$book_id;
 $pfadd=  $redis->pfAdd($pfKey,[$member_id]);
 if($pfadd) {
  #uv收集成功+1,数据库+1
 }

官方的描述:
首先是在Redis中添加了新的HyperLogLog数据结构。HyperLogLog允许您估计集合的基数,或者换言之,获得唯一项的近似计数。甚至在添加HLL之前,您就可以使用Redis来计数内容,但HLL存在的理由是允许您使用固定数量的内存和恒定的复杂性来进行计数。由于没有免费午餐,因此HLL的计数标准误差高达0.81%。如果你想了解更多信息,请查看这篇文章,antirez在文章中对HyperLogLog算法及其实现过程中的一些挑战进行了惊人的概述。除了Phillipe Flajolet的原始论文外,该网站还有其他一些解释HLL的资源,包括这种漂亮的可视化。
官方给的误差率是0.81%,实际使用中确并非如此

我写了测试代码去验证我的猜想

		$redis = RedisPool::instance();
        $member_id = 1;
        $t = microtime(true);
        $pfKey = "member_test_uv";
        $book_id = 7;
        $addSuccess = 0;
        for ($i=0;$i<10000;$i++) {
           $pfadd=  $redis->pfAdd($pfKey,[$member_id.'_'.$book_id]);
           dump("pfadd->",$pfadd);
           if($pfadd){
               $addSuccess++;
           }
           echo $i."/10000";
           echo "\r\n";
            $member_id++;
        }
        $t2 = microtime(true);
        dump($redis->pfCount($pfKey));
        dump("添加成功数量",$addSuccess);
        dump("耗费时间".($t2-$t));

第一次返回结果
在这里插入图片描述
pfcount=9915
所以pfcount的误差率是0.85%基本符合官方描述
但是pfadd返回1的数量只有8239,pfadd误差率竟然高达17.61%
可见官方的误差概率应该仅仅只针对pfcount

把i改到10万,$book_id改成8再试一下

		$redis = RedisPool::instance();
        $member_id = 1;
        $t = microtime(true);
        $pfKey = "member_test_uv";
        $book_id = 8;
        $addSuccess = 0;
        for ($i=0;$i<100000;$i++) {
           $pfadd=  $redis->pfAdd($pfKey,[$member_id.'_'.$book_id]);
           dump("pfadd->",$pfadd);
           if($pfadd){
               $addSuccess++;
           }
           echo $i."/10000";
           echo "\r\n";
            $member_id++;
        }
        $t2 = microtime(true);
        dump($redis->pfCount($pfKey));
        dump("添加成功数量",$addSuccess);
        dump("耗费时间".($t2-$t));

在这里插入图片描述
这次误差离谱
110858-9915,实际10万条add中 pfcount增加了100943条数据
本以为pfcount只会少不会多, 这次竟然是多了0.943%
但是整体的误差而言只多了 858%110858 = 0.77%
但是pfadd反回成功只有25125条数据, pfadd添加成功错误率相当于有74.875%

先总结一下问题:
pfadd这个东西返回结果会随着hyperLogLog的size变大之后误差增加
pfcount基本符合官方±0.81%的误差标准

再看一下内存占用

在这里插入图片描述
11万的uv只占用12kb,并且后续我增加到20万,30万这个size一直没变不得不说hyperloglog真的很优秀、很轻

查看官方文档pfadd返回的问题

pfadd:

将任意数量的元素添加到指定的HyperLogLog里面。(我觉得这句话不对,因为元素真的加进去了size不可能不变,按照hlls算法实际放进去的应该是一个哈希值,而且实际处理的时候pfadd也没返回true)

如果HyperLogLog估计的近似基数在命令执行之后出现了变化, 那么命令返回1, 否则返回0。 如果命令执行时给定的键不存在, 那么命令将先创建一个空的HyperLogLog类型的键, 然后再执行命令。

可见hll的pfadd并不会每次增加uv都返回成功,因为pfadd到一定的基数才会去改变整个hll的近似基数,从而保证整体的hll的误差概率在0.81%

比如我pfcount可能到达了10万,但是10万的0.81%就是810,这个时候我需要pfadd 811个才有可能返回true,从而改变整体的基数近似值

再试一下小基数的数据返回

在这里插入图片描述
在这里插入图片描述

在数据量小的情况下pfadd返回的结果数据偏差并不会非常离谱,
实测pfadd误差
1000条数据误差是 1.9%
1000-2000条数据误差是 5.8%
2000-3000条数据误差是 9.1%

再往上误差就不能接受了,不建议用pfadd返回值做业务判断了!!!

查看redis的pfadd相关源码会有一段注释
在这里插入图片描述

*A)如果是已设置为值>=我们的“计数”的VAL操作码

*无论VAL行程长度字段如何,都不需要更新。

*在这种情况下,由于没有执行任何更改,PFADD返回0。

再看一下hll是怎么去获取一个key的count值的在这里插入图片描述

翻译一下最关键的注释

/*从位HLL_REGISTERS开始计数零的数量
*(这是与我们不使用的第一位相对应的二次幂
*作为索引)。最大游程可以是64-P+1=Q+1比特。
*
*请注意,结束零序列的最后一个“1”必须是
*包含在计数中,所以如果我们找到“001”,则计数为3,并且
*可能的最小计数是完全没有零,只有1位
*在第一位置处,即计数1。
*
*这听起来可能效率低下,但实际上是在一般情况下
*在几次迭代之后找到1的概率很高*/

不难看出其实就是2进制与运算,每次bit会左移动一位,循环二进制数据与运算为0则count++
这里我感觉其实就是hll算法的稀疏、密集存储结构0值命中个数

我们要知道的是pfadd要改变pfcount必须改变这个二进制的结果才有可能改变pfcount!!!

HyperLogLog 表示的总计数值是由 16384 个桶的计数值进行调和平均后再基于因子修正公式计算得出来的。它需要遍历所有的桶进行计算才可以得到这个值,中间还涉及到很多浮点运算。这个计算量相对来说还是比较大的。
当 HyperLogLog 中任意一个桶的计数值发生变化时,就会将计数缓存设为过期,但是不会立即触发计算。而是要等到用户显示调用 pfcount 指令时才会触发重新计算刷新缓存。缓存刷新在密集存储时需要遍历 16384 个桶的计数值进行调和平均,但是稀疏存储时没有这么大的计算量。也就是说只有当计数值比较大时才可能产生较大的计算量。另一方面如果计数值比较大,那么大部分 pfadd 操作根本不会导致桶中的计数值发生变化
在这里插入图片描述找到了计算count的地方,c语言涉及到算法的实在艰涩…先看到这,博主恶补c语言跟算法知识去了!!后续继续更新…

评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

兰博lamb

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值