【性能优化】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 字段的记录。

【性能优化】5.4 [有序遍历] 程序游标
【性能优化】 前言及目录