SPL 遍历复用

遍历复用的原理

对外存数据表做遍历计算时,大部分时间都用来从硬盘上读取数据了。所以我们会希望一次读取能做尽量多的事情,也就是尽量做到能复用遍历过程中读出来的数据。

 

比如我们对订单做分组统计,希望完成两种计算。计算 1(compute1),是按产品分组统计销售额,得到结果 result1。计算 2(compute2),是统计每个地区的最大订单额,得到结果 result2。直接的办法是遍历两次,大致是下图 1 这样的:

..

                                            图 1 遍历两次

图 1 中第 i 步,用游标遍历订单表取出产品和订单金额字段,计算 compute1 得到 result1。第 ii 步,用一个新游标再遍历一次订单表取出地区和订单金额字段,计算 compute2 得到 result2。

假如订单表是列存,两次遍历中金额字段会被重复读取。如果是行存,那么重复读取的数据量更大。

 

我们使用一点技巧,就可以只对订单表遍历一次,也能得到两个分组结果。SPL 代码大致是这样:


A

1

=file("orders.ctx").open()

2

=A1.cursor(area,product,amount).groups(area,product;sum(amount):samount,max(amount):mamount)

3

=A2.groups(product;sum(samount))

4

=A2.groups(area;max(mamount))

这样做,CPU 的计算量会多一些,占用内存也会更大,因为要计算和保持一个更细致的分组结果集,但是遍历量少很多,通常还是能获得更好的运算性能。

不过,如果我们还要对更多字段做不同的分组统计,代码就会麻烦得多;要是再对这个游标做一些不是简单分组的运算,比如先过滤后再分组,那几乎就不可能用这种技巧写出来了。在数据库中使用 SQL 就会面临这种窘境。

 

SPL 提供了更强大的遍历复用技术来解决这类问题。为了便于理解,我们先看只用游标来计算 compute1 的过程,基本上是下图 2 的样子:

..

                                            图 2 仅用游标计算 computer1

图 2 中,游标要完成其上定义的计算,会主动做动作 i,从订单表中分批取出产品和订单金额字段。动作 ii,则是按照产品分组对金额求和,也就是对这批订单数据完成 compute1。游标不断重复这两个动作,直到遍历完成得到结果 result1。

对应的 SPL 代码是这样:


A

1

=file("orders.ctx").open()

2

=A1.cursor(area,product,amount)

3

=A2.groups(product;sum(amount))

A3 中定义了计算 compute1。

 

接下来,我们可以将游标完成的计算 compute1 看作一条主线,在此基础上对遍历进行复用,大致过程如下图 3:

..

                                            图 3 遍历复用

图 3 中,在游标计算 compute1 这条主线之外,我们增加了一条旁路,用来完成 compute2。游标在实施计算(即 compute1)的过程中,每次从订单表取出一批订单数据,如果发现有一个旁路,则把这份数据也送入这个旁路,即动作 iii,去进行 compute2 的计算,结果暂存起来。

我们把这个旁路(compute2 及保存的结果)称为一个与游标同步的管道。这样,在完成 compute1 计算后,游标也实施了对订单表的遍历。游标运算本身将返回 result1,旁路的管道中还可以得到 result2,需要的时候可以通过动作 iv 取出来。

 

我们将图 3 与图 1 相比发现,遍历复用的 CPU 运算量和遍历两次的运算量是一样的,都只要做两个小分组。但是,它的硬盘读取量要小很多,订单金额字段只被读了一次。

 

遍历复用对应的 SPL 代码是这样的:


A

1

=file("orders.ctx").open()

2

=A1.cursor(product,area,amount)

3

=channel(A2).groups(area;max(amount))

4

=A2.groups(product;sum(amount))

5

=A3.result()

相比只有游标的代码,增加了 A3 和 A5。

A3 用 channel 函数定义一个与游标 A2 同步的管道,并在其上设置了计算 compute2。

A5 是取出管道中暂存的结果,得到 result2。对应图 3 中的第 iv 步。

 

遍历复用机制对于所有游标都有效,并不只限于组表。

 

一个游标可以定义多个同步管道,同时附加多套计算。而且这种计算可以随意写,不仅限于分组,可以是任意的,也可以多个步骤。比如再增加一个 compute3,先过滤出金额在 50 以上的订单,然后再统计记录数。计算过程可以参考下图 4:

..

                                            图 4 增加计算 compute3

图 4 中,在原来一个游标、一个管道的基础上,又增加了一个旁路,用于计算 compute3。

 

增加 compute3 后,对应 SPL 的代码大致是这样:


A

B

1

=file("orders.ctx").open()

2

=A1.cursor(product,area,amount)

3

=channel(A2).groups(area;max(amount))

4

=channel(A2).select(amount>=50).total(count(1))

5

=A2.groups(product;sum(amount))

6

=A3.result()

=A4.result()

A4 用 channel 函数定义了第二个与游标 A2 同步的管道,并在其上设置了计算 compute3。

当 A5 中遍历游标计算 compute1 时,读出的数据也会同时送给管道 A4 完成 compute3,遍历结束后,管道 A4 中会保持相应的计算结果,在 B5 取出来即可。

 

实际上,游标本身的计算 computer1 也可以用管道完成,而游标本身仅仅做遍历。这样,多个计算的地位是等同的,代码也会显得更为对称:


A

B

1

=file("orders.ctx").open()

2

=A1.cursor(product,area,amount)

3

=channel(A2).groups(product;sum(amount))

4

=channel(A2).groups(area;max(amount))

5

=channel(A2).select(amount>=50).total(count(1))

6

=A2.skip()

=A3.result()

7

=A4.result()

=A5.result()

A3 到 A5 用 channel 函数定义了三个与游标 A2 同步的管道,并在其上设置了计算 compute1 到 compute3。

当 A6 中遍历游标时,读出的数据也会同时送给三个管道。遍历结束后,三个管道中会保持相应的计算结果,可以在 B6、A7、B7 中取出来。

 

这种情况比较常见,SPL 提供专门的语句式管道语法,上面的代码还可以写成这样:


A

B

1

=file("orders.ctx").open()

2

=A1.cursor(product,area,amount)

3

cursor A2

=A3.groups(product;sum(amount))

4

cursor

=A4.groups(area;max(amount))

5

cursor

=A5.select(amount>=50).total(count(1))

6



A2 游标创建后,用 A3 的 cursor 语句为其创建管道,然后在其上设置运算。可以创建多个管道,后面语句不再写游标参数则表示会复用同一个游标。

所有的 cursor 语句(的代码块)写完后,SPL 即认为整套管道定义结束,就会开始遍历游标,将每个管道的运算结果计算出来存放在 cursor 语句所在的格中。

 

这里 B3、B4、B5 分别定义 compute1、compute2、compute3,这些计算结果会分别放在 A3、A4、A5 中(注意不是 B3、B4、B5)。

 

 

遍历复用用于数据拆分

遍历复用思路可以应用在数据拆分上。比如有个大文本文件 data.txt,我们希望从中挑出正确的数据(满足给定条件)进一步做分析,这对游标使用 select 函数就可以了。同时,我们可能也想知道有哪些错误的数据(不满足条件)以便防止这种情况的再发生。但一次过滤不能同时把满足和不满足条件的记录分开,这时候就可以使用遍历复用技术了。大致的计算过程如下图 5:

..

                                            图 5 数据拆分

图 5 中,游标定义了 select 函数做条件过滤,也就是计算“correct”,得到正确数据 correct_result,输出到 result.btx。游标的管道定义了相反的条件,也就是计算“error”,得到错误数据 error_result。这里我们假定不满足条件的记录很少,能够在内存中放下。

对应 SPL 的代码是:


A

1

=file("data.txt").cursor@t()

2

3

=channel(A1).select(!(${A2})).fetch()

4

=A1.select(${A2})

5

>file("result.btx").export@b(A4

6

=A3.result()

在 A2 中填入过滤条件,A3 的管道将完成计算“error”,过滤出不满足条件的记录并取出 error_result。

A4 的游标完成计算“correct”,过滤出满足条件的正确记录。

A5 遍历游标把满足条件的记录写入新文件,对应图 5 中第 iv 步。

A6 则可以将管道结果取出来了,对应图 5 中的第 v 步。

 

不过,这样做还是需要把这个条件计算两次(满足和不满足要分别计算)。SPL 在 select 函数直接提供了这种方法,可以把不满足条件的记录同时取出来,不过只能写入另一个文件,也只能是集文件的格式。


A

1

=file("data.txt").cursor@t()

2

3

=A1.select(${A2};file("error.btx"))

4

>file("result.btx").export@b(A3)

 

类似地,还有可能把大数据表拆分成多个组,比如把订单记录按地区拆分成多个文件以便分发处理,也可以使用管道。不过,管道的数量是在代码中事先确定的,要事先知道总共分成多少份,而不能在分组过程中临时产生新的管道。

 

对于这种情况,SPL 在游标的序号分组中附加了拆分写入文件的功能,比如下面的代码将把订单分到 12 个月(月份容易序号化方便举例,其它情况读者可以自己做序号化)。


A

1

=file("orders.txt").cursor@t()

2

=12.(file("order"/~/".btx"))

3

=A1.groupn(month(dt);A2)

4

=A1.skip()

这里的 groupn 函数是个延迟游标,只是记录一下动作,在游标遍历过程中才会真地计算。事先要准备好相应数量的文件对象(A2)。和 select 类似,groupn 也只能写出集文件。