Java 嵌入数据引擎:从 SQLite 到 SPL
可以在 Java 应用中嵌入的数据引擎看起来比较丰富,但其实并不容易选择。Redis 计算能力很差,只适合简单查询的场景。Spark 架构复杂沉重,部署维护很是麻烦。H2\HSQLDB\Derby 等内嵌数据库倒是架构简单,但计算能力又不足,连基本的窗口函数都不支持。
相比之下,SQLite 在架构性和计算能力上取得了较好的平衡,是应用较广的 Java 嵌入数据引擎。
SQLite适应常规基本应用场景
SQLite 架构简单,其核心虽然是 C 语言开发的,但封装得比较好,对外呈现为一个小巧的 Jar 包,能方便地集成在 Java 应用中。SQLite 提供了 JDBC 接口,可以被 Java 调用:
Connection connection = DriverManager.getConnection("jdbc:sqlite::memory:");
Statement st = connection.createStatement();
st.execute("restore from d:/ex1");
ResultSet rs = st.executeQuery("SELECT * FROM orders");
SQLite 提供了标准的 SQL 语法,常规的数据处理和计算都没有问题。特别地,SQLite 已经能支持窗口函数,可以方便地实现很多组内运算,计算能力比其他内嵌数据库更强。
SELECT x, y, row_number()OVER (ORDER BY y) AS row_number FROM t0 ORDER BY x;
SELECT a, b, group_concat(b, '.') OVER (ORDER BY a ROWS BETWEEN 1 PRECEDING AND 1 FOLLOWING) AS group_concat FROM t1;
SQLite面对复杂场景尚有不足
SQLite 的优点亮眼,但对于复杂应用场景时还是有些缺点。
Java 应用可能处理的数据源多种多样,比如 csv 文件、RDB、Excel、Restful,但 SQLite 只处理了简单情况,即对 csv 等文本文件提供了直接可用的命令行加载程序:
.import --csv --skip 1 --schema temp /Users/scudata/somedata.csv tab1
对于其他大部分数据源,SQLite 都没有提供方便的接口,只能硬写代码加载数据,需要多次调用命令行,整个过程很繁琐,时效性也差。
以加载 RDB 数据源为例,一般的做法是先用 Java 执行命令行,把 RDB 库表转为 csv;再用 JDBC 访问 SQLite,创建表结构;之后用 Java 执行命令行,将 csv 文件导入 SQLite;最后为新表建索引,以提高性能。这个方法比较死板,如果想灵活定义表结构和表名,或通过计算确定加载的数据,代码就更难写了。
类似地,对于其他数据源,SQLite 也不能直接加载,同样要通过繁琐地转换过程才可以。
SQL 接近自然语言,学习门槛低,容易实现简单的计算,但不擅长复杂的计算,比如复杂的集合计算、有序计算、关联计算、多步骤计算。SQLite 采用 SQL 语句做计算,SQL 优点和缺点都会继承下来,勉强实现这些复杂计算的话,代码会显得繁琐难懂。
比如,某只股票最长的上涨天数,SQL 要这样写:
select max(continuousDays)-1
from (select count(*) continuousDays
from (select sum(changeSign) over(order by tradeDate) unRiseDays
from (select tradeDate,
case when price>lag(price) over(order by tradeDate) then 0 else 1 end changeSign from AAPL) )
group by unRiseDays)
这也不单是 SQLite 的难题,事实上,由于集合化不彻底、缺乏序号、缺乏对象引用等原因,其他 SQL 数据库也不擅长这些运算。
业务逻辑由结构化数据计算和流程控制组成,SQLite 支持 SQL,具有结构化数据计算能力,但 SQLite 没有提供存储过程,不具备独立的流程控制能力,也就不能实现一般的业务逻辑,通常要利用 Java 主程序的判断和循环语句。由于 Java 没有专业的结构化数据对象来承载 SQLite 数据表和记录,转换过程麻烦,处理过程不畅,开发效率不高。
前面提过,SQLite 内核是 C 程序,虽然可以被集成到 Java 应用中,但并不能和 Java 无缝集成,和 Java 主程序交换数据时要经过耗时的转换才能完成,在涉及数据量较大或交互频繁时性能就会明显不足。同样因为内核是 C 程序,SQLite 会在一定程度上破坏 Java 架构的一致性和健壮性。
对于 Java 应用来讲,原生在 JVM 上的 esProc SPL 是更好的选择。
SPL全面支持各种数据源
esProc SPL 是 JVM 下开源的嵌入数据引擎,架构简单,可直接加载数据源,可以通过 JDBC 接口被 Java 集成调用,并方便地进行后续计算。
SPL 架构简单,无须独立服务,只要引入 SPL 的 Jar 包,就可以部署在 Java 环境中。
直接加载数据源,代码简短,过程简单,时效性强。比如加载 Oracle:
A |
|
1 |
=connect("orcl") |
2 |
=A1.query@x("select OrderID,Client,SellerID,OrderDate,Amount from orders order by OrderID") |
3 |
>env(orders,A2) |
对于 SQLite 擅长加载的 csv 文件,SPL 也可以直接加载,使用内置函数而不是外部命令行,稳定且效率高,代码更简短:
=T("/Users/scudata/somedata.csv")
多种外部数据源。除了 RDB 和 csv,SPL 还直接支持 txt\xls 等文件,MongoDB、Hadoop、redis、ElasticSearch、Kafka、Cassandra 等 NoSQL,以及 WebService XML、Restful Json 等多层数据。比如,将 HDSF 里的文件加载到内存:
A |
||
1 |
=hdfs_open(;"hdfs://192.168.0.8:9000") |
|
2 |
=hdfs_file(A1,"/user/Orders.csv":"GBK") |
|
3 |
=A2.cursor@t() |
|
4 |
=hdfs_close(A1) |
|
5 |
>env(orders,A4) |
JDBC 接口可以方便地集成。加载的数据量一般比较大,通常在应用的初始阶段运行一次,只须将上面的加载过程存为 SPL 脚本文件,在 Java 中以存储过程的形式引用脚本文件名:
Class.forName("com.esproc.jdbc.InternalDriver");
Connection conn =DriverManager.getConnection("jdbc:esproc:local://");
CallableStatement statement = conn.prepareCall("{call init()}");
statement.execute();
SPL的计算能力更强大
SPL 提供了丰富的计算函数,可以轻松实现日常计算。SPL 支持多种高级语法,大量的日期函数和字符串函数,很多用 SQL 难以表达的计算,用 SPL 都可以轻松实现,包括复杂的有序计算、集合计算、分步计算、关联计算,以及带流程控制的业务逻辑。
丰富的计算函数。SPL 可以轻松实现各类日常计算:
A |
B |
|
1 |
=Orders.find(arg_OrderIDList) |
// 多键值查找 |
2 |
=Orders.select(Amount>1000 && like(Client,\"*S*\")) |
// 模糊查询 |
3 |
= Orders.sort(Client,-Amount) |
// 排序 |
4 |
= Orders.id(Client) |
// 去重 |
5 |
=join(Orders:O,SellerId; Employees:E,EId) |
// 关联 |
标准 SQL 语法。SPL 也提供了 SQL-92 标准的语法,比如分组汇总:
$select year(OrderDate) y,month(OrderDate) m, sum(Amount) s,count(1) c
from {Orders}
Where Amount>=? and Amount<? ;arg1,arg2
函数选项、层次参数等方便的语法。功能相似的函数可以共用一个函数名,只用函数选项区分差别,比 SQL 更加灵活方便。比如 select 函数的基本功能是过滤,如果只过滤出符合条件的第 1 条记录,可使用选项 @1:
T.select@1(Amount>1000)
二分法排序,即对有序数据用二分法进行快速过滤,使用 @b:
T.select@b(Amount>1000)
有序分组,即对分组字段有序的数据,将相邻且字段值相同的记录分为一组,使用 @b:
T.groups@b(Client;sum(Amount))
函数选项还可以组合搭配,比如:
Orders.select@1b(Amount>1000)
结构化运算函数的参数有些很复杂,比如 SQL 就需要用各种关键字把一条语句的参数分隔成多个组,但这会动用很多关键字,也使语句结构不统一。SPL 使用层次参数简化了复杂参数的表达,即通过分号、逗号、冒号自高而低将参数分为三层:
join(Orders:o,SellerId ; Employees:e,EId)
更丰富的日期和字符串函数。除了常见函数,比如日期增减、截取字符串,SPL 还提供了更丰富的日期和字符串函数,在数量和功能上远远超过了 SQL,同样运算时代码更短。比如:
季度增减:elapse@q("2020-02-27",-3) // 返回 2019-05-27
N 个工作日之后的日期:workday(date("2022-01-01"),25) // 返回 2022-02-04
字符串类函数,判断是否全为数字:isdigit("12345") // 返回 true
取子串前面的字符串:substr@l("abCDcdef","cd") // 返回 abCD
按竖线拆成字符串数组:"aa|bb|cc".split("|") // 返回 ["aa","bb","cc"]
SPL 还支持年份增减、求季度、按正则表达式拆分字符串、拆出 SQL 的 where 或 select 部分、拆出单词、按标记拆 HTML 等大量函数。
简化有序运算。涉及跨行的有序运算,通常都有一定的难度,比如比上期和同期比。SPL 使用 "字段 [相对位置]" 引用跨行的数据,可显著简化代码,还可以自动处理数组越界等特殊情况,比 SQL 窗口函数更加方便。比如,追加一个计算列 rate,计算每条订单的金额增长率:
=T.derive(AMOUNT/AMOUNT[-1]-1: rate)
综合运用位置表达式和有序函数,很多 SQL 难以实现的有序运算,都可以用 SPL 轻松解决。比如,根据考勤表,找出连续 4 周每天均出勤达 7 小时的学生:
A |
|
1 |
=Student.select(DURATION>=7).derive(pdate@w(ATTDATE):w) |
2 |
=A1.group@o(SID;~.groups@o(W;count(~):CNT).select(CNT==7).group@i(W-W[-1]!=7).max(~.len()):weeks) |
3 |
=A2.select(weeks>=4).(SID) |
简化集合运算,SPL 的集合化更加彻底,配合灵活的语法和强大的集合函数,可大幅简化复杂的集合计算。比如,在各部门找出比本部门平均年龄小的员工:
A |
|
1 |
=Employees.group(DEPT; (a=~.avg(age(BIRTHDAY)),~.select(age(BIRTHDAY)<a)):YOUNG) |
2 |
=A1.conj(YOUNG) |
计算某支股票最长的连续上涨天数:
A |
|
1 |
=a=0,AAPL.max(a=if(price>price[-1],a+1,0)) |
简化关联计算。SPL 支持对象引用的形式表达关联,可以通过点号直观地访问关联表,避免使用 JOIN 导致的混乱繁琐,尤其适合复杂的多层关联和自关联。比如,根据员工表计算女经理的男员工:
=employees.select(gender:"male",dept.manager.gender:"female")
方便的分步计算,SPL 集合化更加彻底,可以用变量方便地表达集合,适合多步骤计算,SQL 要用嵌套表达的运算,用 SPL 可以更轻松实现。比如,找出销售额累计占到一半的前 n 个大客户,并按销售额从大到小排序:
A |
B |
|
2 |
=sales.sort(amount:-1) |
/销售额逆序排序,可在SQL中完成 |
3 |
=A2.cumulate(amount) |
/计算累计序列 |
4 |
=A3.m(-1)/2 |
/最后的累计即总额 |
5 |
=A3.pselect(~>=A4) |
/超过一半的位置 |
6 |
=A2(to(A5)) |
/按位置取值 |
流程控制语法。SPL 提供了流程控制语句,配合内置的结构化数据对象,可以方便地实现各类业务逻辑。
分支判断语句:
A |
B |
|
2 |
… |
|
3 |
if T.AMOUNT>10000 |
=T.BONUS=T.AMOUNT*0.05 |
4 |
else if T.AMOUNT>=5000 && T.AMOUNT<10000 |
=T.BONUS=T.AMOUNT*0.03 |
5 |
else if T.AMOUNT>=2000 && T.AMOUNT<5000 |
=T.BONUS=T.AMOUNT*0.02 |
循环语句:
A |
B |
|
1 |
=db=connect("db") |
|
2 |
=T=db.query@x("select * from sales where SellerID=? order by OrderDate",9) |
|
3 |
for T |
=A3.BONUS=A3.BONUS+A3.AMOUNT*0.01 |
4 |
=A3.CLIENT=CONCAT(LEFT(A3.CLIENT,4), "co.,ltd.") |
|
5 |
… |
与 Java 的循环类似,SPL 还可用 break 关键字跳出(中断)当前循环体,或用 next 关键字跳过(忽略)本轮循环,不展开说了。
计算性能更好。在内存计算方面,除了常规的主键和索引外,SPL 还提供了很多高性能的数据结构和算法支持,比大多数使用 SQL 的内存数据库性能好得多,且占用内存更少,比如预关联技术、并行计算、指针式复用。
优化体系结构
SPL 支持 JDBC 接口,代码可外置于 Java,耦合性更低,也可内置于 Java,调用更简单。SPL 支持解释执行和热切换,代码方便移植和管理运营,支持内外存混合计算。
外置代码耦合性低。SPL 代码可外置于 Java,通过文件名被调用,既不依赖数据库,也不依赖 Java,业务逻辑和前端代码天然解耦。
对于较短的计算,也可以像 SQLite 那样合并成一句,写在 Java 代码中:
Class.forName("com.esproc.jdbc.InternalDriver");
Connection conn =DriverManager.getConnection("jdbc:esproc:local://");
Statement statement = conn.createStatement();
String arg1="1000";
String arg2="2000"
ResultSet result = statement.executeQuery(=Orders.select(Amount>="+arg1+" && Amount<"+arg2+"). groups(year(OrderDate):y,month(OrderDate):m; sum(Amount):s,count(1):c)");
解释执行和热切换。业务逻辑数量多,复杂度高,变化是常态。良好的系统构架,应该有能力应对变化的业务逻辑。SPL 是基于 Java 的解释型语言,无须编译就能执行,脚本修改后立即生效,支持不停机的热切换,适合应对变化的业务逻辑。
方便代码移植。SPL 通过数据源名从数据库取数,如果需要移植,只要改动配置文件中的数据源配置信息,而不必修改 SPL 代码。SPL 支持动态数据源,可通过参数或宏切换不同的数据库,从而进行更方便的移植。为了进一步增强可移植性,SPL 还提供了与具体数据库无关的标准 SQL 语法,使用 sqltranslate 函数可将标准 SQL 转为主流方言 SQL,仍然通过 query 函数执行。
方便管理运营。由于支持库外计算,代码可被第三方工具管理,方便团队协作;SPL 脚本可以按文件目录进行存放,方便灵活,管理成本低;SPL 对数据库的权限要求类似 Java,不影响数据安全。
内外存混合计算。有些数据太大,无法放入内存,但又要与内存表共同计算,这种情况可利用 SPL 实现内外存混合计算。比如,主表 orders 已加载到内存,大明细表 orderdetail 是文本文件,下面进行主表和明细表的关联计算:
A |
|
1 |
=file("orderdetail.txt").cursor@t() |
2 |
=orders.cursor() |
3 |
=join(A1:detail,orderid ; A2:main,orderid) |
4 |
=A3.groups(year(main.orderdate):y; sum(detail.amount):s) |
SQLite 使用简单方便,但数据源加载繁琐,计算能力不足。SPL 架构也非常简单,并直接支持更多数据源。SPL 计算能力强大,提供了丰富的计算函数,可以轻松实现 SQL 不擅长的复杂计算。SPL 还提供多种优化体系结构的手段,代码既可外置也可内置于 Java,支持解释执行和热切换,方便移植和管理运营,并支持内外存混合计算。
中文版