标签计算完成后保存在hive虽然可以查询但是性能非常糟糕。而标签的使用往往是即时的。最常见的场景就是“用户分群”,也称“人群圈选”、“圈人”等等。
分群操作就是根据多个标签组合,产生一个用户集合,供营销、广告等部门使用。而这些操作计算量大,产生结果需要时效性高。
选择方案最重要的依据就是数据量和时效性要求。
时效性 | 数据量 | 分群方案 |
---|---|---|
能接受隔天 | 无所谓 | HIVE宽表 |
即时产生 | 千万以下,标签百级 | OLAP宽表(Elasticsearch,Clickhouse,Tidb…) |
即时产生 | 亿级,标签千级 | Bitmap方式(Clickhouse,doris) |
适合的才是最好的,此任务选择用Clickhouse实现Bitmap方式存储。
把hive中标签宽表数据,写入至Clickhouse的宽表。
读取hive的宽表,在clickhouse中建立对应的宽表。
因为并不是hive表到hive表,所以并不能够直接用insert select 解决。
先通过把数据查询成为Dataframe ,再通过行动算子写入至Clickhouse的宽表。
搭建模块 – task-export-ck
在poml文件中添加配置
<dependencies>
<dependency>
<groupId>com.hzy.userprofilegroupId>
<artifactId>task-commonartifactId>
<version>1.0-SNAPSHOTversion>
dependency>
dependencies>
<build>
<plugins>
<plugin>
<groupId>net.alchim31.mavengroupId>
<artifactId>scala-maven-pluginartifactId>
<version>3.4.6version>
<executions>
<execution>
<goals>
<goal>compilegoal>
<goal>testCompilegoal>
goals>
execution>
executions>
plugin>
<plugin>
<groupId>org.apache.maven.pluginsgroupId>
<artifactId>maven-assembly-pluginartifactId>
<version>3.0.0version>
<configuration>
<descriptorRefs>
<descriptorRef>jar-with-dependenciesdescriptorRef>
descriptorRefs>
configuration>
<executions>
<execution>
<id>make-assemblyid>
<phase>packagephase>
<goals>
<goal>singlegoal>
goals>
execution>
executions>
plugin>
plugins>
build>
core-site.xml、hdfs-site.xml、hive-site.xml、hive-site.xml、log4j.properties与之前模块无异,主要用于本地调试。
config.properties
#mysql配置
mysql.url=jdbc:mysql://hadoop101:3306/user_profile_manage2022?characterEncoding=utf-8&useSSL=false
mysql.username=root
mysql.password=123456
clickhouse.url=jdbc:clickhouse://hadoop101:8123/user_profile2022
ClickHouse有两个对外开放的端口号:8123和9000
8123,适用于JDBC,短连接,HTTP协议
9000,适用于Client,长连接,TCP协议
添加执行clickhouse的Sql语句的工具类。
因为该工具也会被其他模块使用,所以放在task-common下的util层
package com.hzy.userprofile.util
import java.sql.{Connection, DriverManager, Statement}
import java.util.Properties
object ClickHouseUtil {
private val properties: Properties = MyPropertiesUtil.load("config.properties")
val CLICKHOUSE_URL = properties.getProperty("clickhouse.url")
def executeSql(sql: String ): Unit ={
Class.forName("ru.yandex.clickhouse.ClickHouseDriver");
val connection: Connection = DriverManager.getConnection(CLICKHOUSE_URL, null, null)
val statement: Statement = connection.createStatement()
statement.execute(sql)
connection.close()
}
}
clickhouse-client -m
create database user_profile1009;
完成测试后,可以在ClickHouse中user_profile1009中看到对应的数据。
添加流程任务,因为要在合并宽表任务之后执行,所以级别设为300,调度任务之后,再次测试执行,等待结果。
注意注释掉//.setMaster("local[*]")
,打包,上传。
启动远程任务提交器,内网穿透。
假设有个1,2,5的数字集合,如果常规的存储方法,要用3个Int32空间。其中一个Int32就是32位的空间。三个就是3*32Bit,相当于12个字节。
如果用Bitmap怎么存储呢,只用8Bit(1个字节)就够了。每一位代表一个数,位号就是数值,1标识有,0标识无。如下图:
7 | 6 | 5 | 4 | 3 | 2 | 1 | 0 |
---|---|---|---|---|---|---|---|
0 | 0 | 1 | 0 | 0 | 1 | 1 | 0 |
这样的一个字节可以存8个整数,每一个数的存储成本实质上是1Bit。
也就是说Bitmap的存储成本是Array[Int32]的1/32,是Array[Int64]的1/64。
好处一: 如果有一个超大的无序且不重复的整数集合,用Bitmap的存储成本是非常低的。
好处二: 因为每个值都只对应唯一的一个位置,不能存储两个值,所以Bitmap结构可以天然去重。
如果有一个需求,比如想判断数字“3”是否存在于该集合中。若是传统的数字集合存储,那就要逐个遍历每个元素进行判断,时间复杂度为O(N)。
但是若是Bitmap存储只要查看对应的下标数的值是0还是1即可,时间复杂度为O(1)。
查询3
7 | 6 | 5 | 4 | →3 | 2 | 1 | 0 |
---|---|---|---|---|---|---|---|
0 | 0 | 1 | 0 | 0 | 1 | 1 | 0 |
好处三:非常方便快速的查询某个元素是否在集合中。
如果有另一个集合2、3、7,想查询这两个集合的交集。
传统方式[1,2,5]与[2,3,7] 取交集就要两层循环遍历。
而Bitmap只要把00100110和10001100进行与操作就行了。而计算机做与、或、非、异或 等等操作是非常快的。
如下:
7 | 6 | 5 | 4 | 3 | 2 | 1 | 0 |
---|---|---|---|---|---|---|---|
1 | 0 | 0 | 0 | 1 | 1 | 0 | 0 |
&
7 | 6 | 5 | 4 | 3 | 2 | 1 | 0 |
---|---|---|---|---|---|---|---|
0 | 0 | 1 | 0 | 0 | 1 | 1 | 0 |
=
6 | 5 | 4 | 3 | 2 | 1 | 0 | |
---|---|---|---|---|---|---|---|
0 | 0 | 0 | 0 | 0 | 1 | 0 | 0 |
好处四:集合与集合之间的运行非常快。
综上,Bitmap非常适合的场景:
当然这种方式也有局限性:
只能存储正整数字而不是字符串
存储的值必须是无序不重复
不适合存储稀疏的集合,比如一个集合存了三个数[5,1230000,88880000] 这三个数,用Bitmap存储的话其实就不太划算。(但是clickhouse使用的RoaringBitmap,优化了这个稀疏问题。)
RoaringBitmap是一种混合的结构,将整个的数据空间分成一段一段的,如0-1000,1000-2000等,这样就可以将每一段去独立的管理。RoaringBitmap中有两种存储方式,使用Bitmap或者使用数组存储,如果数据很稀疏则使用数组存储十分划算,反之使用Bitmap存储划算。于是,在RoaringBitmap中会存在一个阈值,超过阈值使用Bitmap存储,最终将这两种数据结构组合起来,以解决数据稀疏的问题。
首先,如下是用户的标签宽表
用户 | 性别 | 年龄 | 偏好 |
---|---|---|---|
1 | 男 | 90后 | 数码 |
2 | 男 | 70后 | 书籍 |
3 | 男 | 90后 | 美食 |
4 | 女 | 80后 | 书籍 |
5 | 女 | 90后 | 美食 |
如果想根据标签划分人群,比如:年龄:90后 + 偏好:美食。
那么无非对列值进行遍历筛选,如果优化也就是列上建立索引,但是当这张表有1000个标签列时,如果要索引生效并不是每列有索引就行,要每种查询组合建一个索引才能生效,索引数量相当于1000个列排列组合的个数,这显然是不可能的。
那么更好的办法是按字段重组成Bitmap。
将年龄和偏好分别提取出来。
年龄 | Array | Bitmap |
---|---|---|
90后 | 1,3,5 | 00101010 |
80后 | 4 | 00010000 |
70后 | 2 | 00000100 |
性别 | Array | Bitmap |
---|---|---|
男 | 1,2,3 | 00001110 |
女 | 4,5 | 00110000 |
偏好 | Array | Bitmap |
---|---|---|
数码 | 1 | 00000010 |
美食 | 3,5 | 00101000 |
书籍 | 2,4 | 00010100 |
如果能把数据调整成这样的结构,想进行条件组合,就简单了。
比如: [美食] + [90后] = Bitmap[3,5] & Bitmap[1,3,5] = 3,5 这个计算速度相比宽表条件筛选是非常非常快的。
-- 将两列值拼成数组
select [1 as a, 2 as b];
-- 将两列值拼成元组
select (1 as a, 2 as b);
-- 元组外面再嵌套数组
select [(1 as a, 2 as b),(3,4)];
-- 再炸开
select arrayJoin( [(1 as a, 2 as b),(3,4)] );
-- 切开
select rs_col.1 , rs_col.2 from (select arrayJoin( [(1 as a, 2 as b),(3,4)] ) rs_col ) rs_t;
以上面的表举例:
create table user_tag_merge
( uid UInt64,
gender String,
agegroup String,
favor String
)engine=MergeTree()
order by (uid);
模拟数据
insert into user_tag_merge values(1,'M','90后','sm');
insert into user_tag_merge values(2,'M','70后','sj');
insert into user_tag_merge values(3,'M','90后','ms');
insert into user_tag_merge values(4,'F','80后','sj');
insert into user_tag_merge values(5,'F','90后','ms');
原始数据如下:
现依据上图流程,依次对数据进行处理
-- 拼
select uid, [ ('gender',gender),('agegroup',agegroup),('favor',favor)] from user_tag_merge;
-- 炸:用arrayJoin炸开,类似于hive中的explode
select uid, arrayJoin( [ ('gender',gender),('agegroup',agegroup),('favor',favor)] ) tv
from user_tag_merge;
-- 切
select tv.1,tv.2,uid
from
( select uid, arrayJoin( [ ('gender',gender),('agegroup',agegroup),('favor',favor)] ) tv
from user_tag_merge) user_tag;
-- 聚
select tv.1,tv.2,groupArray(uid)
from
( select uid, arrayJoin( [ ('gender',gender),('agegroup',agegroup),('favor',favor)] ) tv
from user_tag_merge) user_tag
group by tv.1,tv.2;
-- 聚(bitmap)
select tv.1,tv.2,groupBitmapState(uid)
from
( select uid, arrayJoin( [ ('gender',gender),('agegroup',agegroup),('favor',favor)] ) tv
from user_tag_merge) user_tag
group by tv.1,tv.2;
-- bitmap的结构本身无法用正常文本显示,为看出效果,再嵌套一层数组
select tv.1,tv.2,bitmapToArray(groupBitmapState(uid))
from
( select uid, arrayJoin( [ ('gender',gender),('agegroup',agegroup),('favor',favor)] ) tv
from user_tag_merge) user_tag
group by tv.1,tv.2;
create table user_tag_value_string
(
tag_code String,
tag_value String ,
us AggregateFunction(groupBitmap,UInt64)
)engine=AggregatingMergeTree()
partition by (tag_code)
order by (tag_value);
Bitmap表必须选择AggregatingMergeTree引擎。
对应的Bitmap字段,必须是AggregateFunction(groupBitmap,UInt64),groupBitmap标识数据的聚合方式,UInt64标识最大可存储的数字长度。
业务结构上,稍作了调整。把不同的标签放在了同一张表中,但是因为根据tag_code进行了分区,所以不同的标签实质上还是物理分开的。
groupBitmapState():将多行的值聚合成一个bitmap值。
insert into user_tag_value_string
select tv.1,tv.2,groupBitmapState(uid)
from
( select uid, arrayJoin( [ ('gender',gender),('agegroup',agegroup),('favor',favor)] ) tv
from user_tag_merge) user_tag
group by tv.1,tv.2;
-- 查看数据是否正确,再转成数组
select tag_code, tag_value, bitmapToArray(us) from user_tag_value_string;
结果如下:
以上操作就是通过一句sql,将在ClickHouse处理的宽表变成一个位图表。
使用这个Bitmap表进行查询。
比如想查询[90后]+[美食]的用户条件组合查询
bitmapAnd(bitmapa,bitmapb):求交集
select bitmapToArray(
bitmapAnd ( (select us from user_tag_value_string where tag_value='90后' and tag_code='agegroup') ,
(select us from user_tag_value_string where tag_value='ms' and tag_code='favor') )
);
首先用条件筛选出us, 每个代表一个Bitmap结构的uid集合,找到两个Bitmap后用bitmapAnd函数求交集。 然后为了观察结果用bitmapToArray函数转换成可见的数组。
groupBitmapState 和groupBitmapMergeState区别
前者 把普通值聚合成bitmap ,后者 是bitmap之间进行并集的聚合
select bitmapToArray(
bitmapAnd ( (select groupBitmapMergeState(us) from user_tag_value_string where tag_value in ('90后','80后') and tag_code='agegroup') ,
(select us from user_tag_value_string where tag_value='ms' and tag_code='favor') )
);
先对多个年龄组取并集,然后去交集。
询时,有可能需要针对某一个标签,取多个值,甚至是一个区间范围,那就会涉及多个值的userId集合,因此需要在子查询内部用groupBitmapMergeState进行一次合并,其实就多个集合取并集。
比如要取 [90后]或者[80后] + [美食]或者[书籍]
select bitmapToArray(
bitmapAnd ( (select groupBitmapMergeState(us) from user_tag_value_string where tag_value in ('90后','80后') and tag_code='agegroup') ,
(select groupBitmapMergeState(us) from user_tag_value_string where tag_value in ('ms','sj') and tag_code='favor') )
);
函数 | |
---|---|
arrayJoin | 宽表转Bitmap表需要行转列,要用arrayJoin把多列数组炸成行。 |
groupBitmapState | 把聚合列的数字值聚合成Bitmap的聚合函数 |
bitmapAnd | 求两个Bitmap值的交集 |
bitmapOr | 求两个Bitmap值的并集 |
bitmapXor | 求两个Bitmap值的差集(异或) |
bitmapToArray | 把Bitmap转换成数值数组 |
groupBitmapMergeState | 把一列中多个bitmap值进行并集聚合。 (连续值) |
bitmapCardinality | 求Bitmap包含的值个数 |
select tag_value,bitmapCardinality(us) from user_tag_value_string;
更多其他函数可以参考官网。