做者:陈凯GrowingIO 数据开发工程师,主要负责 SaaS 和 OP 产品数据平台的开发和设计,目前专攻于微服务、数仓建设方向。java
GrowingIO 天天须要处理近千亿的用户行为数据,平台的「事件分析」模块是使用比较频繁的功能,简单且强大。在事件分析中,客户能够很灵活地使用多种维度组合去查看某个指标,而且查询的速度也十分可观。数组
本文抽取 GrowingIO 在事件分析中的通用数据模型,揭晓该功能背后的存储模型和实现原理。浏览器
在用户行为的数据分析中,不管是无埋点,仍是埋点,对于某一条行为数据的表达形式每每是:「某人」于「某个时间」在「某个维度」下作了「某个动做」「多少次」。缓存
因此在数据统计中,这种表达形式能够拆解成「指标量」和「维度」,指标量能够是用户量、页面浏览量、某个埋点的次数等,维度能够是时间、城市、浏览器、用户属性等。数据结构
在海量数据的背景下,如何比较高效地完成指标+维度的计算,一直是大数据分析领域比较热门的话题,下面将讲述在 GrowingIO ,咱们是如何高效解决的。架构
1.从一个数据需求提及📈并发
假设给定以下一组用户行为的原始数据:框架
数据含义: 表示某个用户的某次访问记录。(这里仅列举了地区和设备维度,固然还会存在浏览器、平台、版本等维度,这里不一一列举了。)分布式
1.1 使用 SQL 分析统计微服务
🤔 如今业务想计算「过去7天」在「地区」维度下,「设备: Mac」的人数是多少?So Easy,一个 SQL 搞定
使用 GrowingIO 平台的分析工具能够表示以下:
可是经过 SQL 这种现查的方式,随着数据量的愈来愈大,几十亿或上百亿的时候,对计算所须要的资源和响应时间也会线性地增加,此时客户在使用平台工具最直观的感觉就是“菊花”转转转,图表一直加载不出来。
1.2 如何使查询更加高效
1.2.1 堆机器,加资源
最直接粗暴的方式,就是增长更多的计算资源,或者对查询的结果进行缓存、预热。可是对于 SaaS 产品来说,在查询并发比较高的时候,再多的计算资源也会由于查询排队而遇到性能瓶颈。
1.2.2 数据分层
😼 在数仓的分层架构中,对于常用的查询结果,咱们能够经过离线计算的方式生成了一个结果表「过去7天-地区-设备-指标表」,示例以下:
这样在特性的查询场景中,只须要查询结果表就行,很大的减小了计算量,响应时间也短了很多。
😱 可是业务那边的需求每每是变幻无穷的,而后一堆统计的需求砸到了你的脑壳上
......
😫 你看了看生成结果表,发现并不能彻底解决这些问题,你以为须要生成更多的结果表来知足更多的需求。然而到最后你发现有些表竟然仅仅只使用了一次,数仓里面堆了一堆垃圾。
1.2.3 数据预聚合
和数仓分层的理念相似,对数据进行预计算、预聚合,使用空间换时间的思想加快计算。这也是目前一些主流开源框架的解决思路,好比 SparkSQL 的物化视图、Kylin的 Cube、Druid 的 Segment 、Carbondata 的 MV 等。
下面使用一张图展现主要区别:
基于咱们所追求的方式,咱们首先须要寻找一种高效而且灵活的存储模型。
1.3 优化存储模型
基于上节中数据预聚合的思路,从预聚合的结果中,咱们不难发现,其中有几个没有摆脱的点:
🤔 如何才能更好的让维度和指标为所欲为地组合呢?咱们在预聚合结果的基础之上作了一些改进:
2. 基于 BitMap 的存储模型 💻
2.1 纵向存储维度(人数)
依然以开篇的那组数据为例,此时将维度进行纵向存储:
此时想取「地区: 北京」和「设备: Mac」的「用户量」
OK!这样能够很灵活的解决各类维度组合起来的问题了,并且连用户的群体也能直接获取。
😇 可是从表格中发现,用户存储「用户集合」的数据结构尚未解决。那么既能以相似数组的方式存储整数值,还能使用交集(and)操做,还须要达到更好的数据压缩和计算。
此时你应该想到了 BitMap 这种数据结构
至于为什么选用 BitMap 的数据结构,以及 BitMap 的功能和基本使用,这里再也不探讨。能够参考 java 的实现 BitSet 以及优化的库 RoaringBitmap。
2.2 存储指标量(次数)
为了解决存储指标次数的存储问题,你须要用一个Map 的结构来存储「总的次数」: Map<Int, BitMap> (其中key为次数,value为符合访问次数的人)
访问量表示: 总共访问「1次」有哪些人,访问「2次」的有哪些人等等。
此时计算「地区: 北京」和「设备: Mac」的「访问量」
2.3 使用更优雅的方式存储次数
在 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 中存储这我的。
而后将上节 2.2 中结果表示以下:
此时计算「地区: 北京」和「设备: Mac」的「访问量」
3. 多维度交叉的问题 ⚔️
理想是美好的,可是现实很残酷。在 2.1 小节的例子中,每一个用户的维度组合只有一种,可是现实中每每一个用户行为可能会存在多种维度组合的状况。
那么什么是维度组合: 一条数据中惟一的全部维度值,称为一个组合。
PS: 若是你的系统中某个 ID 的维度组合只有一种。好比某个订单,一旦生成了,他的价格,商品,物流等信息基本都是固定的。那么以前的模型基本都能知足大多场景了。
3.1 面临的问题
🤔 那么会致使什么问题呢?此时回到起点,又来了一批用户行为的数据以下:
此时多了一个「用户1」在「杭州」使用了「Windows」。若是按照以前的模型存储以下:
此时计算「地区: 北京」的用户:直接返回 [ 1 , 2 , 3 ],问题不大
此时计算「地区: 北京」和「设备: Windows」的用户
❌ 你会发现,得出的结果是错的,应该只有「用户 3 」知足才对。
3.2 使用维度组合编号的方式解决
其实问题出在将维度分开进行存储的时候,丢失了「维度组合关系」这个重要的衡量条件。「用户 1 」虽然在「北京」待过,也使用过「Windows」,可是他却没有同时知足这个条件,这就是问题所在。
因此须要一种方式来存储「维度组合关系」这一重要信息。
将每一个人维度组合进行顺序编号,获得以下结果:
注意:编号是对应到每一个人的,相同的维度组合,编号是同样的。
此时对应的存储结构也发生了变化:Map<Short, BitMap>( key 表明编号,value 表明人的集合
此时咱们再来计算「地区: 北京」和 「设备: Windows」维度的用户
最后获得的结果就是「用户3」,算出来的数据就变准确了。
3.3 多维度状况下计算次数
其实稍微想一下就是两层的 Map 结构:Map<Int, Map<Short, BitMap>>,好比刚刚的那组数据表示以下:
好比「用户1」这个用户,在「编号0」发生的 2 次,在「编号1」发生了 1 次
此时咱们来计算「地区: 北京」和 「设备: Mac」维度的访问量
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 的问题。
......
关于 GrowingIO
GrowingIO 是基于用户行为数据的增加平台,国内领先的数据运营解决方案供应商。为产品、运营、市场、数据团队及管理者提供客户数据平台、获客分析、产品分析、智能运营等产品和咨询服务,帮助企业在数据化升级的路上,提高数据驱动能力,实现更好的增加。
若是对咱们的产品感兴趣,欢迎点击此处领取 15 天免费试用!