【摘要】
关联计算在集合运算中的复杂度无出其右,在报表业务中经常引发查询性能问题,如何优化是大家经常面临的难题!这里《如何优化多数据集关联报表》尝试列举各种报表关联情况,并给出优化方案,提升多数据集关联报表性能看这一篇就够了!
多数据集关联报表是很常见的报表形式,它允许开发者分别从不同的来源(表或数据库)分别准备数据形成不同的数据集,在报表端(模板)通过表达式描述数据集间的关系完成关联。这样可以避免在数据准备时写过于复杂的 SQL/ 存储过程,降低维护难度。尤其当报表数据来源于多个数据库时,多数据集的优势更加明显。
凡事都有两面性,多数据集为开发带来方便的同时却对性能造成了极大的影响。在报表端进行多数据集关联时要计算关联表达式(举例:d(name,,id==A1))时,报表引擎一般会采用顺序遍历的方式进行,先拿一个数据集的第一条记录去第二个数据集中遍历查找符合条件的记录,然后是第二条,第三条…。因此两个数据集关联的时间复杂度是 O(n²),数据量不大时感受还不明显,数据量稍大一些就会很慢,随着数据集数量的增多报表性能也会呈指数下降。
因此在实际报表业务中,当多数据集关联导致报表性能降低时可以考虑将多个数据集 SQL 合并成一句,利用数据库的关联计算能力提升性能。但这种方式又会导致 SQL 过于复杂,很难维护,而太复杂的 SQL 很可能被数据库搞错优化路径,结果性能仍不可控。并且合并 SQL 的方式有适用场景的限制(如无法完成跨异构库关联、文本关联等)。
下面介绍采用集算器的优化方法,写法简单且性能高,能够普遍适用于各种场景:
- 单数据库,多个数据集 SQL 比较复杂,很难写成一句
- 单数据库,多数据集中使用了存储过程,无法整合成一句 SQL
- 单数据库,多数据集合并成一句 SQL 后性能仍不如人意
- 多数据库,多数据集来源多个数据库,无法通过一句 SQL 进行查询
- 涉及文件数据,多数据集中部分数据来自文件,无法使用 SQL 进行统一查询
不同于 SQL(关系代数)采用笛卡尔积再过滤的方式看待 JOIN,基于离散数据集模型的集算器将关联运算做了区分(只考虑等值 JOIN):多对一的主外键表采用外键属性化方式关联、一对一的同维表采用同维表等同化方式关联、一对多的主子表采用主子表一体化关联,针对不同的表间关系采用不同算法进行运算,可以获得更简单的写法和更高的性能以及更广泛的适用范围。
我们将通过一些示例来说明面向各种情况时,如何使用集算器获得最优的实现和效率。需要说明的是,为了描述方便我们使用抽象后最简单的情况说明各种关联运算,实际业务会复杂得多,每个数据集 SQL 也会复杂得多,但是不管怎样多数据集关联关系也逃不出多对一、一对一和一对多的情况,所以拿原子操作来说明问题,以期大家遇到问题时可以采用最合适的方式处理。
报表集成
这里假定读者已经了解集算器与报表的关系,集算器仅为报表提供数据准备,将原来的多数据集通过集算器完成关联计算,将计算以结果以单 / 多数据集的方式提供给报表进行呈现。
集算器脚本可以直接被润乾报表 5.0 及以上版本直接引用(集算器数据集);如果是其他报表工具,集算器提供了标准 JDBC 和 ODBC 接口,可以采用类似调用存储过程的方式调用集算器脚本,详细可以参考教程《应用集成 - 被 JAVA 调用》章节,以及《集算器与 BIRT 集成》或《集算器与 JasperReport 集成》。
因此下面大部分例子将省略报表制作部分,主要说明集算器处理多数据集关联计算的过程。
外键表(多对一)
表 A 的某些字段与表 B 的主键关联。A 表称为事实表,B 表称为维表。A 表中与 B 表主键关联的字段称为 A 指向 B 的外键,B 也称为 A 的外键表。外键表是多对一的关系,且只有 JOIN 和 LEFT JOIN,一般不会用到 FULL JOIN。如:订单表和客户表
Orders 表和 Customer 表的主键都是其中的 id 字段,Orders 表的 customerID 字段是指向 Customer 表的外键。
这里说的主键是指逻辑上的主键(下同),也就是在表中取值唯一的字段(组),一个表上可能有多个字段(组)都取值唯一(并不常见),可以认为都是主键。不是一定是在物理表上建立的那个主键。
单外键举例
报表中有两个数据集,数据分别来自订单信息表(Orders)和客户表(Customer)(实际业务中可能是两条复杂 SQL),订单表的客户 ID 指向客户表的主键客户 ID,属于典型的主外键关系。
【计算目标】 查询某时间段内订单和客户详单
集算器数据准备
单库情况
当两个数据集来源于单个数据库,数据集 SQL 比较复杂不易合并时,通过集算器实现多对一关联计算,脚本如下:
脚本解析:
1、前 3 行连接数据库后分别取订单和客户数据作为两个独立数据集(事实上 A2 和 A3 的 SQL 可以任意复杂,取数阶段无需将两条 SQL 合并,分别查询即可);这里为了说明指针与记录,将两个表所有字段都选出,实际业务中应该用哪些字段取哪些。
2、A2 中使用了脚本参数 begin 和 end 来接收起止时间范围
3、注意 A3 的 query 函数使用了 @x 选项,代表查询后关闭连接,使用完数据库连接一定要及时关闭(也可以通过 Aclose() 显示关闭数据库连接)
4、A4 中通过 switch 函数在 A2 订单表的客户 ID 字段上建立指向客户表记录的指针实现关联
5、A5 利用建立关联关系通过“外键字段. 维表字段”的方式进行引用,如“客户 ID: 客户名称”,将维表记录看做外键的的属性,这便是外键属性化的由来;
6、A5 为报表返回关联后结果集
关于 switch 函数
在 SQL 的概念体系中并不区分外键表和主子表,多对一和一对多从 SQL 的观点看来只是关联方向不同,本质上是一回事。比如,订单也可以理解成订单明细的外键表。但是,集算器把它们区分开,在简化语法和性能优化时使用不同的手段。
switch 是集算器中实现多对一关联的函数,通过建立事实表和维表之间的外键指针实现连接。其原理是通过 HASH 算法在外键字段上建立指向维表记录的指针,这样在建立关联的时间与数据库中最快的关联方式 HASH JOIN 一样,但接下来使用连接结果时就不需要再查找 HASH TABLE,直接通过指针定位到内存中的维表记录。
建立外键指针后外键字段的原值不再存储,而被转化为指向维表记录的指针,所有维表字段都可以通过“外键字段. 维表字段”方式引用,因此 switch 函数只适合做单外键的关联(原外键字段值变了),多外键关联时需要使用 A.join 函数(后面会说明多外键情况)。
指针式连接的意义在于一次建立多次使用,重复使用时由于无需再建立连接性能高效得多。如上述例子中,除了获取订单和客户详单,还想针对客户所在区域汇总订单数量,那么可以写成这样(B5 格):
B5 的计算继续使用了在 A2 客户 ID 字段上建立的指针,而无需重新建立关联。实际应用中,指针式关联建立后,重复使用次数越多性能优势越明显。
在报表中复用连接,计算不同的结果集多用于分片报表,分片报表在报表业务中并非很常见,但也不算罕见,不过对应业务都比较复杂,不大合适举例,这里就不细说了。当遇到报表分片且有相同关联情况时可以考虑使用集算器进行连接复用。
多库情况
前面提到多库尤其是异构多库情况下无法利用 SQL 做关联计算,在报表中计算性能又低,这时非常适合使用集算器来做。下面假设订单和客户表分别来源两个不同数据库 db1 和 db2,计算目标仍然是:查询某时间段内订单和客户详单,来看集算器的具体写法。
注意到和单库情况的区别了吗?
多库情况只需要在脚本中建立多库的连接(A1 和 B1)分别执行 SQL 查询(A2 和 A3),剩下的运算和单库完全一致,轻松实现基于多库的关联计算。
事实上,集算器(脚本)还非常利于应用移植和数据库扩展,当底层数据库发生变化或者由单库拆分成多库时,只需更改数据库连接,主要的计算逻辑完全不用改。更进一步,如果连接信息也维护在配置中,则可以写出更加通用的脚本做到系统扩展时脚本无缝移植。
涉及文本
集算器作为开放计算引擎提供了多数据源支持,除了关系数据库外,本地文件(Excel、TXT、CSV、JSON/XML)、NoSQL、Hadoop 等也可以直接作为数据源参与运算。因此如果报表中有数据来源于文本、Excel 等文件,可以通过集算器直接处理(SQL 就无能为力了)。
沿用上面的例子,假设客户信息来源于 TXT,计算目标仍然是:查询某时间段内订单和客户详单。来看集算器的写法。
涉及到文本有什么变化吗?只将 A3 改为读取文件数据即可,核心计算逻辑仍然没有变化。
上面我们通过多对一的两个表对单库、多库和文件三种情况进行说明,报表遇到相应问题可以使用集算器处理。实际业务中还可能涉及多层外键情况,即多表外键关联。
多层外键关联举例
报表中有三个数据集,数据分别来自订单信息表(Orders)、客户表(Customer)和地区表(Area),订单表的客户 ID 指向客户表的主键客户 ID,客户表的所在区域指向区域表的主键区域 ID。
【计算目标】 查询某时间段内订单及其客户与所在区域详细信息
集算器数据准备
脚本解析:
1、A2-B4 分别查询订单、客户和地区数据
2、A4 中通过 switch 函数在 A3 所在区域上建立指向地区记录的指针实现关联
3、同理,B4 在 A2 订单表的客户 ID 字段上建立指向客户表记录的指针实现关联,这里得到了一个三层结果的集合
4、A5 通过外键属性化的方式引用区域和客户信息,可以看到无论有多少层外键都可以通 过 [点](.)的方式作为外键属性引用
在实际业务中很常见的星型结构还会涉及到同一个事实表和多个维表进行关联,不同于传统的 HASH 分段 JOIN 方案,集算器无需两两消除、多次遍历,通过遍历一次事实表即可完成与多个维表的关联,非常高效,适合多数据库表关联性能低下需要改善的场景。
下面以一个事实表与两个维表关联说明多维表情况下集算器处理方式。
关联多个维表举例
订单信息表(Orders)与客户表(Customer)、雇员表(Employee),订单表的客户 ID 指向客户表的主键客户 ID;销售 ID 指向雇员表的员工 ID
【计算目标】 按客户所在区域和销售人员汇总订单金额
集算器数据准备
集算器实现脚本:
在 A5 中通过 switch 将订单信息同时与客户表和雇员表关联,客户 ID 和雇员 ID 分别指向对应维表的记录
这里可以看到,通过遍历一次订单表就关联了客户和雇员,当外键关联较多时使用 switch 更加简单高效。相反,在写 SQL 关联多个表时,偶尔会出现漏写 join 条件导致数据库被跑死的情况,而集算器则完全避免了这种情况。
多外键情况举例
单外键下无论是数据来源数据库或是文件均可使用 switch 进行处理,实际业务中还可能存在多外键的情况。报表中有两个数据集分别来自学生表(Students)和班级表(Classes),学生表的专业号和班级号为外键字段,分别指向班级表的联合主键(专业号,班级号)。
【计算目标】 查询所有学生的学号,姓名,专业,班级,班主任
集算器数据准备
脚本解析:
1、A3 查询班级数据,并通过 keys 设置班级的主键为专业号和班级号;
2、A4 使用 A.join() 函数进行双主键关联,将班主任信息添加到学生信息中,形成目标结果集
与 switch 处理单外键关联不同,当出现多外键的情况下需要使用 A.join 完成关联。
同维表(一对一)
表 A 的主键与表 B 的主键关联,A 和 B 互称为同维表。同维表是一对一的关系,JOIN、LEFT JOIN 和 FULL JOIN 的情况都会有,如:员工表和经理表。
两个表的主键都是 id,经理也是员工,两表共用同样的员工编号,经理会比普通员多一些属性,另用一个经理表来保存。
单主键举例
报表中有三个数据集,分别来自回款表(OrderPayment)、客户表(Customer)和订单表(Orders),回款表的客户 ID 指向客户表主键客户 ID,订单表的客户 ID 指向客户表主键客户 ID。
【计算目标】 按客户(所有)查看某时间段订单总额和回款总额
这是很常见的一类报表,按照某个维度(如地区、日期、人员)汇总多个指标(如订单额、回款额),但我们发现报表的三个数据集之间并不是像销售表和员工表(主键都是人员 ID)那样互为同维表,不过结合计算目标分析一下,由于一个客户会有多笔订单和回款记录,因此需要对两个表分别按照客户 ID 分组后(结果集以客户 ID 为主键)向客户表主键客户 ID 对齐,显然三个集合是一组以客户 ID 为主键的同维表。
集算器数据准备
脚本解析:
1、A2 和 A3 针对订单和回款数据分别按照客户 ID 进行分组汇总;
2、A5 按照客户表左关联(@1 选项代表左连接)订单和回款数据
3、A6 获得关联结果返回报表数据集
这里关注一下 join 函数(上述例子 A5=join@1(A4: 客户, 客户 ID; A2: 订单, 客户 ID;A3: 回款, 客户 ID)),可以看到 join 的各个表之间看起来似乎是无关的,在集算器中关联时无需关注表间关系,只需要同时向某一个维度(如客户维度)对齐即可,这样在关联表增多或减少时修改非常方便。如果是 SQL 的写法必须指定两个表的关联条件,关联的表数量太多时就容易漏写一两个条件导致出现叉乘算错的情况,如果漏写条件的表比较大,还容易把数据库跑死;集算器的 join 则避免了这种情况。
另外,从上述例子来看当涉及多个事实表同时向维表对齐汇总时,一定要先 group 再 join,如果先 join 再 group 就会算错,写成 SQL 应该是维表和有两个 group by 的子查询 join。
多主键情况举例
与多外键情况类似,当同维表采用联合主键时就会存在多主键同维表关联的情况。报表中有两个数据集,分别来自回款表(OrderPayment)和订单表(Orders),两个表没有关联关系。
【计算目标】 按客户和年份汇总回款金额和订单金额
这两个表直接并没有关联关系,但经过同样两个维度分组汇总后,就形成了两个以客户和日期为主键的同维表
按照计算目标,要同时获得回款金额和订单金额,需要将两个表进行关联计算。
集算器数据准备
脚本解析:
1、A2 和 A3 分别查询订单和回款数据,并按客户和年份汇总订单额和回款额;
2、A4 通过全连接对齐带有两个主键(客户 ID, 年份)的结果集
3、A5 根据关联结果返回报表数据集
同维表与外键表混合
在实际业务中还经常能见到同维表和外键表混合使用的情况,集算器处理起来仍然简单高效。
举例
沿用上述单主键同维表的例子,现在还有一张地区表(Area),客户表外键字段所在区域指向区域表主键区域 ID。
【计算目标】 按客户所在区域和客户查看某时间段订单总额和回款总额
分析后仍然得到下面的同维表,只不过客户表外键字段所在区域又指向了地区表,出现了同维表和外键表混合的情况。
集算器数据准备
脚本解析:
1、A6 在 A4 客户信息中建立外键关联
2、A7 关联后结果可以看到集算器的结果集可以是任意多层结构
3、A8 通过外键属性化方式引用区域名称,为报表返回结果集
主子表(一对多)
表 A 的主键与表 B 的部分主键关联,A 称为主表,B 称为子表。主子表是一对多的关系,只有 JOIN 和 LEFT JOIN,不会有 FULL JOIN,如:订单和订单明细。
Orders 表的主键是 id,OrderDetail 表中的主键是 (id,no),前者的主键是后者的一部分,订单表是主表,订单明细表子表。从子表去看主表,与前述提到的外键表非常类似,只是外键表不要求外键字段是主键,因此从子表角度观察表间关系可以将主子表看做外键表的特殊情况,所以有时也可以采用外键表处理关联的方法(switch)。
主子表关联计算在报表中并不常见,即使有,多数情况下可以转换成将主表作为外键表关联,或者子表 group 后变成同维表处理。当主表作为外键表处理时,除了可以用到外键表(多对一)switch 的处理方式,还可以通过 join 实现。除了 join 可以关联多外键情况,当关联的两个结果集按照关联字段有序时还可以使用归并算法,性能比 switch 更高(数据量不大时优势并不明显)。
子表关联主表将主表作为外键表可参考前述外键表中 < 单外键举例 >,子表 group 后变成同维表关联的例子可参考前述同维表中 < 单主键举例 >。
下面介绍一种有序归并实施关联计算的方法,读者在对主子表(包括同维表)进行关联计算时也可选用,以获得更高性能。
主子表有序归并举例
报表有两个数据集,分别来自订单表(Orders)和订单明细表(OrderDetails)
订单表主键与订单明细表部分主键关联,订单表是主表,订单明细表是子表。现在两个表都按照订单 ID 有序。
【计算目标】 查询某时间段内客户订单明细
集算器数据准备
脚本解析:
1、A2-A3 分别查询订单和订单明细数据,结果集按订单 ID 有序
2、A4 通过有序归并算法(@m 选项)对两个集合按照订单 ID 关联
3、A5 获得关联结果为报表返回结果集
有序归并可以极大提高关联效率,下面简单解释一下。
设两个关联表的规模(记录数)分别是 N 和 M,则 HASH 分段技术的计算复杂度(关联字段的比较次数)大概是 SUM(Ni*Mi),其中 Ni 和 Mi 分别是 HASH 值为 i 的两表记录数,满足 N=SUM(Ni) 和 M=SUM(Mi),这大概率会比完全遍历时的复杂度 N*M 要小很多(运气较好的时候会小 K 倍,K 是 HASH 值的取值范围)。
如果这两个表针对关联键都有序,那么我们就可以使用归并算法来处理关联,这时的复杂度是 N+M;在 N 和 M 都较大的时候(一般都会远大于 K),这个数会远小于 SUM(Ni*Mi),这就是有序归并的好处。
润乾报表层次数据集
通过这些例子,集算器为报表准备数据时最终返回的均为标准 ResutSet,这就经常需要将集算器的分层结构(如 switch 和 join 后结果集)转换成标准的二维表,虽然转换工作不复杂,但如果能直接使用分层结果集会更加简单高效。
润乾报表 5.0 及以上版本就支持直接使用带有层次的数据集进行数据呈现。沿用订单和订单明细的主子表结构。
【报表展现目标】
报表每个单元上面是订单信息(单条),下面是明细信息(多条),属于典型的主子报表。
集算器数据准备
脚本解析:
1、B3 将订单明细按照订单 ID 分组,得到分组子集(保留分组成员)
2、A4 订单关联订单明细,一条订单信息对应多条明细
3、A5 生成报表可以接收的多层结果并为报表返回数据集
润乾报表设计
设置参数
打开报表设计器,新建报表后设置查询参数
设置数据集
设置报表数据集,选择集算器数据集类型,添加上述准备好的集算器脚本文件,并设置报表参数与集算器脚本参数对应
数据集设置后,在报表设计器右下角的数据集窗口中即输出层次结构如下:
编写报表表达式
直接使用集算器提供的层次结果集设置报表表达式,其中设置 B4、B5、B6、B7、B8 左主格为 C4(按照订单扩展)
通过以上步骤即可完成基于层次数据集的报表设计,目前只有润乾报表提供了层次数据集支持,在制作主子表、分组明细报表时就可以在数据准备(数据集)阶段将数据准备好,然后为报表返回带有层次的数据集,报表直接引用无需再次关联或分组,可以带来更高的报表性能。
子表有序计算举例
区分主子表后,如果从主表观察子表常常会涉及分组子集和有序运算,这时用集算器处理就非常方便了。举一个并不十分常见的例子,读者可以感受一下。
报表有三个数据集,分别来自回款表(OrderPayment)、订单表(Orders)和订单明细表(OrderDetails)。
订单表的主键是订单 ID,回款表的主键是(编号,订单 ID),订单明细表的主键是(编号,订单 ID),订单的主键是回款和订单明细的一部分,订单表是主表,回款表和订单明细表是子表。
【计算目标】 统计每个客户的每个订单中,最大和最小两笔回款,最高和最低两个价格
这里并不是计算汇总值,而是要找出每个客户的每笔订单中回款金额最大和最小的两笔回款,以及每笔订单中订购产品最高和最低的两个价格,用以识别客户类型及其回款能力。
集算器数据准备
脚本解析:
1、A2 查询订单数据,按照客户和订单排序
2、A3 查询回款数据,按照订单排序,回款金额降序
3、B3 按照订单 ID 分组,由于要查找分组成员(最大和最小值),所以这里需要使用 group 函数分组并保留分组结果(不聚合)
4、C3 找出每组中回款金额最大和最小两条记录
5、同理 A4-C4 按照订单分组查找每组中价格最高和最低两条记录
6、A5 根据客户和订单信息左关联上述两个结果集(注意:关联一定要在前面两个分组后进行,如果先关联则会出现多对多叉乘,导致结果错误)。
每一条关联结果,订单只有一条记录,回款和订单明细则包含两条记录,这是主子表关联关联计算的特点,主表的一条记录指向子表的多条记录
7、根据关联结果,生成最终结果集,并为报表返回结果集
以上通过集算器关联运算解决了多数据集关联报表的性能问题,实测中报表性能可获得数倍到数百倍的提升(随数据规模和关联表数量线性增长)。同时集算器解决方案实现比较简单,适用范围更广,适用于数据库无法完成的跨异构库关联、文本关联等情况,从而为报表性能优化、降低报表应用耦合性提供了新思路。