【摘要】
日常生活中,我们会遇到各种各样的数据,小到公司通讯录,大到互联网用户行为分析。在进行数据分析处理的过程中,查询是必不可少的环节,如何更加高效地进行数据查询。点击:性能优化技巧 - 查询,来乾学院一探究竟!
SPL为用户提供了强大的索引机制以及针对不同场景中各对象的查询函数,善加运用,可以显著提高查询性能。
1 键值查找
1.1 序表
我们先建立一个份“通话记录”的模拟数据,通过这份数据,来比较一下不同查询函数对序表查询性能的影响。建立模拟数据的代码如下:
代码1.1.1
其中部分数据如下:
图1.1.1
对序表进行查询,通常我们会想到使用A.select()函数。我们来看一下使用该函数的效果:
代码1.1.2
查询耗时为80毫秒。
对序表的键值进行查询时,可以利用A.find()函数进行查询。示例代码如下:
代码1.1.3
查询耗时为1毫秒。
这是因为在集算器的序表中,可以指定某个或某些字段作为主键,基于主键的查找可以使用专门的函数。比如代码1.1.3中A5的find函数,不仅能简化书写,更能有效地提高计算性能。
当键值较多时,我们使用函数A.find@k ()进行批量键值查找。示例代码如下:
代码1.1.4
要注意的是,在使用A.find()函数时,需事先建立主键,否则会报“缺少主键”的错误。
利用主键值查找的函数,可以有效地提升计算性能,是由于在序表中为主键建立索引表。在代码1.1.4中,未建立索引时,平均查询时间在1400毫秒左右;建立索引后,查询平均耗时不到1毫秒。
序表中的数据量越大,需要查找的次数越多,对效率的提升就越明显。
当查询条件对应多个键时,示例代码如下:
代码1.1.5
switch/join函数同样需要根据主键值在序表中查找记录,使用时会对维表自动建立索引。若在多线程fork函数之前没有对相应维表建立索引,就会在每个线程中都自动为该维表建立一个索引,执行过程中会消耗更多内存,这样有可能会造成内存溢出,如图1.1.1.2,要注意避免,较好的处理方式可以参考图 1.1.3。
图 1.1.2 fork中的每个线程都自动建立了索引导致内存溢出
图 1.1.3 fork执行前,先对维表建立索引
1.2 集文件
对有序的集文件进行查找,可以使用f.iselect()函数实现二分查找,该函数也支持批量查找,下面是个基于集文件使用f.iselect()批量查找的例子:
代码1.2.1
代码1.2.1,建立集文件voiceBill@z.btx。显然,Subscriber是有序的。
代码1.2.2
代码1.2.2,因为f.iselect()是个二分查找函数,所以需要注意代码中的A2作为查询序列,与集文件的编号一样,都需要有序。还要注意,这里的选项@b不是二分法的意思,而是读取通过f.export()函数导出的集文件。该集文件导出时,注意需要使用选项@z,否则在使用f.iselect ()对集文件进行查询时会报错。
假设数据总量为N,使用二分法进行查找的时间复杂度为logN(以 2 为底),当数据量越大,性能提升也就越明显。
1.3 组表
组表也有类似序表的T.find()和T.find@k()函数,可以高效地实现键值查找。适合于在大维表中找出少量记录的场景。我们来看这样一个例子:
代码1.3.1
代码1.3.1,建立组表文件voiceBill.ctx,其中Subscriber是该组表的维。
代码1.3.2
代码1.3.2,对组表使用cs.select()函数进行查询,耗时为:13855毫秒。
代码1.3.3
代码1.3.3,对组表使用T.find()函数进行查询,耗时为:77毫秒。
对比可见:对于有维的组表,可以使用类似序表的T.find()函数,进行单个或者批量键值的查询,其查询效率远高于从筛选后的游标中取数。
2 索引查找
组表上可以建立三种索引,每种索引针对的情况也不同,分别为:
1、 hash索引,适合单值查找,比如枚举类型;
2、 排序索引,适合区间查找,比如数字、日期、时间类型;
3、 全文索引,用于模糊查询,比如字符串类型。
下面我们来建立一个组表,使其数据类型覆盖以上三种索引,如下:
代码2.1
代码2.1建立的组表,前十条记录如下:
图2.1
代码2.2
代码2.2,根据每列数据类型的特点,建立不同类型的索引。建立好的索引和组表文件如图2.2:
图2.2
集算器能自动识别条件找到合适的索引,等值和区间都可以,like(“A*”)式的也支持。我们来看下效果:
等值查找
代码2.3
代码2.4
代码2.3是没有省略索引名称的写法,代码2.4是省略索引名称的写法。两者时间消耗基本相同,都是100毫秒左右。
代码2.5
代码2.5使用普通游标查询同样的记录,查询耗时则需要40秒左右。
区间查找
代码2.6
代码2.6对Subscriber使用排序索引,进行区间查找,查询耗时是70毫秒左右。
代码2.7
代码2.7使用普通游标查询同样条件的记录,查询耗时则需要40秒左右。
模糊查找
代码2.8
代码2.8对Company使用全文索引,进行模糊查询,查询耗时是1500毫秒左右。
代码2.9
代码2.9使用普通游标查询同样条件的记录,查询耗时则需要40秒左右。
当数据规模更大时,例如:
代码2.10
代码2.10,建造了10亿条结构如图2.3的组表文件employee.ctx。
图2.3
代码2.11
代码2.11中,对大部分列建立了索引。组表与索引的各个文件如图2.4。
图2.4
多等值条件项&&时,可以分别为每个字段建立索引。集算器能够快速在多个索引中用归并算法计算交集。比如:
代码2.12
代码2.12,查询条件均为等值查询,A3查出记录数为324条,耗时31883毫秒。
但区间条件时不能再用归并计算交集,集算器将只对其中一个条件使用索引,另一个条件使用遍历计算,效果就会差,比如:
代码2.13
代码2.13,查询条件均为区间条件,A3查出记录数为389条,耗时70283毫秒。
3 索引缓存
组表索引提供了两级缓存机制,可以用index@2或者index@3预先把索引的索引读入内存,如果需要重复多次使用索引查找,则可以有效提高性能。
选项@2、@3的意思分别是将索引的第二、三级缓存先加载进内存。经过索引缓存的预处理,第一遍查询时间也能达到查询数百次后才能达到的极限值。@2相比@3缓存的内容少,效果相对差一点,但内存占用也更少。使用时需要程序员根据具体场景来权衡@2还是@3。
代码3.1
代码3.1,基于代码2.10建造的组表文件,不使用索引缓存,查询耗时为31883毫秒。
代码3.2
代码3.2使用第三级索引缓存,查询耗时为5225毫秒。
这里使用的是列存组表,列存采用了数据分块并压缩的算法,对于遍历运算来讲,访问数据量会变小,也就会具有更好的性能。但对于基于索引随机取数的场景,由于要有额外的解压过程,而且每次取数都会针对整个分块,运算复杂度会高很多。因此,从原理上分析,这时候的性能应当会比行存要差。将组表转为行存后,查询耗时仅为1592毫秒。
索引缓存在并行时可以复用,如下:
代码3.3
代码3.3,并行时,A5的每个线程中都可以使用A2、A3中建立的第三级索引缓存,最终查询耗时为21376毫秒。
4 带值索引
组表的行存和列存形式都支持索引,列存索引查找比行存性能差,返回结果集较少时差异不明显,大量返回时会有明显劣势,在设计存储方案时要权衡。
代码4.1
代码4.1建立组表文件id_600m.ctx,结构为(#id,data) ,包含6亿条记录,其中:
A1:包含 26 个英文字母和 10 个阿拉伯数字的字符串。
A2、A3:建立结构为 (id,data) 的组表文件,使用列式存储方式。
A4:循环 6000 次,循环体B4、B5,每次生成 10 万条对应结构的记录,并追加到组表文件。
执行后,生成组表文件:id_600m.ctx
代码4.2
代码4.2为组表id列建立索引。
执行后,生成组表的索引文件:id_600m.ctx__id_idx。
列存组表生成时 create() 函数加上 @r 选项,即可变为生成行存组表,其余代码无异,这里不再举例,当返回数据量较大时:
代码4.3
代码4.3中,列存查询耗时和行存查询耗时,也就是A5和A9的值分别为205270和82800毫秒。
组表支持一种带值索引,即把查找字段也写入索引,这样可以不再访问原组表即返回结果。但存储空间会占用较多。
基于代码4.1的列存组表文件id_600m.ctx。
代码4.4
代码4.4为组表id列建立索引,在对组表建立索引时,当 index 函数有数据列名参数,如本例 A2 中的 data,就会在建索引时把数据列 data 复制进索引。当有多个数据列时,可以写为:index(id_idx;id;data1,data2,…)。
因为在索引中做了冗余,索引文件也自然会较大,本文中测试的列存组表和索引冗余后的文件大小为:
当数据复制进索引后,实际上读取时不再访问原数据文件了。
从 6 亿条数据总量中取 1 万条批量随机键值,完整的测试结果对比:
5 批量键值
组表索引能够识别出contain式条件,支持批量等值查找。
代码5.1
代码5.1建立组表文件id_600m.ctx,结构为(#id,data) ,包含6亿条记录,其中:
A1:包含 26 个英文字母和 10 个阿拉伯数字的字符串。
A2、A3:建立结构为 (id,data) 的组表文件,@r 选项表示使用行式存储方式。
A4:循环 6000 次,循环体B4、B5,每次生成 10 万条对应结构的记录,并追加到组表文件。
执行后,生成组表文件:id_600m.ctx。
代码5.2
代码5.2为组表id列建立索引。
执行后,生成组表的索引文件:id_600m.ctx__id_idx
代码5.3
代码5.3,在组表的 icursor()这个函数中,使用索引 id_idx,以条件 A2.contain(id) 来过滤组表。集算器会自动识别出 A2.contain(id) 这个条件可以使用索引,并会自动将 A2 的内容排序后从前向后查找。
进阶使用
使用排序索引多线程查找时,按键值排序分组后扔给多个线程去查询,避免两个线程中有交叉内容。同时,还可以设计成多个组表,把键值能平均分配到多个组表上并行查找。
所谓多线程并行,就是把数据分成 N 份,用 N 个线程查询。但如果只是随意地将数据分成 N 份,很可能无法真正地提高性能。因为将要查询的键值集是未知的,所以理论上也无法确保希望查找的数据能够均匀分布在每一份组表文件中。比较好的处理方式是先观察键值集的特征,从而尽可能地进行数据的均匀拆分。
如果键值数据有比较明显的业务特征,我们可以考虑按照实际业务场景使用日期、部门之类的字段来处理文件拆分。如:将属于部门 A 的 1000 条记录均分在 10 个文件中,每个文件就有 100 条记录。在利用多线程查询属于部门 A 的记录时,每个线程就会从各自对应的文件中取数相应的这 100 条记录了。
下面我们来看个实际的例子,已有数据文件multi_source.txt的结构如下:
其中 type 和 id 两个字段作为联合主键确定一条记录,其中部分数据如下:
代码5.4
代码5.4详解:
A1:type 的枚举值组成的序列。在实际情况中,枚举列表可能来自文件或者数据库数据源。。
A2:给枚举值序列中每个 type 一个 tid。为后续的数字化主键合并做准备。
A3~A6:从 multi_source.txt 文件中获取数据,并按照 A2 中的对应关系,把 type 列的枚举串变成数字,然后将 type 和 id 进行合并后,生成新的主键 nid。
A7:使用循环函数,创建名为“键值名 _ 键值取 N 的余数 _T.ctx”的组表文件,其结构同为 (#nid,data)。
A8:用循环函数将游标数据分别追加到 N 个原组表上。比如当 N=1 时,拼出的 eval 函数参数为:channel(A4).select(nid%4==0).attach(A7(1).Append(~.cursor()))。意思是对游标 A4 创建管道,将管道中记录按键值 nid 取 4 的余数,将余数值等于 0 的记录过滤出来。attach 是对当前管道的附加运算,表示取和当前余数值对应的原组表,将当前管道中筛选过滤出的记录,以游标记录的方式追加到 A7(1),即第 1 个组表。
A9:循环游标 A6,每次获取 50 万条记录,直至 A6 游标中的数据取完。
执行后,产出 4(这时例子取 N=4)个独立的组表文件:
代码5.5
代码5.5,创建索引过程详解:
A1:列出满足 nid*T.ctx 的文件名(这里 * 为通配符),这里 @p 选项代表需要返回带有完整路径信息的文件名。使用 fork 执行多线程时,需要注意环境中的并行限制数是否设置合理。这里用了 4 个线程,设计器中对应的设置如下:
B1:每个线程为各个组表建立对应的索引文件,最终结果如下:
代码5.6
代码5.6,查询过程详解:
A1:从 keys.txt 获取查询键值序列,因为只有一列结果,使用 @i 选项,将结果返回成序列:
A2:把 A1 的序列按 4 的余数进行等值分组:
A3、B3~B5:用 fork 函数,按等值分组后的键值对各个组表分别并行查询。这里的 fork 后面分别写了两个参数,第一个是循环函数 N.(~-1),第二个是 A2。在接下来的 B3、B4 中分别使用 A3(2) 和 A3(1) 来获取 fork 后面这两个对应顺序的参数,B4:对组表文件进行根据 B3 中的键值集进行数据筛选,B5:返回游标。由于 A3 中是多个线程返回的游标序列,所以 A6 中需要使用 conjx 对多个游标进行纵向连接。
A6~A7:将多个线程返回的游标进行纵向连接后,导出游标记录至文本文件,前几行内容如下。