【性能优化】5.3 [有序遍历] 有序分组子集
【性能优化】5.2 [有序遍历] DISTINCT 和 COUNT(DISTINCT)
5.3 有序分组子集
数据表对分组键有序时,可以依次将分组子集用游标方式读出,利用这一点,我们可以做一些复杂的运算。
比如一年的帐户交易表,我们想统计有多少帐户在连续 n 天内消费超过 m 次,n 和 m 作为界面参数由用户临时输入,希望即时查询出结果。
连续 n 天内消费超过 m 次,是个较复杂的运算,不太可能用简单聚合函数写出来(用迭代函数也不行),一般要把这些交易记录读出到内存中计算才方便,也就是要每次取出一个帐户下的交易记录来计算,一个帐户内的交易数据一般也很少,内存足够放下。
按帐户分组后的分组子集就是每个帐户下的交易记录,但这种场景的帐户数很可能非常多,这是典型的大分组,不可能把所有的分组子集都装入内存。每次取出某个帐户的交易记录,无索引时要遍历整个表,完全无法接受;即使有索引,因为通常原始数据表是按交易时间排序的,这时取出次数过多时也会非常慢(参考前面章节中返回集合的查找中的解释)。
如果我们事先把数据表按帐户排序后(帐户内的交易按日期排序),再利用有序分组技术就可以很方便地取出这些分组子集来计算了:
A |
B |
C |
|
1 |
=file("trades.ctx").open().cursor(id,dt) |
>m-=1,n-=1 |
|
2 |
for A1;id |
if (k=(t=A2.(dt)).len()-m)<=0 |
next |
3 |
=@+if(k.pselect(t(#+m)-t(#)<=n),1,0) |
||
4 |
return B3 |
for 语句针对有序游标每次读出一个分组子集,然后再判断是否存在 n 天内有 m 次交易。
实际运行的代码还会再做些优化,每次要多读入一些帐户数量。
这几句代码也可以拼进 group 函数中:
A |
B |
|
1 |
=file("trades.ctx").open().cursor(id,dt) |
>m-=1,n-=1 |
2 |
=A1.group(id).((k=(t=~.(dt)).len()-m))>0 && k.pselect(t(#+m)-t(#)<=n)) |
|
3 |
return A2.total(count(~)) |
对 group 取出来的每个分组子集计算一个逻辑表达式,结果为 true 表示存在 n 天内有 m 次交易的现象,而 A2 也会返回成一个游标,再对游标遍历统计其中 true 的个数就可以了。
如果数据表使用了按上节讲的组表分段方式建立,这个运算还可以基于多路游标工作,只要简单地在产生游标的函数上加选项即可:
A |
B |
|
1 |
=file("trades.ctx").open().cursor@m(id,dt;;4) |
>m-=1,n-=1 |
2 |
=A1.group(id).((k=(t=~).(dt).len()-m)>0 && k.pselect(t(#+m)-t(#)<=n)) |
|
3 |
return A2.total(count(~)) |
有序数据的维护追加方法在前面章节已经讲过,经过预排序后的数据,再实施前面讲过的把日期转换成整数的技巧,即使数据量非常大,也有可能获得即时响应的高性能。这种计算方法能比传统数据库上使用游标计算(单句 SQL 也写不出这种逻辑)快出一两个数量级。
有序分组子集技术对于提高海量帐户复杂分析的性能非常有用。
有了分组子集,也很容易理解 DISTINCT,每一组取一个成员就可以了。比如计算一下交易表中每个帐户在哪些月份进行了交易。
A |
|
1 |
=file("trades.ctx").open().cursor(id,dt) |
2 |
=A1.group(id,month(dt)).(~(1)) |
A2 计算出来的游标就能发生过交易的帐户及月份,它实际上取出每个分组子集的第一条记录。注意这是一个游标,还需要再 fetch 才能实际计算去取出数据,后面有许多例子都会是这样,我们只计算到游标即可,因为结果集可能很大而不能全部取出,拿到游标后可以再做进一步的计算或将结果集保存成文件。
取分组子集第一条的运算比较常见,SPL 提供了选项:
A |
|
1 |
=file("trades.ctx").open().cursor(id,dt) |
2 |
=A1.group@1(id,month(dt)) |
group@1 会做上面同样的事情,但它不会把分组子集先产生出来,这样占用内存会更小,而且也可以适应有时分组子集很大的情况(不过这时候通常是小分组了)。
group@1 其实就可以理解为 DISTINCT,和 id 函数的功能基本相同。数据有序时去重也可以高效完成,而无序时的去重和分组一样是个高复杂度任务。
继续基于这个帐户交易表,我们现在想为每笔记录增加一个月累计金额信息,即这笔交易是完成后,此帐户该月的累计交易金额是多少,然后再过滤出每个帐户每月第一次累计交易额超过 100 时的那笔交易(含日期)。
可以读出分组子集再计算出这些信息:
A |
|
1 |
=file("trades.ctx").open().cursor(id,dt,amonut) |
2 |
=A1.group(id,month(dt)).(~.derive(itertate(~~+amount,0):mca)) |
3 |
=A2.(~.select@1(mca>=100)) |
使用针对明细数据的迭代函数可以完成这种累计计算,这里的迭代函数仍然有前述过的特征:计算结果有个初值,每次用被遍历到的成员计算出新结果。与用作聚合函数只是返回最终结果不同的是,针对明细数据时,每次都会把当前的计算结果返回。这样就能在 A2 中 derive 函数增加的字段中计算出截止每次交易的月累计额了。
需要注意的是,A2 计算出来的游标,它取出的每一条数据是一个序表,在 A3 中要针对这个序表过滤(取出第一条累计额达标的记录),而不是直接针对游标做过滤。
但是,这个算法要把分组子集取出来。如果我们换一种场景,另一个订单表按产品和日期排序,目标改为计算出每个产品每月累计订单额超过 100 的日期,因为同一产品的交易记录可能很多,有可能是个大分组子集,那么事先取出分组子集再来计算的办法就不行了。
对于有序游标上的累计计算,还可以使用迭代函数的分组参数来实现:
A |
|
1 |
=file("orders2.ctx").open().cursor(product,dt,amount) |
2 |
=A1.derive(iterate( ~~+amount,0; prudoct,month(dt) ): mca) |
3 |
=A2.select(mca>=100).group@1(product,moth(dt)) |
iterate 参数中分号后面的参数用于表明分组字段,当这些字段(或表达式)变化时,SPL 会重新开始新一轮迭代函数的计算(把计算结果再设成为初值然后继续迭代),迭代计算时只要对比上一条记录,不必先取出整个分组子集,内存占用更少,也可以支持大的分组子集。累计额字段会增加到原来记录上,在 A3 中就可以直接对游标做 select。
最后还要用 group@1 做一下去重运算,这时候取出来的分组子集的第一条记录,虽然分组键是产品和月份,但取出来的记录是分组前的,即含有 dt 字段的记录。