RoaringBitmap主要为了解决UV指标计算的问题。旨在建立一种可以多维分析的精准UV数据模型,并且可以低成本地实现交并差等集合运算UV指标。
PV和UV指标一直是各类业务中广泛存在并且重点关注的实时指标。其中对于PV指标而言,由于其具备可加性,因此当对维度组合或者时间维度进行上卷时,可以直接求和得出我们所要的累计结果。但是去重指标UV则具备不可加性,他是一种对UID去重计数的指标,如果在维度上卷时直接求和会导致结果偏大。即UV指标一旦定制化生成,就很难具备再计算的能力,需要用户事先计算好。例如。
根据历史经验,去重指标有许多解决方案可供设计。主要分为两类,一类是前置计算方案,通过Count Distinct将UV预聚合后存储起来,查询时直接路由到对应UV指标。一类是后置计算方案,将user_id转变成可去重的结构,在查询时通过集合运算,算出UV指标。
RoaringBitmap是效压缩位图,采取的是2^ 32位(4294967296)的Bitmap,可以存储[0,2^32 -1]区间范围的用户编号。但是RoaringBitmap会对这个2^32bit的Bitmap做很多压缩操作,将Bitmap尽可能地压缩在很小的存储量级。其核心思想是:
在存储效率方面,数据量离散且小于4096时使用array最优,在数据量大且无规律时bitmap最优,在数据连续性比较好的情况下,RunContainer的存储效率最优。
时间复杂度方面Bitmap时间复杂度为O(1)高于Array和Run存储的nlog(n)。
hologres本身是兼容postgres开源生态,pg版本的roaringbitmap插件通过简单适配,很容易集成在hologres中。roaringbitmap插件:https://github.com/zeromax007/gpdb-roaringbitmap。
holo中roaringbitmap函数使用文档 :https://help.aliyun.com/zh/hologres/user-guide/roaring-bitmap-functions?spm=a2c4g.11186623.0.0.df1b5791jxHiiU
数据的分布键按桶号和bitmap高16位打散到hologres各个计算节点。在进行交并差集计算过程中,由于各个节点之间数据完全独立,每个节点可以单独进行计算,并将计算结果直接汇总到master节点计算进行聚合。整个计算过程是一个二阶段计算过程,完全没有数据shuffle, 整体计算非常高效。
RoaringBitmap使用过程中,主要遇到的问题,就是如何将user_id存入到RoaringBitmap数据结构中。因为RoaringBitmap不能像HyperLogLog那样,可以直接将user_id存入Bitmap数据结构中。为了能将亿级的编号存入Bitmap中,探索过如下两种方案。
主要解决:数据量过多
分桶方案采取分治的思想,即将一个大用户集切成多个桶,每个桶的量级足以存到42亿的Bitmap中,并且桶中的UID互斥,没有重合的UID。
而分桶的方案包括很多,例如可以采取前几位分桶,或者后几位分桶,只要能够保证剩下的几位都可以存在Bitmap里面。在实时场景中,由于要考虑Explorer的机器数,因此既需要保障每个桶内的UV量级均匀的同时,也需要保障每台机器在存储桶的个数也是均匀的。如下图所示,但是由于ODPS没有存储机器数的概念,因此其分桶的个数可以更加灵活,只需要保障每个分桶下的UID数是相同的即可。
此外,为了防止数据条数膨胀太多,我们默认会采取分10个桶。如果UID量级比较大,用户也可以选择性地增加分桶个数。为了将UID均匀地划分到10个桶上面,我们采取取轮询分桶的方式,即对于第1个编号放在第1个桶里,第2个编号放在第2个桶里,不断轮询分配,直到第11个编号,又重新从第1个桶开始划分。因此最终的分桶方法为。
例如将2088022931508105按10个桶划分,则其分桶号和用户编号为
分桶号 = 93150810 % 10 = 0
用户编号 = concat(reverse(022),93150810 / 10 ) = 2209315081
分10个桶,等同于将UID的倒数第二位作为分桶号,剩下的有效位重排后作为编号。
分桶方案的优势:
分桶方案的局限性
建序方案也是当时实时场景中最早探索的方案,并且在一些开源的RoaringBitmap技术分享中,也是广泛使用此类方案的。例如《Flink+Hologres精准去重》 、《Hologres使用Flink+RoaringBitmap实现实时UV计算》 等。
其核心思想在于,在存储用户ID编号时,会先从一张【用户映射表】中获取用户新的编号,然后再将新的编号存储到RoaringBitmap中。【用户映射表】需要覆盖所有用户,如果用户不存在则将新的用户append至表中,并新增一个唯一编号
对于映射表的设计,首先为了保证每个新增用户的新编号是唯一的,可以采取自增主键的方式,并存储在新字段user_no中,此外,为了兼容分桶方案的编号,建序表保留了两个字段:bucket_no和bucket_user_no,分别作为分,分桶号和分桶后的用户编号。
建序方案的优势:
建序方案的局限性
创建名称为uid_mapping的用户映射表,用于映射uid到32位INT类型,其DDL如下所示。
RoaringBitmap类型要求用户ID必须是32位int类型且越稠密越好(用户ID最好连续),而常见的业务系统或者埋点中的用户ID很多是字符串类型,因此使用uid_mapping类型构建一张映射表。映射表利用Hologres的Serial类型(自增的32位int)来实现用户映射的自动管理和稳定映射。
说明: 该表在本例每天批量写入场景,可为行存表也可为列存表,没有太大区别。如需要做实时数据(例如和Flink联用),需要是行存表,以提高Flink维表实时JOIN的QPS。
BEGIN;
CREATE TABLE public.zc_uid_mapping (
uid text NOT NULL,
uid_int32 serial,
PRIMARY KEY (uid)
);
--将uid设为clustering_key和distribution_key便于快速查找其对应的int32值
CALL set_table_property('public.zc_uid_mapping', 'clustering_key', 'uid');
CALL set_table_property('public.zc_uid_mapping', 'distribution_key', 'uid');
CALL set_table_property('public.zc_uid_mapping', 'orientation', 'row');
COMMIT;
创建名称为dws_app的聚合结果表,用于存放RoaringBitmap聚合后的结果,其DDL如下所示。
基础维度为之后进行查询计算pv和uv的最细维度,这里以country、 prov、 city为例构建基础维表。
begin;
create table dws_dau_app(
client_type text,
ipv text,
ds text NOT NULL, --日期字段
uid32_bitmap roaringbitmap, -- UV计算
pv integer, -- PV计算
primary key(client_type,ipv,ds)--查询维度和时间作为主键,防止重复插入数据
);
CALL set_table_property('public.dws_dau_app', 'orientation', 'column');
--clustering_key和event_time_column设为日期字段,便于过滤
CALL set_table_property('public.dws_dau_app', 'clustering_key', 'ds');
CALL set_table_property('public.dws_dau_app', 'event_time_column', 'ds');
--distribution_key设为group by字段
CALL set_table_property('public.dws_app', 'distribution_key', 'client_type,ipv,ds');
end;
更新用户映射表每天从上一天的uid中找出新客户(用户映射表uid_mapping中没有的uid)插入到用户映射表中,命令如下。
WITH
-- 其中ymd = '20210329'表示上一天的数据
user_ids AS ( SELECT imeisi FROM xxx WHERE ds = '20231119' AND imeisi is not null GROUP BY imeisi )
,new_ids AS ( SELECT user_ids.imeisi FROM user_ids LEFT JOIN zc_uid_mapping ON (user_ids.imeisi = zc_uid_mapping.uid) WHERE zc_uid_mapping.uid IS NULL )
INSERT INTO zc_uid_mapping SELECT new_ids.imeisi
FROM new_ids
更新完用户映射表后,将数据做聚合运算后插入聚合结果表,主要步骤如下。
undefined 通过明细表Inner Join用户映射表,得到上一天的聚合条件和对应的uid_int32。
undefined 按照聚合条件做聚合运算后插入RoaringBitmap聚合结果表,作为上一天的聚合结果。
undefined 每天只需进行一次聚合,存放一份数据,数据条数等于UV的量。明细表每天几亿的增量,在聚合结果表每天只需存放百万级数据。
插入数据至聚合结果表命令如下。
WITH aggregation_src AS
(
SELECT client_type,is_ipv as ipv, uid_int32
FROM
(
SELECT imeisi,client_type,is_ipv
from xxx t1
WHERE t1.ds = '20231119' and t1.imeisi is not null
)t1
INNER JOIN zc_uid_mapping t2
ON t1.imeisi = t2.uid
)
INSERT INTO dws_dau_app
SELECT
client_type
,ipv
,'20231119' ds
,RB_BUILD_AGG(uid_int32)
,COUNT(1)
FROM aggregation_src
GROUP BY client_type
,ipv
;
查询时,从dws_app聚合结果表中按照查询维度做聚合计算,查询Bitmap基数,得出Group By条件下的用户数,命令如下。
-- 多天去重
SELECT client_type
,RB_CARDINALITY(RB_OR_AGG(uid32_bitmap)) AS uv
-- ,rb_or_cardinality_agg(uid32_bitmap)
,sum(pv) AS pv
FROM dws_dau_app
WHERE ds in ('20231119','20231120')
GROUP BY client_type;
-- 等价于
SELECT ds,client_type,count(distinct imeisi)
FROM xxx
WHERE ds in ('20231119','20231120')
GROUP by ds,client_type
SELECT client_type
,ds
,RB_OR_AGG(uid32_bitmap)
,RB_CARDINALITY(RB_OR_AGG(uid32_bitmap)) AS uv
,sum(pv) AS pv
FROM dws_dau_app
WHERE ds in ('20231119','20231120')
AND client_type = 'travel'
GROUP BY client_type,ds
;
-- 等价于
SELECT ds,client_type,count(distinct imeisi)
FROM xxx
WHERE ds in ('20231119','20231120')
GROUP by ds,client_type
-- 两天同端的来访去重
SELECT client_type
,RB_CARDINALITY(RB_AND_AGG(xx))
FROM
(
SELECT client_type
,ds
,RB_OR_AGG(uid32_bitmap) xx
,RB_CARDINALITY(RB_OR_AGG(uid32_bitmap)) AS uv
,sum(pv) AS pv
FROM dws_dau_app
WHERE ds in ('20231119','20231120')
GROUP BY client_type,ds
)t
GROUP BY client_type
;
-- 等价于
SELECT t1.client_type,count(distinct t1.imeisi)
FROM xxx t1
INNER JOIN xxx t2
ON t1.imeisi = t2.imeisi
AND t1.client_type = t2.client_type
WHERE t1.ds = '20231119'
AND t2.ds = '20231120'
GROUP by t1.client_type