文章目录
  1. 1、背景
  2. 2、非结构化建模
    1. 2.1、结构化 & 非结构化
    2. 2.2、架构
    3. 2.3、数据需求
    4. 2.4、现有解决方案分析
    5. 2.5、非结构化建模思路
  3. 3、基于Bitmap的存储模型
    1. 3.1、分离维度和指标
    2. 3.2、维度存储模型
    3. 3.3、指标存储模型
    4. 3.4、指标存储优化
    5. 3.5 多维交叉导致的问题
    6. 3.6、维度组合编号
    7. 3.7、多维交叉下计算次数
  4. 4、简单的性能对比
  5. 5、小结
  6. 6、应用到分析工具
    1. 6.1、事件分析
    2. 6.2、漏斗分析
    3. 6.3、留存分析
    4. 6.4、标签计算
  7. 7 总结

从这篇文章中,你可以学习到如下内容

  • 如何基于Bitmap(RoaringBitMap)构建非结构化数仓模型
  • 如何基于一份数据模型优化计算存储成本,并且统一取数口径
  • 如何搭建无表结构约束的OLAP分析
  • 如何有效快速地应对各种业务分析场景

1、背景

在用户行为的数据分析中,无论是无埋点,还是埋点,对于某一条行为数据的表达形式往往是:「某人」于「某个时间」在「某个维度」下做了「某个动作」「多少次」。
为了构建通用类型的数据分析,这种表达形式往往可以拆解成「时间」、「实体」、「维度」和「指标量」:

  • 时间:事件发生的时间,往往最后会聚合成分钟、小时、天。
  • 实体:分析的实体对象,可以是人、设备、视频、文章、作者等。
  • 维度:事件携带的变量,可以是城市、浏览器、用户属性、事件特有的属性等。
  • 指标量:事件携带的分析指标,可以是事件本身的次数,页面浏览量、停留时长、购买事件的金额等。

在海量数据的背景下,如何高效、灵活地完成指标+维度的计算,一直是大数据分析领域比较热门的话题。
本文优先从底层的存储模型出发,从技术的角度,尝试让one-data,any-analysis成为一种尝试和可能。

2、非结构化建模

2.1、结构化 & 非结构化

定义:

  • 结构化数据:指具有固定格式(模式)或有限长度的数据,如数据库,元数据等。
  • 非结构化数据:指不定长或无固定格式的数据,如邮件,word文档,图片,音乐等。

存储到 HIVE 的表现形式:

  • 结构化:字符串、数字
  • 非结构化:binary

2.2、架构

非结构化建模所处的架构位置

2.3、数据需求

假设有如下一组分析数据

时间(时间抽象) 用户ID(分析实体) 城市(分析维度) 操作系统(分析维度) 其他(分析维度) pv(分析指标)
2020-01-01 1 上海 windows 1
2020-01-01 2 上海 windows 3
2020-01-01 3 上海 mac 4
2020-01-01 4 北京 mac 5
2020-01-01 5 北京 windows 3
2020-01-01 6 上海 windows 2

然后来了一个数据需求:「过去7天」在「城市」维度下,「操作系统: Mac」的人数是多少?,此时使用分析工具结果如下:

实现SQL:

1
2
3
4
5
6
7
select
地区,
count(distinct 用户ID)
from table
where 时间 = '过去七天'
and 操作系统 = 'Mac'
group by 地区

但是通过 SQL 这种现查明细的方式,随着数据量的越来越大,几十亿或上百亿的时候,对计算所需要的资源和响应时间也会线性地增长,此时客户在使用平台工具最直观的感受就是“菊花”转转转,图表一直加载不出来。

2.4、现有解决方案分析

  • 数据预聚合、选择优秀的MOLAP引擎

在数仓分层架构中,对于常用的维度组合,我们往往会通过离线计算或者构建cube的方式进行预聚合的计算,这样可以加速特定场景的分析。
但是这种方式往往会形成数据冗余,并且应付不了灵活的维度组合分析,因为产品和分析师的需求往往是千变万化的。

  • 堆机器、加资源、选择优秀的ROLAP引擎

最直接粗暴的方式,就是增加更多的计算资源,或者对查询的结果进行缓存、预热。
这种方式可以很直接的解决多维分析的场景,但是在查询并发比较高,数据量线性递增的时候,再多的计算资源也会因为查询排队而遇到性能瓶颈。
其次对于目前大多的OLAP引擎,需要我们提前指定好「维度+指标」的结构,这样就需要我们提前充分的思考各种业务过程,来满足未来的各种需求,这样其实也有些适得其反。

  • 理想模型

能不能有一种存储模型可以解决当前的各种痛点,她的特性如下:

  • 可以支持任意的分布式计算框架
  • 模型足够简单,计算和存储资源足够减少,数据冗余度降到最低,且满足高并发查询
  • 可以构建Table Schema-Less的OLAP分析
  • 可以满足各种分析场景(上卷,下钻,维度组合,留存,漏斗,用户画像标签等)

2.5、非结构化建模思路

这里我们仍然采用数据预聚合的建模思想,只是需要做”亿点点“的改变。从当前主流OLAP分析框架来讲,在建立数据模型的时候,其中有几个没有摆脱的点:

  • 多个维度横向存储,无法满足所有业务场景
  • 维度和指标没有分离,需要table schema强绑定
  • 精准去重依赖原子粒度数据,伪预聚合

3、基于Bitmap的存储模型

其实现在很多的框架为了满足特定的业务场景,比如分群画像,精准去重等,其实也使用了bitmap的结构,这里我们想把这种数据结构应用到更多的场景中。
关于BitMap的介绍这里就不重复阐述了,这里底层使用的是优化后的 RoaringBitmap

3.1、分离维度和指标

这里其实也是可以分离「实体」的,为了简单,先讨论「维度」和「指标」。同时「时间」在下面的列举中也一并忽略。

依然采用2.3的原子数据,这里我们将数据做些简单的改变。

时间 分析实体 分析维度 分析指标
2020-01-01 1 { _city: 上海, _os: windows, …. } { _pv: 1, … }
2020-01-01 2 { _city: 上海, _os: windows, …. } { _pv: 3, … }
2020-01-01 3 { _city: 上海, _os: mac, …. } { _pv: 4, … }
2020-01-01 4 { _city: 北京, _os: mac, …. } { _pv: 5, … }
2020-01-01 5 { _city: 北京, _os: windows, …. } { _pv: 3, … }
2020-01-01 6 { _city: 上海, _os: windows, …. } { _pv: 2, … }

为了构建Schema-Less分析,我们将维度和指标变成类似Map的结构存储方式。

3.2、维度存储模型

  • 存储结构
维度标识 纬度值 用户集合(bitmap)
_city 北京 [4,5]
_city 上海 [1,2,3,6]
_os windows [1,2,5,6]
_os mac [3,4]
  • 此时计算「城市: 北京」和「操作系统: Mac」的「用户量」
1
2
3
4
5
6
第一步:取出对应的用户集合

第二步:做交集计算
[4,5] and [3,4] = [4]

第三部:用户量 = 1

OK!这样可以很灵活的解决各种维度组合起来的问题了,而且连用户的分群也能直接获取。
使用这种存储方式的优点:

  • 可以直接获取用户和分群
  • 满足群体的交并差计算
  • 无需提前预估维度的数量,直接将维度“变高”即可。

缺点:

  • 在做实际分析,选择维度过多的情况下,需要应对笛卡尔积所带来的挑战。不过在大多分析实例中,4~5个维度基本就能满足大多分析场景了。

3.3、指标存储模型

为了解决指标次数的存储问题,需要用一个 Map 的结构来存储「总的次数」: Map<Int, BitMap> (其中key为次数,value为符合访问次数的人)

指标标识 用户集合
_pv { 1: [1], 2: [6], 3: [2,5], 4: [3], 5: [4] }
停留时长
跳出次数
某个埋点
  • 此时计算「城市: 北京」和「操作系统: Mac」的「pv」
1
2
3
4
5
6
7
8
9
10
11
12
13
14
第一步:取出「维度表」中对应维度的人
[4,5] and [3,4] = [4]

第二步:取出pv对应的指标,进行计算
{
1: [1],
2: [6],
3: [2,5], and [4] = { 5: [4] }
4: [3],
5: [4]
}

第三步:计算值,总共1个人
pv = 5 * 1 = 5(次)

3.4、指标存储优化

在 Map<Int, BitMap> 这个结构中,key 存储的是 10 进制的数字。这就会导致 Map 的 key 变得特别特别多,所以需要有一种方式来优化一下结构。
方式就是用将 10 进制转化为 2 进制的方式去存储次数,此时 Map 的 key 存储是 二进制为 1 的位置:

  • 比如 2 的二进制是: 「10」,从右向左分别表示(下标i从0开始)「第0位是0」,「第1位是1」。所以将key为1的 bitmap 中存储这个人。
  • 比如5的二进制是: 「101」,从右向左分别表示「第0位是1」,「第1位是0」,「第2位是1」。所以将 key 为 0 和 2 的 bitmap 中存储这个人。

然后将上节的数据存储如下:

指标标识 用户集合
_pv { 0: [1,2,4,5], 1: [2,5,6], 2: [3,4] }
停留时长
跳出次数
某个埋点
  • 此时计算「城市: 北京」和「操作系统: Mac」的「pv」
1
2
3
4
5
6
7
8
9
10
11
12
第一步:取出「维度表」中对应维度的人
[4,5] and [3,4] = [4]

第二步:取出pv对应的指标,进行计算
{
0: [1,2,4,5],
1: [2,5,6], and [4] = { 0: [4], 2: [4] }
2: [3,4]
}

第三步:计算值
pv = 2^0 * 1 + 2^2 * 1 = 5(次)

3.5 多维交叉导致的问题

理想是美好的,但是现实很残酷。在 3.1 小节的例子中,每个用户的维度组合只有一种,但是现实中往往一个用户行为可能会存在多种维度组合交叉的情况。

维度组合: 一条数据中唯一的所有维度值,称为一个组合。
PS: 如果你的系统中某个 ID 的维度组合只有一种。比如某个订单,一旦生成了,他的价格,商品,物流等信息基本都是固定的。那么之前的模型基本都能满足大多场景了。

所面临的问题:假设又来了一批数据如下

时间 分析实体 分析维度 分析指标
2020-01-01 1 { _city: 上海, _os: windows, …. } { _pv: 1, … }
2020-01-01 2 { _city: 上海, _os: windows, …. } { _pv: 3, … }
⭐️ 2020-01-01 2 { _city: 北京, _os: mac, …. } { _pv: 5, … }
2020-01-01 3 { _city: 上海, _os: mac, …. } { _pv: 4, … }
2020-01-01 4 { _city: 北京, _os: mac, …. } { _pv: 5, … }
2020-01-01 5 { _city: 北京, _os: windows, …. } { _pv: 3, … }
2020-01-01 6 { _city: 上海, _os: windows, …. } { _pv: 2, … }

此时多了一个「用户2」在「北京」使用了「mac」访问了「pv: 5」。按照之前的维度存储模型如下:

维度标识 纬度值 用户集合
_city 北京 [2,4,5]
_city 上海 [1,2,3,6]
_os windows [1,2,5,6]
_os mac [2,3,4]
  • 此时计算「地区: 北京」的用户:直接返回 [2,4,5],问题不大
  • 此时要是想要计算「地区:北京」,「操作系统:windows」的用户
1
[2,4,5] and [1,2,5,6] = [2,5]

❌ 你会发现,得出的结果是错的,应该只有「用户 5 」满足才对。

3.6、维度组合编号

其实问题出在将维度分开进行存储的时候,丢失了「维度组合关系」这个重要的衡量条件。「用户 2 」虽然在「北京」待过,也使用过「Windows」,但是他却没有同时满足这个条件,这就是问题所在。
所以需要一种方式来存储「维度组合关系」这一重要信息。

使用维度组合编号的方式解决这种问题,将每个人维度组合进行顺序编号,得到如下结果:

编号 时间 分析实体 分析维度 分析指标
0 2020-01-01 1 { _city: 上海, _os: windows, …. } { _pv: 1, … }
0 2020-01-01 2 { _city: 上海, _os: windows, …. } { _pv: 3, … }
⭐️1 2020-01-01 2 { _city: 北京, _os: mac, …. } { _pv: 5, … }
0 2020-01-01 3 { _city: 上海, _os: mac, …. } { _pv: 4, … }
0 2020-01-01 4 { _city: 北京, _os: mac, …. } { _pv: 5, … }
0 2020-01-01 5 { _city: 北京, _os: windows, …. } { _pv: 3, … }
0 2020-01-01 6 { _city: 上海, _os: windows, …. } { _pv: 2, … }

注意:编号是对应到每个人的,相同的维度组合,编号是一样的。

此时维度对应的存储结构也发生了变化:Map<Short, BitMap>( key 代表编号,value 代表人的集合)

维度标识 纬度值 用户集合
_city 北京 { 0: [4,5], 1: [2] }
_city 上海 { 0: [1,2,3,6] }
_os windows { 0: [1,2,5,6] }
_os mac { 0: [3,4], 1: [2] }

此时要是想要计算「地区:北京」,「操作系统:windows」的用户

1
2
3
{ 0: [4,5], 1: [2]  }  and { 0: [1,2,5,6] } = { 0: [5] }

> 计算规则:相同维度编号的进行计算

此时只有5一个用户,算出来的数据就变准确了。

3.7、多维交叉下计算次数

其实稍微想一下就是两层的 Map 结构:Map<Int, Map<Short, BitMap>>,之前存储次数的结构如下:

指标标识 用户集合
_pv { 0: {0: [1,2,4,5], 1: [2]}, 1: {0: [2,5,6]}, 2: {0: [3,4], 1: [2]} }
停留时长
跳出次数
某个埋点

此时要是想要计算「地区:北京」,「操作系统:windows」的「pv」

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
第一步:取出对应维度的人
{ 0: [4,5], 1: [2] } and { 0: [1,2,5,6] } = { 0: [5] }

第二步:取出pv对应的指标,进行计算
{
0: { 0: [1,2,4,5], 1: [2] },
1: { 0: [2,5,6] }, and { 0: [5] }
2: { 0: [3,4], 1: [2] }
}

=

{
0: { 0: [5] },
1: { 0: [5] }
}

第三步:计算次数
pv = 2^0 * 1 + 2^1 * 1 = 3(次)

4、简单的性能对比

环境准备:SparkSQL(local[16], 内存4G), BitMap 单线程计算(内存4G)
场景:简单的 2 ~ 3 个维度组合求人数、次数,按照值的降序取 Top 1000

x轴含义: 数据量/用户量。
y轴含义: 计算时间, 单位毫秒。

可以看到随着数据量的不断递增,SparkSQL 的计算时间也在不断递增,但是 BitMap 的计算时间却相对比较稳定。
对于数据量偏小的产品,理论上在计算出基础模型之后,查询这块单台机器就能搞定。

5、小结

BitMap 是一个兼并计算和存储优势的数据结构,在存储上百万甚至上千万的 ID 时,也能得到很好的计算效果。
并且当你使用 BitMap 存储的时候,就已经天然支持很多的业务场景,比如分群计算、标签计算、漏斗分析、留存分析、用户触达等,因为无需再重新计算人群。

本篇主要揭晓如何基于 BitMap 来作为底层的数据模型,当然在实际应用过程中还有很多的挑战,由于篇幅原因,这里就不展开讲述了。

以下列出一些当前面临的问题以及未来的攻克方向:

  • bitmap 是以 int 值进行存储的,但是在实际生产中,你的 ID 数据可能是类似 UUID 的这种字符串,那么需要解决 string 转唯一 int 的问题,这块可以使用「分布式ID服务」来解决。
  • 如何使 bitmap 在分布式环境下达到更好的计算效果,并且在计算层面做到延迟物化。
  • 如何解决高基维度所带来的挑战。
  • 如何在实时、图表分析、自定义标签、分群画像以及更多的业务场景中进行使用。
  • 如何设计一个类 SQL 语言来兼容这种数据模型,这个也是我目前正在研究的方向,因为现在主流的BI工具如superset,metabase,都是需要对接SQL语言的。
    ……

6、应用到分析工具

下面将列举一些简单的分析场景,看一下这种数据模型是如何在实战中应用的。

6.1、事件分析

前面例举计算pv的例子就是基于「多维分析」的事件分析工具进行阐述的,这里就不重复了。

6.2、漏斗分析

如果进行维度对比,比如「city」,可以直接取「city」维度对应的bitmap去”削“图中的结果即可。

6.3、留存分析

6.4、标签计算

PS:需要pv的bitmap支持各种四则运算。

7 总结

欢迎吐槽。

文章目录
  1. 1、背景
  2. 2、非结构化建模
    1. 2.1、结构化 & 非结构化
    2. 2.2、架构
    3. 2.3、数据需求
    4. 2.4、现有解决方案分析
    5. 2.5、非结构化建模思路
  3. 3、基于Bitmap的存储模型
    1. 3.1、分离维度和指标
    2. 3.2、维度存储模型
    3. 3.3、指标存储模型
    4. 3.4、指标存储优化
    5. 3.5 多维交叉导致的问题
    6. 3.6、维度组合编号
    7. 3.7、多维交叉下计算次数
  4. 4、简单的性能对比
  5. 5、小结
  6. 6、应用到分析工具
    1. 6.1、事件分析
    2. 6.2、漏斗分析
    3. 6.3、留存分析
    4. 6.4、标签计算
  7. 7 总结