简述亿级数据下的统计与计算
庞大数据的计算过程,尽管可以使用分布式计算,但是某些场景下,期望可以高速统计,或者高速过滤。数据量太大会占据太多内存,数据逐个检查也十分浪费性能,所以需要采取某些措施,在逻辑上将数据映射,通过统计与概率的理论,实现精确估算。本文介绍三种方法:Bitmap、BloomFilter、HyperLogLog。
Bitmap
位图,应该叫bit list,用bit位标记某些信息。如果信息严格有序,那么,可以用0 1表示数据是否存在。一亿个int类型的数据存起来,相比存1亿个bit位成本大得多。而且,如果只是为了记录0 1状态,还可以通过位运算实现高效计算。这就是bitmap产生的根源。 一个 1 亿位的 bitmap,其大小大概是 12.5 MB,相较于直接将 1 亿个int存成对应的 map,成本相较还是低廉很多的。而且,bitmap之间可以通过位运算,计算统计数量情况。
如: bitmapA表示了A集合的数据,bitmapB表示B集合的数据,执行and操作,可以得到两个集合交集,执行or操作得到并集,可以快速进行集合计算与统计。而且Clickhouse已经原生支持bitmap的存储与计算。
CREATE TABLE tb (
`id` Int64,
`bmp_encode` String COMMENT 'bitmap编码数据',
`bmp` AggregateFunction(groupBitmap, UInt32) MATERIALIZED base64Decode(bmp_encode) COMMENT '实际bitmap数据'
) ENGINE = AggregatingMergeTree() ORDER BY (id) ;
注意这里 AggregateFunction的uint32与uint64是有区别的,bitmap的结构都不同了。
insert into tb values (1,'AAUDAAAABAAAAGQAAAC7MwAAkW1dAw==');
手动聚合
OPTIMIZE TABLE tb;
select bitmapToArray(bmp) from tb;
--> [3,4,100]
但是,构造普通bitmap的时候,最大值 最小值会影响整个序列的大小,如果数据序列中,有的值很小有的值很大,属于稀疏结构,那么这样子构造出来的bitmap就十分耗费内存,因为太过稀疏,导致很多bit位都是无效的0填充。所以稀疏数据使用bitmap不友好。为了解决这个问题,RoaringBitmap诞生。
RoaringBitmap
为了解决位图稀疏内存的问题,引入压缩机制,随后诞生了roaringBitmap(roaringbitmap.org/)。
-
对于一个uint32数据,共4个字节,32bit。取它的高16位,也就是2byte(2 byte可以表示2的16次方个数字),这个uint32的高16位取出来自然就会得到一个数字a(a的范围就是0到2的16次方)。
-
这个数字a作为key,存放在一个数组中。然后将uint32的低16位数据,放到另外一个数组中,存放的具体位置下标就是a的值,这样,在这个uint32重新进来的时候,取高16位到key的数组中查找,找得到值就作为下标到另外一个数组中找到低16位的数据,组合起来就知道数据在不在了。
-
高16位表示的数组就是直接存进去,低16位数据的存储格式不用,RBM定义了container来存低16位的数据,每一个高16位对应的值指向一个container,存放了低16位上的数据。
- arrayContainer,当桶内数据的基数不大于4096时,会采用它来存储,其本质上是一个unsigned short类型的有序数组。数组初始长度为4,随着数据的增多会自动扩容(但最大长度就是4096)。另外还维护有一个计数器,用来实时记录基数。
- bitmapContainer,数据基数大于4096的时候,会使用这个类型的container。本质上就是最开始的位图,固定长bit数组。
- runContainer,它使用可变长度的unsigned short数组存储用行程长度编码(RLE)压缩后的数据。举个例子,连续的整数序列11, 12, 13, 14, 15, 27, 28, 29会被RLE压缩为两个二元组11, 4, 27, 2,表示11后面紧跟着4个连续递增的值,27后面跟着2个连续递增的值。
由此可见,RunContainer的压缩效果可好可坏。考虑极端情况:如果所有数据都是连续的,那么最终只需要4字节;如果所有数据都不连续(比如全是奇数或全是偶数),那么不仅不会压缩,还会膨胀成原来的两倍大。所以,RBM引入RunContainer是作为其他两种container的折衷方案。
-
container的创建与转换:
RBM默认会用ArrayContainer来存储,插入的是一串数据的时候,会自动计算从而决定是要用arrayContainer还是runContainer;当ArrayContainer的容量超过4096后,会自动转成BitmapContainer存储。4096这个阈值很聪明,低于它时ArrayContainer比较省空间,高于它时BitmapContainer比较省空间。也就是说ArrayContainer存储稀疏数据,BitmapContainer存储稠密数据,可以最大限度地避免内存浪费。
RBM还可以通过调用特定的API(名为optimize)比较ArrayContainer/BitmapContainer与等价的RunContainer的内存占用情况,一旦RunContainer占用较小,就转换之。也就是说,上图例子中的第二个ArrayContainer可以转化为只有一个二元组0, 100的RunContainer,占用空间进一步下降到10200字节。 -
时空分析
空间占用(即序列化时写出的字节流长度)方面,BitmapContainer是恒定为8192B的。ArrayContainer的空间占用与基数(c)有关,为(2 + 2c)B;RunContainer的则与它存储的连续序列数(r)有关,为(2 + 4r)B。以上节图中的RBM为例,它一共存储了33868个unsigned int,只占用了10396个字节的空间,可以说是非常高效了。
增删改查的时间复杂度方面,BitmapContainer只涉及到位运算,显然为O(1)。而ArrayContainer和RunContainer都需要用二分查找在有序数组中定位元素,故为O(logN)。
BloomFilter
布隆过滤器是一种概率数据结构,用于检查元素在数据集中是否存在。它被认为是帮助组织数据集的各种数据结构中最紧凑的数据结构之一。
只需几兆字节,布隆过滤器就可以以超过 99%的准确率预测元素是否在数百万项数据集中。它还可以 100%确定某个元素不在数据集中。这些特性使得布隆过滤器成为处理大量请求以从大型数据集中读取数据的系统的绝佳选择,因为它可以过滤掉不必要的计算。
一个字节数组(一个矩阵) + 一组hash函数 + 一个插入矩阵的函数 + 一个检查矩阵的函数 可以构建一个bloom过滤器。
过程
- 加入一个数据的时候,通过hash会得到一组数字,如{18, 28, 26},这些数字表示这个数字在字节数组中的下标位置。
- 将字节数组对应的三个位置的值从0变为1
- 检查一个数据的时候,通过相同hash得到一组数字,如果这些数字下标对应字节数组的bit的值都为1,则表示数据存在,否则数据不存在
- 可以看到bloom filter的思想就是概率,对一个数据降维得到多个因子,这些因子落入同一个范围内,只要参数合适,就可以通过概率计算推断数据是否存在。
bloom filter 数学推导
HyperLogLog
hyperloglog是一种概率型算法,目的是做基数统计,可以预估一个集合中不同数据的个数,它不会保存元数据,只记录数量,支持输入非常大体积的数据量。HyperLogLog 通过使用哈希函数将集合中的元素映射到一个较大的空间,并根据映射结果来估计基数。其核心思想基于这样一个事实:哈希值的分布是均匀的。
步骤
-
输入一个数3,157,369,hash得到哈希值2,297,270,962,这个哈希值的二进制1110000010100101010100101110111010111010
-
取二进制后6位(不是一定的6位,具体数值自己定),可以得到2的6次方个状态,也就是一个4 * 16的矩阵A。后六位 可以得到一个数字,表示在矩阵A中的值。这里111010是50,算法中的含义表示在矩阵A中,第50个位置将被标识占用。
-
二进制后六位被使用后,从第七位开始向前数直到出现1(前导1,反映了数据的稀疏情况,如果值越大,则表示越稀疏),里面有几位a,这里是 10 ,所以是2位,a=2,将a填充到上一步得到的第50个位置
-
重复计算,当a的值比将要放入的矩阵A的位置的旧值大的时候,覆盖掉旧值存入新值。
-
在所有元素处理完毕后,使用桶中的值计算基数估计值。HyperLogLog 使用调和平均数(Harmonic Mean)来减少偏差。使用累加的方式计算
-
本质上就是利用hash是均匀的,然后推断各个桶里数据的差异程度,随后结合所有桶的数据,估算出集合里共有多少去重后的数据。
HyperLogLog与redis
在 redis 中也存在 hyperloglog 类型的结构,能够使用 12k 的内存,允许误差在 0.81%的情况下统计 2^64 个数据。
在 Redis 的 HyperLogLog 实现中用到的是 16384 个桶,也就是 2^14,每个桶的 maxbits 需要 6 个 bits 来存储,最大可以表示 maxbits=63,于是总共占用内存就是2^14 * 6 / 8 = 12k字节。\
HyperLogLog 提供了两个指令 pfadd 和 pfcount,根据字面意义很好理解,一个是增加计数,一个是获取计数。pfadd 用法和 set 集合的 sadd 是一样的,来一个用户 ID,就将用户 ID 塞进去就是。pfcount 和 scard 用法是一样的,直接获取计数值。
HyperLogLog与Clickhouse
在clickhouse中,可以使用 uniqState 初始化 HLL 数据,uniqMerge 进行估算,uniqMergeState 合并数据。
1. 创建包含 HLL 列的表
首先,创建一张包含 HLL 列的表:
CREATE TABLE user_visits (
date Date,
user_id UInt64,
hll_column AggregateFunction(uniq, UInt64)
) ENGINE = MergeTree()
ORDER BY date;
date:日期user_id:用户 IDhll_column:存储 HLL 数据的列,使用uniq聚合函数
2. 插入数据
插入数据时,使用 uniqState 函数初始化 HLL 列:
INSERT INTO user_visits (date, user_id, hll_column)
VALUES
('2023-10-01', 1, uniqState(1)),
('2023-10-01', 2, uniqState(2)),
('2023-10-01', 3, uniqState(3)),
('2023-10-02', 1, uniqState(1)),
('2023-10-02', 4, uniqState(4)),
('2023-10-02', 5, uniqState(5));
3. 查询估算的唯一用户数
使用 uniqMerge 函数合并 HLL 数据并估算唯一用户数:
SELECT
date,
uniqMerge(hll_column) AS estimated_unique_users
FROM user_visits
GROUP BY date;
输出示例:
date | estimated_unique_users
------------|------------------------
2023-10-01 | 3
2023-10-02 | 3
4. 合并多天的 HLL 数据
若要合并多天的 HLL 数据,可以使用 uniqMergeState 函数:
SELECT
uniqMerge(hll_column) AS total_estimated_unique_users
FROM user_visits;
输出示例:
total_estimated_unique_users
-----------------------------
5
5. 更新 HLL 数据
更新 HLL 数据时,使用 uniqState 函数:
INSERT INTO user_visits (date, user_id, hll_column)
VALUES
('2023-10-03', 6, uniqState(6)),
('2023-10-03', 7, uniqState(7));
6. 查询更新后的唯一用户数
再次查询以获取更新后的估算值:
SELECT
date,
uniqMerge(hll_column) AS estimated_unique_users
FROM user_visits
GROUP BY date;
输出示例:
date | estimated_unique_users
------------|------------------------
2023-10-01 | 3
2023-10-02 | 3
2023-10-03 | 2
参考
bitmap: juejin.cn/post/733044…
roaringBitmap: www.jianshu.com/p/818ac4e90…
hyperLogLog在线计算:
content.research.neustar.biz/blog/hll.ht…
juejin.cn/post/699289…
bloom Filter:
mp.weixin.qq.com/s/maxriWYKq…