面向初学者的 SPL 概念

作为程序语言,SPL 有一些自有的特点,不了解这些并不影响上手学习,但在架构设计或代码编写上都难以充分发挥其功能。这里试图为初步者建立一套 SPL 的基本概念骨架,其它更多的内容再根据实际需求参考相关文档就可以了。
这些内容也不太少,需要花数小时时间仔细研读理解。

一. 应用结构

首先要了解 SPL 的运行环境及应用结构。

1. 脚本与开发

SPL 是一种解释执行的程序语言,写出来的代码通常不大,一般称为脚本
SPL 脚本的提交形式一般是文件,扩展名是 **.splx**,不是文本格式。SPL 也支持文本格式的脚本文件,扩展名是.spl。
SPL 提供了专门的 IDE 用于编写 SPL 脚本,IDE 支持常规的调试功能。IDE 是个客户端程序,可以运行在 Windows,Linux 以及 Mac 上

2. 环境与集成

SPL 软件可以分为三个部分:IDE、JDBC 和 Server。全部是纯 Java 开发,可以在任何有 JDK1.8 以上版本的 JVM 的环境下运行。

SPL JDBC 不是独立进程,它由一批 jar 组成,可以被引入到 Java 应用中,向应用提供标准 JDBC 接口。
SPL JDBC 和传统 RDB 的 JDBC 驱动对比:
1) SPL JDBC 包含了完整的运算库,单独就能提供计算服务,不再依赖于某个服务器工作
2)接受的语句是 SPL 而不是 SQL,执行某个.splx 脚本类似于使用 SQL 执行 RDB 中的存储过程

SPL Server 是个独立进程,可对外提供基于 HTTP 协议的服务,可供非 Java 应用调用。也可被 SPL JDBC 访问调用。
多个 SPL Server 可以构成集群。很多大数据方案都会强调集群的作用,但 SPL 的多次实践表明单机能够解决绝大部分任务,除非高并发需求,否则很少用到集群,初学者不必关注集群。

3. 数据源和外部库

SPL 支持多样性数据源,只要有 Java 接口的数据源均可访问和计算。
SPL 内置有文本文件、Excel 文件、有 JDBC 驱动的关系数据库、HTTP/Restful 访问接口,可以直接访问这些数据源。
SPL 为其它数据源提供了各种外部库,引入相应的外部库后即可在 SPL 脚本中访问相应的数据源,具体的读写能力由数据源本身决定。业界内常见的数据源都有对应的外部库。

SPL 将数据源抽象成两种形式:序表游标,序表将被一次性读入内存处理;如果数据源支持,SPL 还可以使用游标逐步读入数据并处理。
SPL 并不要求把所有外部数据读入内存才能计算。不要把 esProc 理解为内存计算引擎。当然它可以保持一定的内存数据以用作内存计算引擎。

SPL 没有“库”的概念,任何能访问到的数据源在逻辑地位上是等同的,仅仅是数据源本身的功能以及访问的性能不同。esProc 也不负责数据本身的管理及其安全性,只负责计算。

二. 脚本和语法

SPL 代码的基本逻辑和概念和大多数程序语言类似,对于有经验的程序员非常简单,但还有些 SPL 特有的内容需要关注。

1. 变量

SPL 可以使用常规的变量名,但更推荐直接使用单元格作为变量名。
表达式中引用的单元格名在编辑过程中会自动变迁,但字符串中的不会,用 $[…] 表示字符串时,其中涉及到单元格也会在编辑过程被变迁。
SPL 的变量具有泛型性,不需要声明数据类型,计算出什么就是什么。
在 SPL 表达式中也可以临时使用变量。

2. 函数

SPL 中有大量函数选项的语法以区分某个函数可能的轻微不同运行状态,选项可以组合使用,了解函数功能时要特别注意相关的选项。

replace( s, s1, s2 ) 替换子串
replace@1( s, s1, s2 ) 只替换第一个
replace@c( s, s1, s2 ) 匹配时大小写不敏感
replace@c1( s, s1, s2 ) 匹配时大小写不敏感且只替换第一个

SPL 使用层次参数来描述有结构的复杂参数,各层次的参数分别用冒号、逗号、分号来分隔,这和常规语言中仅用逗号分隔参数有较大不同,在了解函数定义时要注意参数层次。

if(a,b,c) 常规一层参数,逗号
between( x, a:b) 两层参数,逗号和冒号
hash(xi,...;n) 两层参数,逗号和分号
case(x1:y1,x2:y2,...; y ) 三层参数

3. 对象

SPL 有对象的概念,许多函数是封装在对象上的。
SPL 没有继承和重载这些面向对象的重要机制,严格地说,SPL 不算是个面向对象的语言,从这个意义上讲 SPL 要比 Java 甚至 Python 都简单。SPL 对象的主要作用在于封装一系列相关的函数。

4. Lambdba 语法

SPL 支持 Lambda 语法,从某种意义上可以说是函数式语言(它也支持过程)。
SPL 不支持为 Lambda 语法定义变量名,而是使用固定符号来表示 Lambda 语法中的变量,~ 表示当前成员,# 表示当前成员的序号,还可以用 ~[i] 和 ~[a:b] 表示相邻成员及子集。

A.sum( ~*~ ) 计算平方和
A.sum( if(#%2==0,~) ) 计算偶数位置的成员的和
A.max( ~-~[-1] ) 计算出最大差分值
A.( ~[-1,1].avg() ) 计算移动平均

某个参数是否可以写成 Lamdba 语法,由函数本身决定,在了解函数定义时需要注意。

参考 SPL 看 Lambda 语法

5. 宏

SPL 是动态语言,可以在执行过程中使用宏来临时改变语句

像 SQL 一样,SPL 语句中涉及到表名和字段名时大部分情况下都可以直接书写,不必写在字符串中,比如:

file("employee.txt").import@t( ID, Name, Gender, Department )

可以从 employee.txt 读出 ID,Name,Gender,Department 字段构成数据表,这样书写更为方便,可读性也好。
但是,如果希望 import 的参数变动的(可能根据代码上下文改变要读出的字段),就不太方便描述了,写成

file("employee.txt").import@t( "ID, Name, Gender, Department" )

会被 import 函数将 "ID, Name, Gender, Department" 整个作为一个字段名处理,显然不是我们想要的。

使用宏可以解决这个问题:

S="ID,Name,Gender,Department"
file("employee.txt").import@t(${S})

宏的本质是一个字符串,将这个字符串拼入待执行语句中,即可获得新的可执行语句。

参考 SPL 的宏

三. 数据对象

SPL 中最重要的数据对象有三种,这是用 SPL 处理数据的基础。

1. 序列

序列是内存中的有序集合。
SPL 中的集合都是有序的,也就是可以有序号来访问其成员,成员在集合中明确的前后次序,这和 Java 中的数组是一致的。

SPL 的序列有泛型性,不要求序列成员数据类型相同,序列的成员可以是任何数据类型,以下都是合法的序列:

[1,2,3]
["1","2","3"]
[1,"2",3]
[1,[2,3],4,5]

特别地,针对集合成员循环时可以直接写

for A
    在循环体中可以直接引用A的成员

而不推荐写成

for i,1,A.len()
    在循环体内用A(i)引用A的成员

更不要写成

i=1
for i<=A.len()
    在循环体内用A(i)引用A的成员
    i+=1

和 SQL 不同,SPL 提供了序列这种无数据结构的数据对象,在 SQL 中只能表示成单个字段的表。
SPL 的集合化函数常常是针对序列设计的,比如过滤、聚合、分组等运算,而不必针对有结构的数据表。
序列是比数据表更基础的数据对象,学习 SPL 要习惯于使用更轻量级的序列,而不是总是生成有结构的数据表。

2. 序表

序表是内存中的数据表。
SPL 延用了 SQL 中记录、字段以及数据表的概念,序表可以理解为有相同数据结构的记录构成的集合。
SPL 中的集合总是有序,序表表作为记录的集合也不例外,故称为序表。
序表也是个序列,成员也可以序号访问,针对序表成员的循环也推荐使用前述的语法。

SPL 的序表也有泛型性,序表中记录的数据结构要求相同,但对字段的取值并没有限制。序表中不同记录的同一字段的取值可以是不同数据类型的(尽管并不常见)。
特别地,字段取值甚至可以是另一个序表。这样,在 SPL 中可以很容易表示 json 式的多层数据结构。

SPL 的记录也是一种数据对象,可以从序表中取出再参与运算。这和 SQL 的概念不同,SQL 并没有记录数据类型,单条记录事实上一个只有一条记录的数据表。

SPL 还允许序表中的记录再构成新集合,由记录构成的序列称为排列
特别地,从某个序表中按某种条件过滤出来(SQL 中的 WHERE 运算,SPL 中的 select 函数)的子集就是一个排列。而构成这个排列的成员记录和原来序表中的记录是同一个对象,改变这些记录中的字段值会导致原序表中中记录相应的记录值也改变,SPL 并不会复制这些记录,这样能获得更好的计算性能以及占用更小的内存空间。这一点和 SQL 非常不同,SQL 中 WHERE 运算的结果集和原数据表没有关系,是复制出来的新记录。
排列也支持泛型性,构成排列的记录可以来自不同序表,即数据结构不同的记录可能拼到一起运算,当然这种情况并不常见。

在 SPL 中,记录从序表中游离出来单独存在或再构成集合后参与运算的情况很常见,称为离散性,这是 SPL 和 SQL 的本质差别之一。这一点其实和 Java 的对象引用机制是一致的,Java 程序员很容易理解这种数据类型。

SPL 中,记录的字段取值允许是另一个序表的记录,这样可以实现多表之间的关联引用。比如

D=file("department.txt").import@t()
E=file("employee.txt").import@t()
E.switch( DeptID, D:ID) 将E表的DeptID字段值改成D表中对应的记录
D.switch( Manager, E:ID ) 将D表中Manager字段取值改成E表中对应的记录
E.select( DeptId.Manager.Gender=="Female" ) 现在可以直接引用字段值存储的记录的字段值

类似地,字段取值还可以另一个排列,这样可以实现主子表的结构。

D=file("department.txt").import@t()
E=file("employee.txt").import@t()
D.derive( E.select( DeptID==D.ID ):Employee ) 在D表增加Employee字段取值为该部门的员工记录构成的排列
D.select( Employee.len()>10 ) 使用这个取值为排列的字段

这种通过记录和排列实现的表间引用关系在 SPL 中很常见,也是 SQL 中没有的内容,而在 Java 中很容易实现这种效果。

3. 游标

外存不像内存那样适合随机访问,经常只能提供流式的访问机制,需要在这种前提下设计计算方案。也有很多运算并不需要将源数据全部装入内存,只要逐步读入数据进行累积计算就可以,比如求和。这样可以用较小的内存处理较大的数据量。
SPL 中,可以流式读入的数据对象被抽象为游标,SPL 游标的基本概念特征和数据库游标类似。
和数据库游标一样,SPL 游标也是单向性,只能向后读出数据直到取完所有数据,不能倒退回去。

SPL 游标读出的数据通常会被组织成序表或序列(序表更常见),和 SQL 每次只能读出一条记录不同,SPL 提供了从游标中批量读出数据的方法

cs=file("Orders.txt").cursor@t()
cs.fetch( 100 ) 读出100记录构成序表
cs.fetch( ;TradeDate<=date("2022-12-31") ) 读出一直能使条件满足的记录(注意不是按条件过滤所有记录)
cs.fetch( ;UserID ) 读出一直到UserID字段发生改变之前的记录

后一种读数方式在很多场景非常有用,是 SPL 特有的机制。

为了调试方便,SPL 还提供了选项

cs.fetch@0( 1 ) 读出1条记录,但游标位置并不移动,下次再读还能将这条记录读出

这样可以先看看游标中的数据是否正常,而不会破坏整体代码的执行。

SPL 的游标不仅有读数功能,还可以在上面附加运算,比如

cs.select( ... ) 过滤
cs.groups( ... ) 分组汇总

游标上的运算函数在语法上和序表(排列)上的相应函数非常相似,保证内外存运算的代码尽量统一。但仍要注意这两者是不等同的。

游标上的运算函数可以分为两大类,一类称为延迟计算,执行此类函数时仅仅是做个标记,并不会实质性地遍历游标。另一类称为立即计算,会实质地遍历游标并计算出结果。

cs2=cs.select( ... ) 延迟计算,仅仅在游标上登记会有个过滤动作,不会立即遍历游标,返回一个新游标
cs2.groups(...) 立即计算,马上实施对游标的遍历和计算返回计算结果,之前在游标上登记过的延迟计算也将被执行

这一点和序表(排列)不同,序表(排列)上的运算函数都会立即执行并返回相应的结果。

游标只能遍历一次,完成遍历后就失效,不像内存的序表可以反复运算。有些基于游标的运算要求数据有序,如集合的交、并、差运算。
SPL 为游标提供了遍历复用的机制,可以在一次遍历过程中计算出多种结果。
SPL 还提供特有的多路游标,可以将数据拆分成(和存储方案有关)多段后并行遍历,充分利用多核 CPU 和 SSD 的并发能力。数据库游标没有直接的并行能力,写出并行代码非常困难。

四. 运算理解

SPL 提供了丰富的结构化数据计算类库,包括常规的集合运算如交、并、差,以及对结构化数据的过滤、分组、连接等运算。其中有些运算有 SPL 特有的理解和风格。

1. 循环函数

大部分针对集合(序列 / 序表 / 排列)的运算甚至处理动作,都可以使用循环函数解决,而不必使用复杂的循环语句。这样书写简单且性能也更好

p=directory("*.csv") 列出目录下所有csv文件
p.conj( file(~).import@tc() ) 将这些文件读出后合并

养成这种好习惯。

用公式 e=1+1/1!+1/2!+1/3!+…计算自然对数的底数 e(使用前 20 项)

=1+20.run(~=~*if(#>1,~[-1],1)).sum(1/~)

读懂这个语句,对于理解 SPL 的循环计算逻辑有很大的帮助。

在循环函数可以使用前述的 Lambda 语法。序表和排列上的 Lamdba 语法中可以直接引用字段名而不必写 ~。

2. 位置与对齐

SPL 提供了许多位置相关的函数。

T.pmax(age) 返回序表T中age字段值最大的记录的序号
T.pselect@a( age>50 ) 返回序表T中age>50的所有记录 的序号

善加利用可以让代码更为简洁

T=file("stock.txt").import@t().sort(Date)
S=T.ptop( 10; -Price ) 股价最高的10天所在的位置
T.calc( S, Price-Pirce[-1] ) 这10天的涨幅

以及按位置对齐的函数,相当于把数据按指定次序排序

T.align( 31, Day ) 把交易记录分到一月的31天,没有交易的日期填空,保证结果是31个成员
T.align( ["Sun","Mon","Tue","Web","Thu","Fri","Sat"], WeekDay )

3. 聚合理解

与 SQL 不同,SPL 中的聚合运算并不限于 SUM/COUNT/MAX/MIN 这些返回单个数值的运算。任何一种从集合计算出来的规模更小的值都可以认为是聚合计算。
聚合运算的结果有可能是个小集合,比如 top,返回前 N 名成员。
聚合运算的结果还可能是一个对象,比如 maxp,返回最大值所在的记录。
所有的聚合计算都可以用于分组。

一些例子:

T.top( -3, salary ) 最高工资的前三名
T.top( -3; salary ) 工资最高的前三名员工
T.maxp( age ) 年龄最大的员工
T.groups( Dept; maxp( age ) ) 每个部门年龄最大的员工

这些聚合运算也都可用应用在游标的分组汇总上。
程序员还可以使用 iterate 函数自定义聚合运算。

4. 分组

SQL 的分组后面会强制伴随针对分组子集的聚合,SPL 没有这个要求,分组和聚合可以分成两步,分组子集可以保留住继续参与计算。

T.group( Birthday ).select( ~.len()>1 ).conj() 有生日和其他人相同的人

SQL 只有一种等值分组,即把键值相同的成员分到同一个组中。SPL 除了等值分组还提供有更多的分组方法:

有序分组

T.sort( Date ).group@i( Price<Price[-1] ).max( ~.len() ) 按次序扫描数据,价格下跌时分组,计算最长连涨天数

序号分组

T.groups@n( Day; sum( Amount) ) 计算每日期的销售额

对位分组

T.align@a( ["Male","Female"], Gender ) 按性别分组,保证结果有两条

等值分组是一种完全划分,即所有原成员都会被分且只被分到一个分组子集中,不会出现空的分组子集。而 SPL 能支持不完全划分的分组,允许某些原成员被丢弃,允许分组子集出现空集,甚至可以实现可重复分组,即某些原成员被分到多个分组子集中。
去重运算(DISTINCT)本质上也是分组(没有聚合运算),要用分组的思维来解决。

5. 连接

SQL 把连接定义为笛卡尔积后再过滤,并不区分等值和不等值连接,且不要求和主键相关。SPL 也提供了这种自由灵活的连接运算,但很少用到。

等值连接,即以关联表的对应字段相等为过滤条件的连接,是最常见的连接运算形式。SPL 将等值连接再分成两种类型:

1) 外链连接:事实表和某个字段和维表的主键等值关联

2) 主键连接:A 表的主键和 B 表的主键或部分主键等值关联

两种连接都会有主键参与。没有主键参与的等值连接,绝大多数情况是业务逻辑和数据发生了错误。

两种连接运算要使用完全不同的函数实现,应用连接运算时事先要明确区分连接的类型,并找到参与的(逻辑)主键。

T.switch( C, D:K ) T表的C字段和D表的主键K进行外键连接,计算结果将K转换成D表中记录的引用
T.join( C, D:K, x:F ) T表的C字段和D表的主键K进行外键连接,将针对D表的关联记录计算x作为F字段拼到T上
join(T1,K1; T2,K2 ) T1表的K1字段和T2表的K2字段进行主键关联,返回以关联的T1,T2记录为字段的序表

外键连接时的维表需要被随机访问,原则上只能存储在内存中。SPL 提供了专门的函数针对内存装不下的巨大维表。
基于游标的主键连接运算要求参与游标都对主键有序。

参考:连接运算系列讲解

五. 存储及高性能

高性能和存储强相关,实现高性能计算的第一步通常是设计合理的存储方案。

1. 集文件

集文件是一种简单的文件格式,和访问文本文件使用同一个函数,只是选项不同。
创建集文件时不需要指名数据结构。
集文件相当于把文本文件二进制化了,省去解析数据类型的工作,能避免歧义,也比文本文件速度更快。
小数据可以写成集文件,临时生成的数据也可以写成集文件。
集文件可以支持分段,在上面建立多路游标做并行遍历。
集文件可以追加,不能改写,只能重写。
SPL 不是交易型数据库,为保证性能,所有的写操作都没有做共享冲突管理,不可以并发写,也不可以边写边读,读操作可以并发。

2. 组表

组表是 SPL 的高性能大数据文件格式,缺省会将用列式存储,也可以用选项指定使用行式,建议初学者使用缺省的列式。
组表在物理上会分成数据块,列式组表中每个字段至少占据一块,所以不适合存储太小量的数据。一块的大小通常是 1M。
组表是较复杂的文件格式,创建组表时要指定数据结构。特别地,组表通常会对某些关键字段有序,在创建组表时要指明有序字段,称为维字段,对于初学者可以把维字段简单理解为表的主键。维字段必须列在前面。
初学者可以将组表先简单地理解为增加了主键有序要求的、并以列式存储的数据库表,每个组表文件对应一个数据库表。
组表也可以支持分段,在上面建立多路游标做并行遍历。

组表可以追加数据,追加后仍要保持数据对维字段有序,如果追加数据不能自然满足对维字段有序的前提,就可能导致新追加数据和原有数据的重新排序,追加数据的耗时就 = 会较高。为了避免大量数据重排序,组表提供了补表机制,可以减少日常追加时排序重写的数据量,较长周期后才进行一次全量的重排。SPL 还提供了复组表机制可以将巨大数据量分段存储,减小每次重排序的规模。
保持对维字段有序后,可以实施很多种高性能算法,相当于用一次性的低效排序换取后续多次的高效查询计算。

组表可以少量插入和修改,但也较大程度地影响性能,所以建议 ** 初学者可以简单认为组表不可修改!** 只用于存储不再改变的历史数据,不要把组表当成数据库中的表那样可以随意增删改。即使是追加,也不要应用中随意多次追加很少量数据,而要在专门的 ETL 过程中一次性追加较大的数据量,以保持存储的紧凑性。
同样地,组表的写操作也没有共享冲突管理,不能并发写,也不可能边写边读。当然读和算都可以并发。

3. 有序存储实现大分组和大关联

有序存储是组表的关键,选择合适的维字段(有序字段)是后续高性能的关键。
常规的过滤和小结果集分组汇总对有序存储要求不高,合适的有序方案可能提高过滤的性能,但并不关键。

依赖于有序存储的运算主要是去重统计和主键连接。
去重统计时原则上要把所有已经遍历过的键值都保持住以判断再遍历到的键值是否是新的,键值数量很大时会占用巨大的内存而且也会消耗大量的对比时间。如果键值有序则可以不必保持历史遍历过的键值,也只要和相邻键值对比即可,无论空间复杂度和时间复杂度都会大大降低。
主键连接的两个表(更多也可以)通常非常大,如果无序,要么占用巨大内存将数据全部读入后,要么采用双边 HASH 分堆算法生成外存缓冲区后分批处理,无法获得空间复杂度和时间复杂度都好的算法。而如果数据对连接键值有序,则可以使用简单的归并算法,不仅占用内存非常小,而且计算量也比 HASH JOIN 小得多。

大多数情况,去重统计和主键连接涉及的键值都是类似用户帐户的字段,并不会随意指定一些字段进行这类运算。这样保持一份对帐户有序的数据就可以了,不必保持多份冗余数据。
对帐户内行为事件的复杂分析可以看成是去重运算和主键连接的延伸,这些分析任务有这么几个特点:1 帐户量非常大,2 每个帐户的数据量都不大,3 帐户之间无关。对于这类运算,如果数据对帐户有序,则可以用游标依次读入一个帐户的数据(可能是多表关联的)(回顾前面讲的游标读入机制)进行复杂的运算,完成后再读入并处理下一个帐户。典型的场景是电商的漏斗分析。SPL 非常擅长这类运算。
这类帐户分析(包括基本的去重统计和主键连接)还可以利用多路游标并行计算,这需要数据也能正确分段,要保证同一帐户的数据落在同一路游标下处理。SPL 在创建组表时可以用选项指定将第一字段用做分段字段,确保对组表分段时不会把分段字段相同的记录拆分到不同段。

参考:
如何让 JOIN 跑得更快?
双维有序结构提速大数据量用户行为分析
SPL 的有序存储

4. 内存维表及索引

外键连接涉及随机访问,一般不能使用预先排序的办法,只能读入到内存中。不过除帐户表(如果看成是维表的话)外,大多数维表都较小。而帐户表上的连可以看成是主键连接而采用有序存储的方案来解决。
所以,设计硬件环境时就要考虑运算中涉及的维表空间,硬件的内存要大到能把维表装入,否则运算就很难高效地进行了。可以用这个方法来估算所需内存容量。

SPL 可以内存维表上可以建立多种索引,用于快速定位。
如果内存允许,可以在机器启动时就一次性把所需要的维表旋入内存并建立好索引,以后每次运算时就不再临时读入和建索引,对于高性能并发查询业务非常有效。

但有些场景要考虑维表有变化,常规手段是在数据库中按日期生成 = 多份维表。维表有多份后通常就不可能全部读入内存了,这时候就要每次运算时分别读入和建索引,无法预先加载准备了,性能会受到影响。SPL 提供了时间维表的机制,可以只保存初始的维表和后续维表中变化的记录及发生变化的时刻,这会极大地减少内存占用,毕竟维表有变化是罕见事件。时间维表可以当普通维表一样,在和做外键连接时能正确地根据事实表记录的时刻找到关联的维表记录。

5. 外存查找与索引

外存索引(包括数据库的索引)通常只对定点查找有效,即返回很少量数据的查找,对大部分要遍历全量数据的运算都没有效果,不要对索引有太多期望。

SPL 对组表提供了外存索引,可以实现高速查找。
与遍历式任务不同,查找任务使用行式存储会更有优势。被查找到的记录放在一起一次读入,而不像列存那样被物理上按字段分放在多处要读取多次。

对于并发要求较高的帐户查询,还要把数据按帐户排序,保证同一帐户的数据在硬盘上存储在一起,这样就可以把一个帐户的数据一次读入,不会浪费读的内容。而索引通常只是逻辑有序,如果物理无序,仍然会导致硬盘到处跳读,无法获得高性能。