作为OLAP数据库,StarRocks 诞生之初的核心使用场景就是统计报表,避免不了有统计去重的需求。
以如来的业务需求举例,在统计命中某个标签的人数时,显然是需要基于用户ID去重的。
海量数据的去重,是不能使用传统的 count distinct 方式的,例如下图2个BE节点上的数据,就需要进行多次运算。
StarRocks 提供两种高效的去重方法:
- BitMap:对应 BITMAP_UNION
- HyperLogLog:对应 HLL_UNION
还记得聚合表中:BITMAP_UNION、HLL_UNION 两个函数吗,对应的就是这里的去重方式。
1. Bitmap 去重
1.1. 原理
Bitmap 去重能够 准确计算 一个数据集中不重复元素的数量,相比传统的 Count Distinct,可以节省存储空间、加速计算。
例如,给定一个数组 A,其取值范围为 [0, n),可采用 (n+7)/8 的字节长度的 bitmap 对该数组去重。
即将所有 bit 初始化为 0,然后以数组 A 中元素的取值作为 bit 的下标,并将 bit 置为 1,那么 bitmap 中 1 的个数即为数组 A 中不同元素 (Count Distinct) 的数量。
优势主要体现在以下两点 :
(1)节省存储空间
通过用 Bitmap 的一个 Bit 位表示对应下标是否存在,能节省大量存储空间。例如,对 INT32 类型的数据去重,如使用普通的 bitmap,其所需的存储空间只占 COUNT(DISTINCT expr) 的 1/32。
StarRocks 采用一种设计的十分精巧的 bitmap,叫做 Roaring Bitmap,相较 Bitmap 会进一步减少内存占用。
(2)加速计算
Bitmap 去重使用的是位运算,所以计算速度相较 COUNT(DISTINCT expr) 更快,而且 bitmap 去重在 StarRocks MPP 执行引擎中还可以并行加速处理,提高计算速度。
1.2. 举例
1、建表
创建一张聚合表 page_uv。其中 visit_users 列表示访问用户的 ID,为聚合列,列类型为 BITMAP,使用聚合函数 BITMAP_UNION 来聚合数据。
CREATE TABLE `page_uv` (
`page_id` INT NOT NULL COMMENT '页面id',
`visit_date` datetime NOT NULL COMMENT '访问时间',
`visit_users` BITMAP BITMAP_UNION NOT NULL COMMENT '访问用户id'
) ENGINE=OLAP
AGGREGATE KEY(`page_id`, `visit_date`)
DISTRIBUTED BY HASH(`page_id`)
PROPERTIES (
"replication_num" = "3",
"storage_format" = "DEFAULT"
);
2、向表中导入数据。
采用 INSERT INTO 语句导入:
INSERT INTO page_uv VALUES
(1, '2020-06-23 01:30:30', to_bitmap(13)),
(1, '2020-06-23 01:30:30', to_bitmap(23)),
(1, '2020-06-23 01:30:30', to_bitmap(33)),
(1, '2020-06-23 02:30:30', to_bitmap(13)),
(2, '2020-06-23 01:30:30', to_bitmap(23));
数据导入后:
- 在 page_id = 1, visit_date = '2020-06-23 01:30:30' 数据行,visit_users 字段包含 3 个 bitmap 元素(13,23,33);
- 在 page_id = 1, visit_date = '2020-06-23 02:30:30' 的数据行,visit_users 字段包含 1 个 bitmap 元素(13);
- 在 page_id = 2, visit_date = '2020-06-23 01:30:30' 的数据行,visit_users 字段包含 1 个 bitmap 元素(23)。
统计去重的人数可以用 bitmap_union_count,也可以还用 count distinct,StarRocks 会将其自动转换为 bitmap_union_count 函数:
select bitmap_union_count(visit_users) from page_uv group by page_id
1.3. 说明
Bitmap index 和 Bitmap 去重二者虽然都使用 Bitmap 技术,但引入原因和解决的问题完全不同。前者用于低基数的枚举型列的等值条件过滤,后者则用于计算一组数据行的指标列的不重复元素的个数。
StarRocks 的 bitmap 去重是基于 Roaring Bitmap 实现的,roaring bitmap 只能对 TINYINT,SMALLINT,INT 和 BIGINT 类型的数据去重。如想要使用 Roaring Bitmap 对其他类型的数据去重,则需要构建全局字典,将其他类型数据(如字符串类型)通过全局字典映射成为整数类型。
Bitmap 是一种数据类型,虽然 BITMAP_UNION 函数是聚合表中的函数,但不是只有聚合表才能创建 Bitmap 类型指标列。甚至表中某列的数据类型是int,但是在查询时可以通过 to_bitmap 函数,将其转换为 Bitmap 类型再进行位运算。直接存储 Bitmap 类型列,是为了减少存储空间、查询更快。
从 StarRocks 2.3 版本开始,所有类型的表均支持设置指标列为 BITMAP 类型,但是所有类型的表不支持设置排序键为 BITMAP 类型。
因为 Bitmap 是精确去重,存的是实际值,因此可以通过 bitmap_to_string 函数,将 Bitmap 类型数据转换为逗号分隔(当有多个元素时)的字符串,查看原始int值。
2. HyperLogLog
2.1. 概念
HLL(HyperLogLog)是一种 近似去重 算法,当数据量达到一定程度,Bitmap 精确去重也会比较慢,在对去重精度要求不高的场景下,可以选择使用 HLL 减轻计算压力。HLL 算法的误差可控制在 1% 至 10% 左右,数据量越大,误差越小。
使用 HLL 去重,需要在建表语句中,将目标指标列的类型设置为 HLL,聚合函数设置为 HLL_UNION。
这里要注意,只有 聚合表 支持 HLL 类型列。
2.2. 举例
1、建表
创建聚合表,存储 union_id、member_id HLL类型:
CREATE TABLE xxx_hll (
ts DATETIME NOT NULL COMMENT "时间",
scene VARCHAR(128) COMMENT "场景编码",
ab VARCHAR(40) COMMENT "ab分流内容",
union_id_hll HLL HLL_UNION COMMENT "unionId近似去重",
member_id_hll HLL HLL_UNION COMMENT "memberId近似去重"
)
AGGREGATE KEY(ts, scene,ab)
PARTITION BY RANGE(ts)(
PARTITION p20240114 VALUES LESS THAN ("2024-01-15"))
DISTRIBUTED BY HASH(ts,scene,ab) BUCKETS 8
PROPERTIES( ... );
2、StarRocks 通过 Stream Load 导入 columns 为:
ts,scene,ab,union_id,member_id,union_id_hll=hll_hash(union_id),member_id_hll=hll_hash(member_id)
3、查询去重的 union_id、member_id 数量SQL为:
SELECT hll_union_agg(union_id_hll),hll_union_agg(member_id_hll) from buddha_user_hit_scene_ab_hll
和 Bitmap 类似,此时使用 count distinct 效果一样,StarRocks 会将其自动转换为 hll_union_agg
2.3. HLL 原理
2.3.1. 伯努利试验
HLL算法的数学基础是伯努利试验,这里用最直白的方式带大家理解这个算法过程。
伯努利试验 是在同样的条件下重复地、各次之间相互独立地进行的一种试验,试验就两种结果。
最常见的伯努利试验就是抛硬币,只有正、反两面结果,而且每次抛硬币的结果可能性都是二分之一。
现在试验规则设定为:每轮试验抛硬币,如果出现反面就继续抛,如果出现正面就结束本轮试验,我们记录本轮试验一共抛了几次。
可以计算概率:
- 试验抛了1次的概率是:1/2,第1次抛就是正面,概率是1/2。
- 试验抛了2次的概率是:1/4,即第1次抛是反面,第2次是正面,概率是 1/2 * 1/2。
- 试验抛了3次的概率是:1/8,即第1次抛是反面,第2次是反面,第3次是正面,概率是 1/2 1/2 1/2。
- ... ...
- 试验抛了n次的概率是:1/2^n。
意味着要进行 2^n 轮试验,才可能出现一轮抛了 n 次才出现正面的试验。
如果你告诉我,你进行了无数轮试验,这些试验中单轮抛了最多的次数,是100次。我大概可以推测出你进行了 2^100 轮试验。
极大似然估计中,试验的基数越大,结果误差越小。这里的基数就是进行多少轮试验,HLL 是一种基数估算算法。只需要记录目前所有试验中单论抛了最多的次数,就可以估算试验基数是多少。即实际数据库中可以只存储数值 n,可以预估统计数量 2^n 。
2.3.2. HLL算法的实现
前面讲解如何通过伯努利试验的思想预估庞大的数量,那么 HyperLogLog 算法具体是如何实现这个思想的呢?
还记得在写入hll类型数据时,都经过 hll_hash 转换一下:union_id_hll=hll_hash(union_id)
这也是一种 hash 算法,每写入一条数据,就将 union_id 通过 hash 转换成二进制,假设二进制如下:
hash(record) = 011... 00101101110
二进制中只有0和1,我们可以定义1是抛硬币的正面,0是反面。只要 hash 算法足够平均,就能保证二进制中每一位出现0、1的概率都是1/2。
上述二进制值,从低位开始,记录第一个出现1的位数(抛了正面),这里就是第2位。数据库中hll字段就存储值2就可以了。
因为是聚合表,当每次有排序键相同的数据写入表中时,记录第一个出现1的位数,如果比当前 hll 类型字段的值大就替换,否则就丢弃。
但是以前学概率时老师常说,小概率事件却经常发生!如果说运气好,第一轮试验就抛了十几次,那后续几百轮的误差都会很大,HyperLogLog 算法有什么优化方式吗? 求调和平均数
2.3.3. 求调和平均数
调和平均数公式为:
调和平均数 和 平均数 有啥区别吗?举个例子就清楚了:
比如我的工资1千块,我同事的工资10万元一个月,求我们公司平均工资。
- 平均数 = (1000+100000)/2 = 50500 (元)
- 调和平均数 = 2/(1/1000 + 1/100000) ≈ 1980 (元)
明显调和平均数更合理。因为调和平均数的结果会倾向于集合中比较小的数。
在 HyperLogLog 算法中,hll 数据结构会分成多个桶,二进制数据会将第一个出现1的位数最大值存到随机一个桶内,然后基于各个桶内统计的值求调和平均数。
Redis 中同样有 HyperLogLog 结构,它会将存储的数据 hash 成64位二进制,取其中14位决定数据落入哪个桶内(2^14,可以有16384个桶),剩下50位存储数据,即伯努利试验最多有50轮,单个 hll 数据结构可以预估的最大值是 2^50,有1千多万亿。
50的二进制是110010,最多需要6个bit存放。16384个桶,每个桶存6个bit,则一共不到12kb,就能预估1千多万亿数据。
**粗体** _斜体_ [链接](http://example.com) `代码` - 列表 > 引用
。你还可以使用@
来通知其他用户。