我们先来看一张资产负债表:
这是一个典型的中国式复杂报表格式,其复杂并不在于布局,而在于其中“期末余额”的每个单元格都是一个需要独立计算的指标,互相之间几乎没有关系,事实上就是一个各种指标的汇总清单,而这些指标往往会有上百个之多。
在源数据表结构中,有一个字段称为科目,其长度总是固定的 10 位,如:1234567890,如下图:
科目字段的值实际上是一个分层的代码,而前面表里上百个指标就是根据需求对不同层次科目数据的统计结果,具体的做法是通过截取科目的前几位来确定层次,然后按需求自由组合,作为条件进行过滤,最后对金额字段进行累计汇总。
比如计算指标 A 对应的科目列表是 [1001,1002],代表累计所有前 4 位是 1001、1002 的科目,用 SQL 写出来就是:select sum(金额 )from T1 where concat( 年, 月)<=? and (left( 科目,4)="1001" or left(科目,4)="1002")
其中年、月是公共过滤条件,代表统计的时间范围。
类似的,如果另一个指标 B 对应的科目为 [2702,153102,12310105], 那就代表对前 4 位是 2702、前 6 位是 153102、前 8 位是 12310105 的所有科目值进行累计,用 SQL 写出来就是:select sum(金额 )from T1 where concat( 年, 月)<=? and (left( 科目,4)="2702" or left(科目,6)="153102" or left(科目,8)="12310105");
实际业务中,每个指标对应的科目数量不定,可能多达 10 个以上,而且就像指标 B 这样,各个科目的层次也不尽相同。
在有了报表工具之后 (固定报表),原则上这类格式复杂、指标参数任意组合的报表需求并不难实现,只是原始数据量一大,查询响应就会非常慢,用户体验变差,当多并发请求时,还会对正常业务产生影响。
最常见的开发思路,就是按前面说的计算方式,对报表的每个指标都写一句完整的 SQL 来计算,有 100 个指标,就写 100 个 SQL。
有些报表工具提供了函数,可以直接在单元格中执行 SQL (比如 query/call 等),单元格的表达式大概会是这样:
= query("select sum(金额 )from T1 where concat( 年, 月)<=? and (left( 科目,4)='2702' or left(科目,6)='153102' or left(科目,8)='12310105')", concat(year,month))
如果是非多源报表工具,则可以借助外部程序数据源来实现,比如可以直接用集算器编写以下脚本:
A |
|
1 |
=connect("demo") |
2 |
=A1.query@1("select sum(金额 )from T1 where concat( 年, 月)<=? and (left( 科目,4)='1001' or left(科目,4)='1002')",concat(year,month)) |
3 |
=A1.query@1("select sum(金额 )from T1 where concat( 年, 月)<=? and (left( 科目,4)='2702' or left(科目,6)='153102' or left(科目,8)='12310105')",concat(year,month)) |
… |
… |
102 |
>A1.close() |
103 |
return [A2:A101] |
简单说明一下:
A1:连接数据库 demo
A2-A3:执行指标 A 的查询 SQL 和指标 B 的查询 SQL;其中 query() 函数中 @1 选项代表查询符合条件的第一条记录, 返回成单值或序列(一个字段是单值,多个字段是序列);这个例子是对金额汇总求和,所以返回单值。
A4-A101:假定有剩余的 98 个指标,每个指标的查询 SQL 都类似于 A2、A3 的写法
A102:关闭数据库连接
A103:合并 A2-A101 每个格子的计算结果 (共计 100 个指标值),返回一个单列数据集,供报表工具使用。
不过,在这种思路下,无论直接在格中使用 SQL 还是在程序数据源中计算,实际上每计算一个指标就得遍历一次源数据;而每个指标还对应多个需要 AND 的条件,这些都会严重降低性能。
这种思路的优点是简单直接,看上去确实能够实现需求,开发过程也并不太难。在数据量不大的情况下,查询也不会很慢,勉强还能接受。不过,随着数据量越来越大,性能瓶颈就会随之而来,到了一定程度后,就可能出现在关键时刻用户无法及时获得自己想要指标的问题,最终只能放弃。
上面“多次遍历方案”的问题在于,无论如何,对源数据遍历 100 次实在是太低效了,那么,我们有没有办法能少遍历几次呢? 能不能只做一次遍历就把所有指标都计算出来呢?
这种一次遍历的思路确实是可以的,我们只需要把 SQL 中的 WHERE 条件拼到 SELECT 中就行了,比如前面说到的指标 A 和 B 可以写成:
SELECT SUM(CASE WHEN (LEFT(科目,4)='1001' OR LEFT( 科目,4)='1002')THEN 金额 ELSE 0 END) 指标 A,
SUM(CASE WHEN (LEFT(科目,4)="2702" OR LEFT( 科目,6)="153102" OR LEFT(科目,8)="12310105")THEN 金额 ELSE 0 END) 指标 B,
…
FROM T1 WHERE CONCAT(年, 月 )<=?
但是,真要用这个思路来处理 100 个指标,可以想见这个 SQL 会有多长,维护难度会有多大。为此,我们可以利用集算器的游标来实现这个逻辑,适当降低维护难度。
另外,这种方案下的遍历,还是需要把整表数据读出数据库,而 JDBC 太慢,IO 时间很可能成为瓶颈。对于这个问题,我们注意到其中处理的都是不再变化的历史数据,那么我们就可以把数据先搬出数据库存成文件,然后用文件作为数据源,从而加快 IO 访问。具体实现如下:
1、把数据搬出数据库保存成文件,集算器的 SPL 脚本如下:
A |
|
1 |
=connect("demo") |
2 |
=A1.cursor("select 科目, 年, 月, 金额 from T1") |
3 |
=file("总账凭证 -pre.btx") |
4 |
>A3.export@b(A2) |
5 |
>A1.close() |
A1:连接数据库 demo
A2:根据 sql 创建数据库游标返回
A3:集文件保存的位置
A4:导出整表数据并保存到文件中,其中 export()函数的@b选项代表写入到集文件中,即总账凭证 -pre.btx
A5:关闭数据库连接
2、遍历一次源数据,计算 100 个指标,集算器的 SPL 脚本如下:
A |
B |
C |
|
1 |
=file("总账凭证 -pre.btx") |
/指标参数列 |
|
2 |
=A1.cursor@b() |
||
3 |
=A2.select(concat(年, 月 )<=concat(year,month)) |
||
4 |
for A3,10000 |
||
5 |
=@+A4.select(C5.contain(科目 \1000000)).sum( 金额) |
[1001,1002] |
|
6 |
=@+A4.select(C6.contain(科目 \1000000)||C6.contain( 科目 \10000)||C6.contain(科目 \100)).sum(金额) |
[2702,153102,12310105] |
|
… |
… |
… |
|
105 |
return [B5:B104] |
值得注意的是:这个例子引入了一个新的写法,100 个指标参数可以统一写到 C 列上,当 B 列每计算一个指标时,直接引用 C 列当前行的所对应的参数即可。比如:
C5:指标 A 的参数条件 (按科目号前 4 位截取的多个值形成的集合)
C6:指标 B 的参数条件 (按科目号前 4 位 / 前 6 位 / 前 8 位截取的多个值,形成的参数集合)
剩余的 98 个指标,计算的写法类似 B6,参数的写法类似 C6,依次类推到 100。
显然,这种计算逻辑和参数分离的写法,能够极大地提高可维护性。
下面我们完整地分析一下这段脚本:
A1:打开预处理前的原始数据表的集文件对象
A2:根据文件创建游标返回,其中 cursor() 函数使用 @b 选项代表从集文件中读取。
A3:在 A2 的基础上,先按公共条件年、月过滤出结果集中符合条件的记录,其中 year,month 是 SPL 脚本中定义的参数,接收来自报表前端传入的查询条件,比如查询 2017 年 01 月,日期范围是截止到某个时间点,所以需要利用 concat 函数对 year、month 连接起来再去做条件比较。
A4:循环游标,每次从游标读取 10000 条记录返回。
B5:代表指标 A 的金额累计汇总值;每次 for 循环,根据 C5 的参数选出符合条件的记录,用 contain()函数来判断参数是否在结果集中 ( 其中参数都是 4 位,所以需要对原记录中科目 \1000000 后保留科目的前 4 位,才能与参数进行比较),然后对金额进行累计汇总。其中的 @符号代表当前格的值,初始值为空,每次循环时将上次的值与本次符合条件的数据值相加,作为新值写入格中,最终可计算出某个指标的金额累计汇总。
B6:代表指标 B 的金额累计汇总值;与指标 A 不同的是,多个参数由不同的位数组成,所以需要在 contain()函数中分别截取不同的位数,与 C6 列的参数进行多次比较。
A105:合并 B5-B104 每个格子的值 (从上往下,100 个指标的计算结果),返回一个单列数据集,可以供报表工具使用。
现在我们已经做到了只需要遍历一次数据,但需要遍历的整体数据量仍然比较大,还有什么办法能进一步减少数据量呢?
如果能够把数据事先按科目汇总,那么我们就可以不必重复累加科目相等的记录了,而且存储量也会变少,IO 也会更快。
2.3.1 分组计算汇总值
首先,按照科目、年、月分组,金额进行汇总,汇总结果的数据结构应当是:科目、年、月、本科目下当月的金额汇总值。
集算器 SPL 脚本实现分组、汇总计算的样例如下:
A |
|
1 |
=file("总账凭证 -pre.btx") |
2 |
=file("总账凭证 -mid.btx") |
3 |
=A1.cursor@b() |
4 |
=A3.groupx(科目, 年, 月;sum(金额): 汇总金额 ) |
5 |
>A2.export@b(A4) |
A1:打开预处理前的原始数据表的集文件对象
A2:计算后中间结果数据的集文件保存的位置
A3:根据文件创建游标返回,其中 cursor() 函数的 @b 选项代表从集文件中读取
A4:先按照科目、年、月分组,金额汇总
A5:执行 A4 的计算结果写入到集文件中,其中 export() 函数使用了 @b的选项,@b代表写集文件格式,即总账凭证 -mid.btx
2.3.2 利用跨行组计算累计值
我们这个问题最终是要计算指标的期末值,也就是截止某个日期的金额累计值;上一步计算的是当月的金额汇总值,那金额的累计值该如何计算呢?
集算器提供了跨行引用的语法,可以用A[-1]代表上一行的 A,这样就可以计算:累计值 = 上一行的累计值 + 当前行值。
脚本中,接着上一步作如下修改即可计算累计值:
A |
B |
|
1 |
=file("总账凭证 -pre.btx") |
|
2 |
=file("总账凭证 -mid.btx") |
|
3 |
=A1.cursor@b() |
|
4 |
=A3.groupx(科目, 年, 月;sum(金额): 金额 ) |
|
5 |
for A4;科目 |
=A5.run(金额 = 金额 [-1]+ 金额 ) |
6 |
>A2.export@ab(B5,#1:科目,#2: 年,#3: 月,#4: 累计金额 ) |
其他格子的代码,在上面已经解释过了,这里不再赘述。
A5:利用 for 循环游标 A4,其中分号的参数“科目”表示每次从游标读取一组科目值相同的记录返回。我们先单步执行一下,返回某一个科目的记录:
再接着执行一次 for 循环,返回下一组科目的记录:
B5:针对取出的同一科目的记录,对金额累计;其中表达式:金额 = 金额 [-1]+ 金额,金额代表当前行金额,金额[-1] 代表上一行累计金额值,相加计算好后再重新赋值给金额字段。如下图是接着 A5 格子执行后的结果变化:
B6:执行计算后的结果写入到集文件中。其中 export() 函数使用了 @ab 的选项,@b代表写成集文件格式,由于在for循环里面,需要执行多次,所以用@a以追加的方式把结果逐步保存到文件中,保证文件的完整性,即总账凭证 -mid.btx。部分执行结果如下图:
2.3.3 构造多层科目汇总值
现在计算出了明细科目的累计值,我们还需要计算高层次科目(截取前 N 位)对应的汇总值。
从需求可以看到,每个计算指标都是按照科目截取前 4 位、前 6 位、前 8 位等作为参数集合,那么在构造不同层次的科目号时,也需要和这种规则匹配,从而计算出不同层次的聚合值。
比如:对于科目是 1234567890,那么就需要新增科目号 1234、123456、12345678 对应的汇总金额。也就是对于每个 1234567890 这样的 10 位科目号,还需要分别增加 4、6、8 位的科目 1234、123456、12345678。其中科目 1234 会把所有 1234 开头的科目的金额值进行累计汇总,依次类推。其实,这就是 CUBE 的常用手段。
需要注意的是,基于上一步计算结果,数据量大小又需要分两种情况讨论:
1、 结果已经可以全部直接读入内存参与下一步计算;
2、 结果依然很大,需要采用外存计算 (游标技术可以边读边算,多次计算还需要管道技术来配合)
2.3.3.1 内存计算
如果结果集可以全部装入内存,集算器 SPL 脚本构造多层次科目汇总值的样例如下:
A |
|
1 |
=file("总账凭证 -mid.btx") |
2 |
=file("总账凭证 -later.btx") |
3 |
=A1.import@b() |
4 |
=A3.groups((科目 \100): 科目, 年, 月;sum( 累计金额): 累计金额汇总 ) |
5 |
=A4.groups((科目 \100): 科目, 年, 月;sum( 累计金额汇总): 累计金额汇总 ) |
6 |
=A5.groups((科目 \100): 科目, 年, 月;sum( 累计金额汇总): 累计金额汇总 ) |
7 |
=[A6,A5,A4].conj() |
8 |
>A2.export@z(A7) |
A1:打开中间计算结果的集文件对象
A2:计算后的结果集文件保存的位置
A3:从文件对象 A1 中读出内容作为记录形成结果集返回;其中 @b 代表从集文件中读出。
A4:按科目截取前 8 位 (科目 \100)、年、月进行分组,累计金额进行汇总,如果截取前 7 位,就需要写成:(科目 \1000);具体按多少位截取由需求场景决定。执行结果如下图:
A5:在 A4 的结果集的基础上,按科目 \100 得到科目前 6 位、年、月进行分组,累计金额进行汇总;执行结果如下图:
A6:同理,在 A5 的基础上,按科目 \100 得到科目前 4 位、年、月进行分组,累计金额进行汇总;执行结果如下图:
A7:多个结果集合并成一个结果集
A8:计算后的结果集导出并保存到文件中,其中 export()函数使用了@z的选项,代表分段写入到集文件中,即总账凭证 -later.btx
2.3.3.2 外存计算(游标 + 管道)
在前面的例子中,我们已经使用了游标,需要特别强调的是游标只能从前向后单向移动,执行一次遍历计算,只有最终生成的游标中的 cs.fetch() 函数才能够有效取得数据。遍历结束后,计算过程中产生的其它游标都将不能再次读取数据。
不过有时候,在一次读取数据的过程中,我们需要同时计算出多个结果,那么此时就需要使用与游标类似的管道,用 channel(cs) 建立管道将游标 cs 的数据在遍历同时压入管道以便实施其它运算。
和内存相比,外存速度慢很多,因此要尽量减少硬盘访问,所以,我们采用游标 + 管道的机制一次遍历获得需要的汇总结果:
A |
|
1 |
=file("总账凭证 -mid.btx") |
2 |
=file("总账凭证 -later.btx") |
3 |
=A1.cursor@b() |
4 |
=channel(A3).groupx((科目 \100): 科目, 年, 月;sum( 累计金额): 累计金额汇总 ) |
5 |
=channel(A3).groupx((科目 \10000): 科目, 年, 月;sum( 累计金额): 累计金额汇总 ) |
6 |
=A3.groupx((科目 \1000000): 科目, 年, 月;sum( 累计金额): 累计金额汇总 ) |
7 |
=[A6,A5.result(),A4.result()].conjx() |
8 |
>A2.export@z(A7) |
A1-A3:前面已经解释过了,这里不再赘述。
A4:创建管道,将游标 A3 中的数据推送到管道,其中 ch.groupx() 函数针对管道中的有序记录分组并返回管道;按科目截取前 8 位、年、月进行分组,累计金额进行汇总
A5:同理于 A4 返回管道,按科目截取前 6 位、年、月进行分组,累计金额进行汇总
A6:返回游标,按科目截取前 4 位、年、月进行分组,累计金额进行汇总
A7:多个游标运算结果合并成一个结果集;其中 ch.result() 代表管道的运算结果
A8:计算后的结果集导出并保存到集文件,即总账凭证 -later.btx
2.3.4 优化“一次遍历”的方案
经过上面两步数据预处理,结果数据可以直接作为报表的数据源,每个指标的计算条件只要相等比较就可以,而不再需要截取、计算前几位了。
所以在前述“一次遍历“方案的基础上,我们来做一些优化;集算器的 SPL 脚本样例如下:
A |
B |
C |
|
1 |
=file("总账凭证 -later.btx") |
/指标参数列 |
|
2 |
=A1.cursor@b() |
||
3 |
=A2.select(concat(年, 月 )<=concat(year,month)) |
||
4 |
for A3,10000 |
||
5 |
=@+A4.select(C5.contain(科目 )).sum(累计金额汇总) |
[1001,1002] |
|
6 |
=@+A4.select(C6.contain(科目 )).sum(累计金额汇总) |
[2702,153102,12310105,1122,12310101,12310401,12319001,12310201,12310301,12310501,12310601,12310701,12310801,12319101] |
|
… |
… |
… |
|
105 |
return [B5:B104] |
A1-A4:前面已经解释过了,这里不再赘述。
B5:代表指标 A 的累计金额的汇总值求和;每次 for 循环,根据 C5 的参数选出符合条件的记录,用 contain() 函数来判断参数是否在结果集中,然后对累计金额汇总进行求和。其中的 @符号代表当前格的值,初始值为空,每次循环时将上次的值与本次符合条件的数据值相加,作为新值写入格中,最终可计算出某个指标的累计金额汇总的求和值。
B6:同 B5 的写法,代表指标 B 的累计金额汇总值求和,通常集合元素个数超过 13 个时,如果事先能对常数集合排序,那么可以选择 contain() 函数的 @b 选项,利用二分查找会明显快于顺序查找。
A105:合并 B5-B104 每个格子的值 (从上往下,100 个指标的计算结果),返回一个单列数据集,供报表工具使用。
至此,我们可以看到,按照预先汇总的思路,事先根据数据特征对数据进行预处理,可以让总的数据量变小,同时减少遍历量,从而避免前述方案中总是从最底层再去累加的模式。经过实测:从报表取数到报表展现整个环节比“常规”方案足足提高了6-8倍左右,这样的体验已经可以很好地满足用户要求了。
那么,是否有更好的优化方案呢?答案是肯定的!请看:多层科目任意组合汇总报表的性能优化 (下)