新手如何使用 SPL 存储
用 SPL 实现高性能计算,通常要将数据转存成 SPL 的格式。初次接触 SPL 的程序员对此比较陌生,参考本文可以快速上手,完成常见数据转存的工作。
这里给出了适合一般场景的样例代码,特殊情况或者需要了解更多细节时,请查阅 SPL函数参考、学习教程等。
SPL 文件存储
SPL 的数据存储在文件中,并没有数据库的数据表等概念。对于初学者来说,可以先简单地将一个文件理解为一个数据表。随着对 SPL 理解的深入,就可以设计出更灵活的存储方案。
SPL 对于大表和小表采取不同的存储方式,我们先要对表的大小加以区分。小表是指数据量不大,内存能够全部装下的表。比如:部门表、地区表、雇员表等。而大表的数据量很大,内存无法全部装下。比如:订单表、交易明细表、日志表等,这些表一般都有大量的历史数据,而且随着时间推移还将不断产生新增数据。
集文件和组表
SPL 有集文件和组表两种文件格式,后缀分别是 btx 和 ctx。
集文件采用简单的二进制格式,不能设置主键,可追加数据,但是不能插入、修改和删除。
组表性能更好,是 SPL 的大数据存储方式,可以设置主键,也可以不设置。能够追加数据,在设置了主键时也可以少量修改、插入和删除,但不支持大量更新。
这两种文件格式主要用于存储几乎不再变动的历史冷数据,不适合存储还在经常变动的热数据。SPL 目前还没有发布 OLTP 能力,实现 HTAP 需求可以使用冷热混合计算的结构,有兴趣可参考乾学院上的资料。
实际应用中,通常用集文件存储小表,用组表存储大表。这样做,主要是因为大表对性能的影响很大,存储成组表有利于提升系统整体性能。而且,组表头部有个索引块,即使只有一行数据也要至少占一个块,列存会占得更多。索引块对于大数据来说可以忽略,但对小数据来说就不合适了。集文件创建和使用都更简单,用来存储小表会很便捷,也不会因为索引块而降低存储效率。
将小表从数据库读出,写入集文件的代码大致是这样:
A |
|
1 |
=connect("demo") |
2 |
=A1.query("SELECT AREAID,AREANAME FROM AREA ORDER BY AREAID") |
3 |
=file("area.btx").export@b(A2) |
4 |
=T("area.btx") |
5 |
>A1.close() |
A1 连接数据库,A2 取出地区表数据,A3 写入集文件,A4 从集文件中读出数据进行检查。
从代码中可以看到,生成集文件时不需要事先指定数据结构,SPL 会自动使用待写出数据集的数据结构。
将大表从数据库转出存储为组表时需要考虑的问题较多,我们将在下面的内容中讲解。
行存和列存
SPL 组表支持行存和列存两种方式,要在创建时确定,这取决于对数据的计算主要是遍历还是查找。遍历是指从大表中读取大量数据(甚至是全部)进行计算,比如对多年的订单数据分组汇总。查找则是在大表中找出少量数据,比如在订单表查找某个订单、在交易表中找到某一笔交易。遍历计算一般要使用列存,特别是表的字段很多,经常参与计算的字段却很少的情况,列存的优势更加明显。如果是查找计算,则使用行存通常更有优势。
对于遍历和查找性能要求都很高的场景,可以将数据存储成两份,列存用于遍历,行存用于查找。不过,这种行列共存方案的数据要冗余两遍,占用的硬盘空间会比较大。
将数据库表存成列存组表的代码大致是这样的:
A |
|
1 |
=connect("demo") |
2 |
=A1.cursor("select ORDERID,CUSTOMERID,EMPLOYEEID,AREAID,AMOUNT,ORDERDATE from ORDERS") |
3 |
=file("orders.ctx").create@y(ORDERID,CUSTOMERID, EMPLOYEEID,AREAID,AMOUNT,ORDERDATE) |
4 |
=A3.append(A2) |
5 |
>A1.close(),A3.close() |
6 |
=file("orders.ctx").open().cursor().fetch(100) |
A2 中使用数据库游标分批取数,这是因为大表数据量太大,全部读入会造成内存溢出。
A3 创建组表,@y 选项的意思是如果有同名文件则直接覆盖。因为没有设置主键,所以这个组表只能追加数据,不能插入、修改或者删除。
A4 向组表中追加 A2 游标的数据,注意两者的字段名称和顺序要完全一致。
B4 关闭数据库连接和组表。
A5 打开组表,建立游标,取出 100 条数据检查一下。
从代码中可以看出,组表必须事先创建数据结构,然后才能写入,它的使用比集文件要复杂。
如果需要存成行存组表,只要修改 A3,为 create 函数增加 @r 即可,大致是这样:
=file("orders.ctx").create@ry(ORDERID,CLIENT,SELLERID,AMOUNT,ORDERDATE)
有序存储
有序存储对提高遍历、查找计算性能都有非常大的作用,通常有按时间有序或按帐号有序两种情况。
按照时间有序存储,用时间条件做过滤计算时可以使用二分法提高性能,对按日期分组统计提速也很有效。将订单数据按照订单日期有序存入组表的代码大致是这样:
A |
|
1 |
=connect("demo") |
2 |
=A1.cursor("select ORDERDATE,ORDERID,CUSTOMERID,EMPLOYEEID,AREAID,AMOUNT from ORDERS order by ORDERDATE") |
3 |
=file("orders.ctx").create@y(#ORDERDATE,ORDERID,CUSTOMERID, EMPLOYEEID,AREAID,AMOUNT) |
4 |
=A3.append(A2) |
5 |
>A1.close(),A3.close() |
A2 取出数据时,是按照订单日期 ORDERDATE 排序的。
A3 建立组表,用 #指明了组表对订单日期有序,有序字段必须放在最前面。
A4 将数据按照订单日期顺序追加到组表中。
集文件也可以有序存储:
A |
|
3 |
=file("orders.btx").export@b(A2) |
这里是将订单数据按照日期有序存入集文件,在按照日期条件过滤时可以采用二分法计算。
对于按时间有序的数据,定期新增数据的时间通常是晚于已有数据的。我们可以将新增数据也按照时间排序后,直接追加到数据的最后。追加的代码大致是下面这样:
A |
B |
|
1 |
=file("orders.ctx").open() |
=connect("demo") |
2 |
=B1.cursor("select ORDERDATE,ORDERID,CUSTOMERID,EMPLOYEEID,AREAID,AMOUNT from ORDERS where ORDERDATE=?",date(now())) |
|
3 |
=A1.append(A2) |
>A1.close(),B1.close() |
A2 从数据库中取出当天的新增数据。
A3 直接追加到组表的最后。
集文件也是类似,只要将 export@b 改为 export@ab 就可以,@a 代表在文件末尾追加数据。
帐号有序也是很常见的情况,帐号一般是主键或主键的一部分,按帐号有序本质上也就是按主键或者部分主键有序。按账号有序,对于查找指定帐号的全部交易数据、按照账号分组汇总(去重),或者对每个帐号做复杂计算(例如用户行为分析),都会有很大的提速作用。
集文件很少用于这种场景,这里我们只以组表举例了。修改上面按时间有序的代码,让新建的组表按照客户编号 CUSTOMERID 有序,大致是这样:
A |
|
1 |
=connect("demo") |
2 |
=A1.cursor("select EMPLOYEEID,ORDERDATE,ORDERID,CUSTOMERID, AREAID,AMOUNT from ORDERS order by EMPLOYEEID") |
3 |
=file("orders.ctx").create@y(#EMPLOYEEID,ORDERDATE,ORDERID,CUSTOMERID, AREAID,AMOUNT) |
4 |
=A3.append(A2) |
5 |
>A1.close(),A3.close() |
A2 中改为按照客户排序,并将客户字段放到最前面。
A3 创建组表时,也要将客户字段放到最前面,而且要加 #指明有序。
按照帐号有序时,新增数据就不能在已有数据后面直接追加了。因为新产生数据中的帐号还是同样的一批值,直接追加会破坏帐号的有序性。如果每次有新增数据都重新排序所有数据再生成组表,则会耗时太长。
SPL 在组表上提供了避免每次新增时重新排序所有数据的方法,多次累积更新后再统一做一次重新排序,这样可以减少每次更新数据的时间。而且排序可以使用有序归并手段,也比常规大排序效率好很多。详情可以参考SPL 的有序存储。
例如,对于按照客户编号有序的组表,每天新增数据的代码大致是这样:
A |
B |
|
1 |
=file("orders.ctx") |
=connect("demo") |
2 |
=B1.cursor("select EMPLOYEEID,ORDERDATE,ORDERID,CUSTOMERID, AREAID,AMOUNT from ORDERS where ORDERDATE=? order by EMPLOYEEID",date(now())) |
|
3 |
if (day(now())==1 |
>A1.reset(;file("new_orders.ctx”).open().cursor()) |
4 |
=file("new_orders.ctx”).create@y(#EMPLOYEEID,ORDERDATE,ORDERID,CUSTOMERID, AREAID,AMOUNT) |
|
5 |
=file("new_orders.ctx”).reset(;A2) |
A2 从数据库中取出当天的新增数据。
A3 判断是不是每月的 1 日,如果不是的话,则做每天的例行工作:在 A5 中将当天的新增数据有序归并到一个单独的小文件 new_orders.ctx。
如果是 1 日,那么就要在 B3 中做每个月一次的重置工作:将独立小文件中积累的更新数据和原组表有序归并生成新组表后,再在 B4 中把小文件清空。
读数据时,需要将历史数据组表与增量数据组表归并后使用,例如:
=[file("orders.ctx").open().cursor(),file("new_orders.ctx").open().cursor()].merge(#1)。
这里需要注意的是,游标序列中应同数据文件的先后顺序,即 [历史数据, 增量数据]。
存储分段
在用有序数据做多线程并行计算的时候,需要考虑分段的问题(每个线程执行一段)。比如前面提到的交易明细数据按照帐号有序存储,很有利于对每个帐号进行复杂计算。但是,如果需要并行做复杂的帐户计算,不能在分段时将同一个帐户的多条记录分到不同的段,否则会算出错误的结果。
这种情况下,在创建组表的时候需要声明:分段时必须将帐号字段相同的多条数据分到一个段中。对于交易明细表来说,创建组表的代码要写成这样:
A |
|
… |
|
=file("detail.ctx").create@py(#ACCOUNTID,...) |
|
… |
其中的 create@p 就是指明,将来在做并行计算的分段时,不能将第一个字段 ACCOUNTID 的相同记录分到不同的段中。目前 SPL 只提供了针对第一字段的分段处理,过去的实践表明,已经足够用了。由于集文件不需要事先指定数据表结构,就没有提供这种机制了。
还有一个典型的应用场景是一对多关系的主子表,按照主键有序存储后,常常要做并行的有序归并计算。对于主表来说,主键是不重复的,分段时不可能把两个相同主键的记录分到两个段中。但子表的关联字段并不是全部的主键,几乎必然会有重复值,也就可能在两个自然分段中出现关联字段相同的记录,这样的分段就会导致关联错误。所以,在创建主子两个组表时,创建子表必须使用 create@p,保证不出现这种情况。
数据类型转换
在转存数据时,有可能要改变原数据类型,以获得更小的存储量和更好的运算性能。
1. 数值
数值字段要尽可能转换为整数,最好是小于 65536 的小整数。例如,订单表的员工编号 EMPLOYEEID 总共只有几千个,却被人为变成了很大的数值:100001、100002…,我们可以将其转换成员工表的序号。假设 100001 在员工表是第一条记录,那么就转换为 1,以此类推。再比如通过 jdbc 从 Oracle 读取数据时,所有数值(包括整数)默认都变成了 big decimal,非常影响计算性能,要再转换回适当的数据类型。
2. 日期
日期可以转换成距离某一天起的天数,这样不影响比较。小整数表示的日期范围大于 6 万天,可以存储超过 100 年的时间段,在大多数场景都够用。SPL 提供了一种方法,把年月转换成距离 1970 年起的月数,而日用 5 个二进制位表示(一个月最多 31 天,5 位二进制数可以表示 0-31 之间的数),即相当于 ((yyyy-1970)*12+(mm-1))*32+dd,这样就可以用小整数表示从 1970 年到 2140 年间的日期,也基本够用。
3. 枚举串
有些字符串字段其实是一种编码,其可取范围很小,比如性别、学历、国家或地区的缩写等,称为枚举串。枚举串也需要转换成代码表的序号,例如,订单表中的地区字段 AREAID 值用地区缩写,可以转换为地区表的序号。如果枚举串没有对应的代码表,可以将全部编码统计出来,新建一个代码表,再完成枚举串的转换。
对订单数据做类型转换的代码大致如下:
A |
|
1 |
=connect("demo") |
2 |
=T("area.btx").keys@i(AREAID) |
3 |
=T("employee.btx").keys@i(EMPLOYEEID) |
4 |
=A1.cursor("select ORDERID,CUSTOMERID,EMPLOYEEID,AREAID,AMOUNT,ORDERDATE from ORDERS order by ORDERID") |
5 |
=A4.new(ORDERID,int(CUSTOMERID):CUSTOMERID,A3.pfind(EMPLOYEEID):EMPLOYEENO,A2.pfind(AREAID):AREANO,AMOUNT,days@o(ORDERDATE):ORDERDATE) |
6 |
=file("orders.ctx").create@y(#ORDERID,CUSTOMERNO,EMPLOYEENO,AREAID,AMOUNT,ORDERDATE) |
7 |
=A6.append(A5) |
8 |
>A1.close(),A6.close() |
B2:确定一个日期的起点,时间要早于所有的订单日期。
A2、A3 读入预先存成 btx 的地区表和员工表,确定主键。
A4:如果是 Oracle 数据库,这里可以写作 cursor@d(),将 big decimal 转为双精度类型。
A5:将客户号强制转换为整数;员工号、地区缩写转换为员工表、地区表的序号;days@o 函数采用上面提到的方法,将日期转换为小整数。如果日期计算时需要用到年月日,分别用表达式 year(ORDERDATE)、month(ORDERDATE)、day(ORDERDATE) 得到。
索引
查找计算除了要采用行存组表,还要为待查找字段建立索引。比如对行存订单表的订单号建立索引,那么从中查找某个订单号时,性能会得到明显提升。大致的代码是这样的:
A |
|
1 |
=file("orders.ctx").open() |
2 |
=A1.index(index_orderid;ORDERID) |
A2:为订单号字段建立索引,文件名为:orders.ctx_index_orderid。
不过,当条件遍历取出的记录比较多时,索引的效果就不一定好了。例如:从订单表中查找某个客户的交易明细,利用客户号索引查找的性能就不太好。这是因为,订单表可能是按照订单号有序的,对于客户号来说则是乱序的,同一个客户的数据分散在大表的各处,使用索引查找将造成硬盘的大量不连续读取,性能会很不理想。
这种情况需要在使用索引的同时,按照带查找字段有序存储。例如,我们将订单数据按照客户号排序存储后,再建立客户号的索引。查询时先用索引快速定位到指定客户号,再从硬盘上连续读取该客户的所有订单数据,性能提升会相当明显,足以应对海量数据的高并发查询。
对于前面所说的遍历和查找要求都很高的场景,SPL 还提供了一种带值索引,在建立索引时把其它字段值一起复制过来。原组表继续采用列存用于遍历,而索引本身已经保存了字段值并使用行存,在查找时一般不再访问原表,能获得更好的性能。为订单表建立带值索引的代码大致是这样:
A |
|
1 |
=file("orders.ctx").open() |
2 |
=A1.index(INDEX_ORDERID_1;ORDERID;CUSTOMERID,AMOUNT) |
A2 为订单表建立了订单号的带值索引,索引中包含客户号和金额。
带值索引和前面所说的行列共存方案都能兼顾遍历、查找的性能。而且,带值索引相当于行存加上索引,比行列共存方案占用的空间更小。
多文件存储
通常我们会把同一个业务逻辑和结构的数据存储在一个数据表中,对应到 SPL 的存储也就是一个文件。但是,当这个文件数据量特别大时就会不太方便,无论查询计算还是数据维护都可能会涉及明知无关的数据,导致性能严重下降。
其实,在使用数据库时,有经验的程序员也常常会采用分库分表的手段,将巨大的数据表拆分成若干较小的表(甚至分成多个数据库),以方便相关的处理。使用文件存储的 SPL 同样可以采用这种方案,也就是让逻辑上一个数据表在物理上对应成多个文件。
比较常见的是按时间拆分。比如:当帐户交易明细表非常大时,如果还用一个文件来存储,在维护上就会很不方便。明细表随着时间会不断有新增数据追加进来,过期的老旧数据则要定期删除。SPL 不支持大量旧数据的删除,只能把组表重新写一遍。新增数据要保持帐户有序,组表虽然可以避免每次更新时重新排序,但定期整理累积更新后,仍然会要求和全量老数据归并。这些计算都很费时间,会让维护操作变得很漫长。
而且,帐户交易明细表常常要做帐户分析计算,用一个文件存储就会降低性能。帐户分析一般是先过滤出一段时间的明细数据,再对每个帐户的数据进行复杂计算。这时,如果一个大文件对时间有序,可以做到快速过滤,但进行复杂帐户计算却很难;反过来,如果对帐户有序,又很难按时间快速过滤了。
我们可以把数据按时间拆成多个文件,每个文件保存一小段时间(一个月)的数据,维护就变得比较简单:新增数据只需要和最后一个文件合并,处理过期数据只要删除最早的文件就可以了。而且,帐户分析计算性能也会得到提升,因为拆分好的文件之间整体上是对日期有序的,而在每个文件内部,可以存储成按帐户有序的。这样做,相当于数据整体上对日期和帐号同时有序了,既可以按照时间快速过滤,又很容易进行复杂帐户计算。
SPL 提供了能使这些拆分文件在逻辑上统一成一个表的方法。程序员只要声明拆分好的小文件以及时间、帐号字段名称,就可以当成一个大表来使用。SPL 会自动完成上面说的时间过滤、有序归并等操作。更详细的原理介绍和用法参见SPL 虚表的双维有序结构。
除了按时间拆分之外,实际上还会有其他方式,比如按地区拆分大数据表。这样做,同样可以使数据单元变小,方便维护。同时,也能提升计算的性能,比如有些计算总是在某个地区之内进行的情形。
SPL 使用文件存储数据,可以利用文件系统的多层树状结构完成大表的拆分。必要时将大数据拆成几百上千个文件都是可以的,只要将文件分类放入文件夹即可。而数据库对数据表采用的线状管理机制,就不方便拆得太细了。
当然,也不是拆得越细越好,拆细了可能会导致性能的下降。因为,虽然 SPL 在逻辑上可以将拆分的文件整合成单表,但是物理上还是多个文件。而把小文件映射成单表通常要做归并计算,会消耗额外的资源,拆得越碎就要用越多时间来合并。因此,文件拆分的粒度要适中,必须根据实际需求权衡决定。
除了上面介绍的这些存储机制,如果采用 SPL 进行多机集群计算,还要考虑分布式数据存储等等问题,这里就不一一介绍了。请参考:【性能优化】。
英文版