SPL 实践:海量灵活结构数据查询
问题描述
灵活数据结构的意思是指数据表的每条记录的数据结构不相同。所有字段通常能分成为两部分,一部分是所有记录的共同字段,另一部分是各自不同的字段,其总数量可能多达数百种,但每条记录只占少数几种。更复杂的情况,这些灵活字段还会再分类。
没分类的简单场景举例,学生成绩单:
共同字段 |
灵活字段 |
||||||||
学号 |
姓名 |
性别 |
年龄 |
语文 |
英语 |
数学 |
物理 |
化学 |
生物 |
1 |
张三 |
男 |
16 |
67 |
50 |
90 |
100 |
80 |
80 |
2 |
李四 |
女 |
18 |
/ |
/ |
/ |
99 |
/ |
70 |
3 |
王五 |
男 |
17 |
98 |
88 |
78 |
/ |
/ |
/ |
… |
… |
… |
… |
… |
… |
… |
… |
… |
… |
有的同学没有相应的课程,也就没有该课程的成绩了,如表格中各斜线框。
有分类的复杂场景举例,体育生成绩表:
共同字段 |
灵活字段 |
||||||||
学号 |
姓名 |
性别 |
年龄 |
篮球 |
足球 |
||||
评语 |
是否国家队 |
评分 |
评语 |
是否国家队 |
评分 |
||||
1 |
张三 |
男 |
16 |
经常… |
是 |
100 |
/ |
/ |
/ |
2 |
李四 |
女 |
18 |
/ |
/ |
/ |
偶尔… |
/ |
95 |
3 |
王五 |
男 |
17 |
善于… |
是 |
20 |
擅长… |
是 |
10 |
… |
… |
… |
… |
… |
… |
… |
… |
… |
… |
共同字段是每个人的基本属性。灵活字段,分为不同属性(这里是篮球、足球);不同的体育运动下,又有相同分类(这里是评语[字符串]、是否国家队[布尔]、评分[数值])
下面的讨论将主要针对有分类的复杂情况,无分类情况是有分类情况的特例。
查询条件举例:
共同字段(主表)中满足,性别为“男”
且
灵活字段(子表)中满足,属性为“篮球”的 “是否国家队” 分类值为“是” 且属性为“足球”的 “评分” 分类值小于60的那些记录。
本质上,这两个例子描述的数据结构,在逻辑上可以看作是一张大宽表,但是当属性和分类过多时,这张表的字段就会有几千列(共同字段 + 所有可能的属性 * 分类)。如果在物理上也处理成大宽表,会造成字段过多而且大多数都是空白。
SQL中一般会使用两个数据表来描述这样的数据结构:
主表main(共同字段)
字段名 |
类型 |
含义 |
id |
数值 |
id号(自增) |
name |
字符串 |
姓名 |
gender |
布尔 |
性别 |
age |
数值 |
年龄 |
子表sub(各自不同的字段)
字段名 |
类型 |
含义 |
id |
数值 |
id号同主表 |
atr |
数值 |
属性(可枚举,数字化存储) |
comment |
字符串 |
评语(人工填写) |
nt |
布尔 |
是否国家队 |
rate |
数值 |
评分 |
结构示例中,分类就是comment、nt、rate这些字段。如果要查性别为男,属性为篮球的是否国家队分类为是,属性为足球的评分分类小于60的记录,SQL将会这样写:
SELECT *
FROM main m, subs
WHERE m.id= s.id AND
(SELECT count(*)
FROM sub ss
WHERE m.id= ss.id AND (ss.atr= 1 AND ss.nt= 1) or (ss.atr=2 AND ss.rate<60) ) = 2
SPL 中,通常有三种办法来处理这种情况:
一、 使用json式字段
这种灵活数据结构通常会使用json式字段。
无分类时,json串是一条记录。例如,学生成绩单中,李四的成绩:
{
"物理": 99,
"生物": 70
}
有分类时会是个序表。例如,体育生成绩中,王五的成绩:
[
{
"属性":"篮球",
"评语":"善于…",
"是否国家队":"是",
"评分":"20"
},
{
"属性":"足球",
"评语":"擅长…",
"是否国家队":"是",
"评分":"10"
}
]
json字段存储量会较大(每个记录或序表都要存储一遍字段名),而且总要解析json为记录或序表后才能参与运算,性能不会好。SPL处理json式字段很方便,相应的代码比较常规,这里就不详细说了。
下面介绍另外两种手段。
二、 使用序列字段
以前述的“体育生成绩表”为例,使用序列字段是将子表的所有属性当成一个序列字段,分类也随之转为一一对应的序列,如下:
共同字段 |
各自不同的字段 |
||||||
学号 |
姓名 |
性别 |
年龄 |
属性 |
评语 |
是否国家队 |
评分 |
1 |
张三 |
男 |
16 |
[篮球] |
[经常…] |
[是] |
[100] |
2 |
李四 |
女 |
18 |
[足球] |
[偶尔…] |
[null] |
[95] |
3 |
王五 |
男 |
17 |
[篮球,足球] |
[善于…, 擅长…] |
[是,是] |
[20,10] |
… |
… |
… |
… |
… |
… |
… |
… |
2.1数据结构
定义测试数据结构如下:
主表main(共同字段)
字段名 |
类型 |
含义 |
id |
数值 |
学号(自增) |
name |
字符串 |
姓名 |
gender |
布尔 |
性别 |
age |
数值 |
年龄 |
class |
数值 |
等级(可枚举,数字化存储) |
子表sub(各自不同的字段)
字段名 |
类型 |
含义 |
id |
数值 |
学号,同主表 |
atr |
数值 |
属性(可枚举,数字化存储) |
comment |
字符串 |
评语(人工填写) |
nt |
布尔 |
是否国家队 |
rate |
数值 |
评分 |
2.2从数据库转储数据
从数据库中取数的代码如下:
A |
|
1 |
=connect@l("oracle12c") |
2 |
=A1.cursor("SELECT * FROM MAIN ORDER BY ID") |
3 |
=A1.cursor@x("SELECT * FROM SUB ORDER BY ID") |
4 |
=A3.group(id;~.(atr):atr,~.(comment): comment,~.(nt): nt,~.(rate): rate) |
5 |
=joinx(A2:main,id;A4:sub,id) |
6 |
=A5.new(main.id,main.name,main.gender,main.age,main.class,sub.atr,sub.comment,sub.nt,sub.rate) |
7 |
=file("seq_all.ctx").create(#id,name,gender,age,class,atr,comment,nt,rate) |
8 |
>A7.append@i(A6) |
2.3 生成测试数据
为实验方便,我们写段代码直接生成seq_all.ctx组表文件,脚本如下:
A |
B |
|
1 |
>rand@s(1) |
|
2 |
>n=100000 |
|
3 |
=file("seq_all.ctx").create(#id,name,gender,age,class,atr,comment,nt,rate) |
|
4 |
for 30 |
=to(n*(A4-1)+1,n*A4).new(~:id,"name"/~:name,rand(2)+1:gender,rand(17)+18:age,rand(10)+1:class,20.sort(rand()).to(rand(5)+5):atr,atr.len().(rands("qwertyuiopasdfghjklzxcvbnm",rand(200)+2)):comment,atr.len().([true,false,null](rand(3)+1)):nt,atr.len().(rand(100)+1):rate) |
5 |
>A3.append@i(B4.cursor()) |
2.4查询
场景1: 年龄大于等于20,小于等于25,且等级包含(等级1,等级3,等级6)
场景2: 属性值为"篮球"的是否国家队分类为"是",属性值为"足球"的评分分类值小于60
A |
|
1 |
/case1:年龄大于等于20,小于等于25,且等级包含(等级1,等级3,等级6) |
2 |
[1,3,6] |
3 |
age>=20 && age<=25 && A2.pos(class) |
4 |
/case2:属性值为"篮球"的是否国家队分类为"是",属性值为"足球"的评分分类值小于60 |
5 |
j(atr,nt,rate).count((#1==1 && #2==true) || (#1==2 && #3<60))==2 |
6 |
/主表字段 |
7 |
id,name,gender,age,class |
8 |
/子表字段 |
9 |
atr,comment,nt,rate |
10 |
/case1_query |
11 |
=now() |
12 |
=file("seq_all.ctx").open() |
13 |
=A12.cursor@v(${A7},${A9};${A3}) |
14 |
=A13.fetch(5000) |
15 |
=interval@ms(A11,now()) |
16 |
/case2_query |
17 |
=now() |
18 |
=file("seq_all.ctx").open() |
19 |
=A18.cursor@v(${A7},${A9};${A5}) |
20 |
=A19.fetch(5000) |
21 |
=interval@ms(A17,now()) |
A5是个条件表达式,j将多序列字段合成一个序表,然后count出每条记录满足条件的个数,当数量与提的条件相等,说明这条记录需要被取出
三、 使用附表
2.1的数据结构,可以视为主子表。主子表关系时,以共同字段部分为基表,灵活字段部分作为附表。附表的使用可以参考:《SPL附表》
3.1 从数据库转储数据
A |
|
1 |
=connect@l("oracle12c") |
2 |
=A1.cursor("SELECT * FROM MAIN ORDER BY ID") |
3 |
=A1.cursor@x("SELECT * FROM SUB ORDER BY ID") |
4 |
=file("attach_all.ctx").create(#id,name,gender,age,class) |
5 |
=A4.attach(sub,atr,comment,nt,rate) |
6 |
=A4.append@i(A2) |
7 |
=A5.append@i(A3) |
3.2 生成测试数据
基于2.3生成的文件,再生成附表数据的脚本如下:
A |
|
1 |
=file("seq_all.ctx").open().cursor(id,name,gender,age,class) |
2 |
=file("seq_all.ctx").open().cursor(id,atr,comment,nt,rate).news(#2;id,~:atr,comment(#):comment,nt(#):nt,rate(#):rate) |
3 |
=file("attach_all.ctx").create(#id,name,gender,age,class) |
4 |
=A3.attach(sub,atr,comment,nt,rate) |
5 |
=A3.append@i(A1) |
6 |
=A4.append@i(A2) |
3.3 查询
查询条件同2.4
A |
|
1 |
/case1:年龄大于等于20,小于等于25,且等级包含(等级1,等级3,等级6) |
2 |
[1,3,6] |
3 |
age>=20 && age<=25 && A2.pos(class) |
4 |
/case2:属性值为"篮球"的是否国家队分类为"是",属性值为"足球"的评分分类值小于60 |
5 |
sub.count((atr==1 && nt==true) || (atr==2 && rate<60))==2 |
6 |
/主表字段 |
7 |
id,name,gender,age,class |
8 |
/子表字段 |
9 |
atr,comment,nt,rate |
10 |
/case1_query |
11 |
=now() |
12 |
=file("attach_all.ctx").open() |
13 |
=A12.cursor@v(${A7},sub{${A9}}:sub;${A3}) |
14 |
=A13.fetch(5000) |
15 |
=interval@ms(A11,now()) |
16 |
/case2_query |
17 |
=now() |
18 |
=file("attach_all.ctx").open() |
19 |
=A18.cursor@v(${A7},sub{${A9}}:sub) |
20 |
=A19.select@v(${A5}) |
21 |
=A20.fetch(5000) |
22 |
=interval@ms(A17,now()) |
3.4 两遍查找
使用序列字段时,可以使用游标前过滤,提升性能。但是附表中的子表上的条件需要先把所有列都读出来,即使用不上的字段。如果条件过滤出的结果很少,就会多读很多没用的字段。
所以,在用附表时,先只用条件相关的字段取出主键再找,很可能是更快的。而序列字段则不需要这样处理。
先根据条件,找出满足条件记录的主键序列,再用主键序列找结果,脚本如下:
A |
|
1 |
/case1:年龄大于等于20,小于等于25,且等级包含(等级1,等级3,等级6) |
2 |
[1,3,6] |
3 |
age>=20 && age<=25 && A2.pos(class) |
4 |
/case2:属性值为"篮球"的是否国家队分类为"是",属性值为"足球"的评分分类值小于60 |
5 |
sub.count((atr==1 && nt==true) || (atr==2 && rate<60))==2 |
6 |
/主表字段 |
7 |
id,name,gender,age,class |
8 |
/子表字段 |
9 |
atr,comment,nt,rate |
10 |
/case1_query |
11 |
id |
12 |
atr,nt,rate |
13 |
/keys |
14 |
=now() |
15 |
=file("attach_all.ctx").open() |
16 |
=A15.cursor@v(${A11},sub{${A12}}:sub;${A3}) |
17 |
=A16.fetch(5000) |
18 |
=interval@ms(A14,now()) |
19 |
/find by keys |
20 |
=now() |
21 |
=file("attach_all.ctx").open() |
22 |
=A21.cursor@v(${A7},sub{${A9}}:sub;A17.find(id)) |
23 |
=A22.fetch(5000) |
24 |
=interval@ms(A20,now()) |
25 |
/case2_query |
26 |
=now() |
27 |
=file("attach_all.ctx").open() |
28 |
=A27.cursor@v(${A11},sub{${A12}}:sub) |
29 |
=A28.select@v(${A5}) |
30 |
=A29.fetch(5000) |
31 |
=interval@ms(A26,now()) |
32 |
/find by keys |
33 |
=now() |
34 |
=file("attach_all.ctx").open() |
35 |
=A34.cursor@v(${A7},sub{${A9}}:sub;A30.find(id)) |
36 |
=A35.fetch(5000) |
37 |
=interval@ms(A33,now()) |
四、 使用行存
SPL列存采用了数据分块并压缩的算法,这样对于遍历运算来讲,访问数据量会变小,也就会具有更好的性能。但对于基于索引随机取数的场景,由于要有额外的解压过程,而且每次取数都会针对整个分块,运算复杂度会高很多。因此,从原理上分析,这时候的性能应当会比行存要差。
同一份数据不能在遍历运算和随机取数这两方面都达到最优性能,要追求极致性能,可以再冗余一份行存组表文件,并对该组表的主键建立索引。
转为行存并建立索引,脚本如下:
A |
|
1 |
/col2row |
2 |
=file("seq_all.ctx") |
3 |
=file("seq_all_row.ctx") |
4 |
=A2.reset@r(A3) |
5 |
/create_index |
6 |
=A3.open().index(idx;id) |
先从附表找出主键,再用主键在行存索引中找结果,脚本如下:
A |
|
1 |
/case1:年龄大于等于20,小于等于25,且等级包含(等级1,等级3,等级6) |
2 |
[1,3,6] |
3 |
age>=20 && age<=25 && A2.pos(class) |
4 |
/case2:属性值为"篮球"的是否国家队分类为"是",属性值为"足球"的评分分类值小于60 |
5 |
sub.count((atr==1 && nt==true) || (atr==2 && rate<60))==2 |
6 |
/主表字段 |
7 |
id,name,gender,age,class |
8 |
/子表字段 |
9 |
atr,comment,nt,rate |
10 |
/case1_query |
11 |
id |
12 |
atr,nt,rate |
13 |
/keys |
14 |
=now() |
15 |
=file("attach_all.ctx").open() |
16 |
=A15.cursor@v(${A11},sub{${A12}}:sub;${A3}) |
17 |
=A16.fetch(5000).(#1) |
18 |
=interval@ms(A14,now()) |
19 |
/find by keys |
20 |
=now() |
21 |
=file("seq_all_row.ctx").open() |
22 |
=A21.icursor(${A7},${A9};A17.contain(id)) |
23 |
=A22.fetch(5000) |
24 |
=interval@ms(A20,now()) |
25 |
/case2_query |
26 |
=now() |
27 |
=file("attach_all.ctx").open() |
28 |
=A27.cursor@v(${A11},sub{${A12}}:sub) |
29 |
=A28.select@v(${A5}) |
30 |
=A29.fetch(5000).(#1) |
31 |
=interval@ms(A26,now()) |
32 |
/find by keys |
33 |
=now() |
34 |
=file("seq_all_row.ctx").open() |
35 |
=A34.icursor(${A7},${A9};A30.contain(id)) |
36 |
=A35.fetch(5000) |
37 |
=interval@ms(A33,now()) |
英文版