用户行为分析系列实践 10 – 帐户有序存储
目标任务
用户事件表T结构和部分数据示例如下:
Time |
UserID |
EventType |
… |
2022/6/1 10:20 |
1072755 |
Search |
… |
2022/6/1 12:12 |
1078030 |
Browse |
… |
2022/6/1 12:36 |
1005093 |
Submit |
… |
2022/6/1 13:21 |
1048655 |
Login |
… |
2022/6/1 14:46 |
1037824 |
Logout |
… |
2022/6/1 15:19 |
1049626 |
AddtoCart |
… |
2022/6/1 16:00 |
1009296 |
Submit |
… |
2022/6/1 16:39 |
1070713 |
Browse |
… |
2022/6/1 17:40 |
1090884 |
Search |
… |
… |
… |
… |
… |
T表字段说明:
字段名 |
数据类型 |
字段含义 |
Time |
日期时间 |
事件发生的时间戳,精确到毫秒 |
UserID |
字符串 |
用户ID |
EventType |
字符串 |
事件类型 |
… |
… |
… |
计算任务:
统计指定时间段内,每种事件类型下的发生次数,以及去重用户数。
需要考虑的问题是,去重用户数非常多,内存放不下。
实践技能
关于有序去重/有序增量的相关知识可参考:
SQL 提速:大数据 DISTINCT 和 COUNT(DISTINCT)
1. 将数据按帐户排序后实施去重运算
按用户ID排序后另存为有序数据,可以实现快速去重。对有序字段去重计数,不需要在内存中保留结果集,遍历过程中只需要和上一条记录对比即可,速度快且不会有内存溢出问题。
2. 外部排序
数据量太大超出内存可容纳范围时,数据库的排序往往很慢,此时可以把数据先读出到集文件,在集算器中排序,再把结果存为组表文件
3. 有序增量
新增数据通常并不会按帐户字段继续有序,所以不能直接追加到有序数据的末尾,而直接将有序数据和新增数据一起重新做常规大排序,会非常耗时。
采用组表的补表机制,另外保持一个小规模的有序数据 (即补表)。新增数据排序后和补表归并,原组表不变。经过适当的时间后,补表积累到合适大小时,再和原组表归并。做去重计算时,要从原组表和补表中分别读取,归并后遍历,会比只有一份有序数据时性能下降一些,但仍能利用有序快速去重。
这个适当时间的确定,与新增数据的周期有关。比如每天都有新增数据,则每个月做一次原组表和补表的归并。补表不会超过一个月的数据量,原组表存储一个月之前的所有数据。也就是说补表可能会比原组表小很多,所以每天归并的数据量相对较小,很快就能完成数据追加。每个月才需要完成一次全量有序归并,耗时长一些也可以接受。
4. 并行计算
生成组表时指定按UserID字段分段,保证不把UserID相同的记录分到不同段里,之后统计时对组表产生多路游标,实现并行计算,进一步提高性能。
示例代码
1、 按用户ID排序后转储T表的基本方法
A |
|
1 |
=connect("demo").cursor@x("select * from T order by UserID,Time") |
2 |
=file("T.ctx").create@p(#UserID,#Time,EventType,…) |
3 |
=A2.append(A1) |
4 |
>A2.close() |
A1 读事实表,按UserID和Time排序
A2 产生组表文件的结构,@p选项表示组表将按第一个字段(这里是UserID)为单位分段,在并行计算时将不会把UserID相同的记录分配给不同的线程,确保并行计算的正确性。无此选项时,将简单地按记录为单位分段,可能把同一个UserID的数据分配给两个线程,在有序去重统计时会出错。前面章节中没有用到有序去重算法,也就没有特别的分段要求,不用@p也可以正确地并行计算。
A3 把数据输出到A2组表文件中
2、 外部排序
如果数据量很大,超出了内存可容纳范围,需要利用硬盘缓存才能排序的话,数据库的排序可能会非常慢,此时可以先把数据导出到集文件,再利用集算器排序,结果存为组表文件:
A |
|
1 |
=connect("demo").cursor@x("select * from T") |
2 |
=file("T.btx").export@b(A1) |
3 |
=file("T.ctx").create@p(#UserID,#Time,EventType,…) |
4 |
=file("T.btx").cursor@b(UserID,Time,EventType,…).sortx(UserID,Time;1000000) |
5 |
=A3.append(A4) |
6 |
>A3.close() |
A1 读事实表不排序
A2 把数据输出到集文件
A3 产生组表数据结构
A4 读集文件,产生游标,按UserID,Time排序。此时可以在sortx(…;n)的n参数中指定缓冲区的行数,如果无法确定缓冲区的行数,可以不用此参数,sortx函数会自动算出一个相对合适的缓冲区行数。一般建议缓冲区占用的内存不超过可用内存的一半,占用多了会导致java虚拟机变慢,在内存足够大的情况下,缓冲区越大排序越快
A5 把排好序的数据输出到组表文件
3、 有序增量
增量数据:用时间戳来识别增量数据,每天0点以后,把前一天的增量数据追加到组表文件里:
A |
B |
|
1 |
=connect("demo").cursor@x("select * from T where Time>=? && Time<? order by UserID,Time",date(now()-1), date(now())) |
|
2 |
=file("T.ctx").open() |
|
3 |
=A2.append@a(A1) |
|
4 |
if day(now())==1 |
>A2.reset() |
5 |
>A2.close() |
A1 读事实表的增量部分数据,因为增量比较小,直接按UserID,Time排序
A2 打开组表文件
A3 把增量数据以归并的方式输出到组表文件的补表中,@a选项表示以归并的方式输出到补表文件中
A4-B4 判断当前是否为每月1日,如果是,对组表和补表进行合并,补表清空,这个功能由reset()函数来完成
产生组表时使用了@p选项,增量数据追加到补表时,SPL也会自动按正确的方式处理,保证后续的并行计算正确。
4、 对按用户有序的组表计算去重用户数
A |
|
1 |
>start=date("2022-03-15","yyyy-MM-dd"),end=date("2022-06-16","yyyy-MM-dd") |
2 |
=file("T.ctx").open().cursor@m(UserID, EventType;Time>=start && Time<=end;2).groups(EventType; count(1):Num, icount@o(UserID):iNum) |
A2 在游标中对T表进行过滤,然后再分组汇总。icount@o()选项表示数据按照去重字段有序。数据准备时使用过@p,在这里只要简单使用@m选项就可以实现并行计算了。
遗留问题:数据进行去重计数前,还需要按Time字段过滤,由于为了解决去重计数的问题,数据先按UserID排序了,Time不再是第一有序字段,Time上的过滤条件就不能快速执行,只能在每个UserID下再利用有序来过滤,但每个UserID下的数据量很少,一般不会超过一个数据块,所以几乎不会发生直接跳块的现象,结果会相当于遍历所有数据。对于总数据量的时间跨度较大而待查询区间的时间跨度较小的场景,这种方法的性能会较差。如何既解决UserID的去重计数问题,又解决Time字段的过滤问题,会在后续的文章(双维有序)中介绍。
运行结果:
EventType |
Num |
iNum |
AddtoCart |
1845674 |
175476 |
Browse |
3578901 |
348791 |
Login |
4033000 |
393400 |
Logout |
4033000 |
393400 |
Search |
2947931 |
257539 |
Submit |
867345 |
83375 |
英文版