结构化数据的计算处理一直以 SQL 为主。不过随着技术的不断发展,现在经常为了满足扩展性的需要而使用 Java 来处理数据。这样可以显著降低数据库与应用的耦合性(数据库仅用于存储),整体架构更加灵活,应用扩展和维护都比较方便。
而在大数据时代,计算性能又是避不开的话题,一项技术如果跑得太慢会把其他所有好处都抵消掉。
一个后端工程师老生常谈的问题是:Java 和 SQL 做数据运算哪个更快?
从数据处理方面来看,SQL 不仅使用更加广泛,一般在性能上也更有优势。SQL 的高性能主要源于以下两方面:
1. 优化引擎。SQL 作为声明式语言,通常用户只需要描述需要完成的任务,而不需要关心具体的实现细节。数据库会根据 SQL 语句的描述自动优化查询计划和执行方式,从而提高查询效率,这就是优化引擎的作用。数据库对于常规运算都有很成熟的算法,很多计算在优化引擎的帮助下可以快速完成。
2. 数据存储。计算和数据存储密不可分,而数据库集二者于一身。有了存储就可以在工程上实施很多提速手段,如索引、缓存、分区、冗余等。特别是以 AP 为主的数据仓库还可以针对计算而设计专门的存储(如列存),存储与算法相互配合就可以实现高效率。
不过,SQL 也有力有不逮的时候。由于 SQL 描述能力的局限,很多复杂查询要采取迂回的方法,写出来很繁琐。更重要的是,一旦 SQL 语句的复杂度上来,优化引擎就很难发挥作用了(猜不出目标只能按照字面表达去执行,性能很差),因此优化引擎仅对简单情况有效。而能让优化器失效的 SQL 复杂度其实很低。
另一方面,SQL 数据类型和算法不够全面,如果要用到超出范围的算法就需要自己实现,如编写自定义函数(UDF)。但当基础数据类型不支持,或需要根据计算特征设计存储时 UDF 也无能为力。
这里我们举个例子,TopN 运算时:
SELECT TOP 10 x FROM T ORDER BY x DESC
select * from
(select y,*,row_number() over (partition by y order by x desc) rn from T)
where rn<=10
SQL 由于缺乏显式的集合数据类型,数据库也不会提供把 TopN 当作聚合运算的算法,就只能寄希望于优化器足够强大,但结果却常常不尽人意。
再比如,电商计算用户流失率的漏斗分析,由于涉及多步和相互依赖的顺序,使用 SQL 要借助子查询实现:
with e1 as (
select uid,1 as step1,min(etime) as t1
from event
where etime>= to_date('2021-01-10') and etime<to_date('2021-01-25')
and eventtype='eventtype1' and …
group by 1),
e2 as (
select uid,1 as step2,min(e1.t1) as t1,min(e2.etime) as t2
from event as e2
inner join e1 on e2.uid = e1.uid
where e2.etime>= to_date('2021-01-10') and e2.etime<to_date('2021-01-25')
and e2.etime > t1 and e2.etime < t1 + 7
and eventtype='eventtype2' and …
group by 1),
e3 as (
select uid,1 as step3,min(e2.t1) as t1,min(e3.etime) as t3
from event as e3
inner join e2 on e3.uid = e2.uid
where e3.etime>= to_date('2021-01-10') and e3.etime<to_date('2021-01-25')
and e3.etime > t2 and e3.etime < t1 + 7
and eventtype='eventtype3' and …
group by 1)
select
sum(step1) as step1,
sum(step2) as step2,
sum(step3) as step3
from
e1
left join e2 on e1.uid = e2.uid
left join e3 on e2.uid = e3.uid
很显然,天底下没有什么都好的事情。SQL(数据库)的“固有”优化算法和“自动”优化能力有时让我们使用起来的确很轻松,不必关心执行步骤,写好 SQL 扔过去就好了。但当我们想要干预其执行路径的时候会发现非常困难,更别提让数据库执行我们设计的算法了。这种不灵活加上架构上的缺点(耦合紧、扩展性差)经常会抵消掉 SQL 在性能上的一些优势,让使用者望而却步。
Java 数据运算
相比之下,Java 作为一个功能完善的编程语言,其灵活性就要强很多了。我们不仅可以使用 Java 实现复杂计算,还可以编写各种高性能算法来提升计算性能,毕竟没有什么是代码解决不了的。
像前面提到的组内 TopN 使用 Java 实现时就可以不用大排序(借助一个分组长度的集合,保持集合成员是该分组最大的 N 个成员即可),遍历一次就可以完成计算。漏斗分析也没问题,每次读出一个用户的行为数据再按次序统计漏斗即可。
但是,这里还有一个关键,算法不仅要想到还要能实现,而这些算法写起来并不容易,很多应用程序员缺乏这种能力。像两表连接运算,最容易想到也最容易实现的就是两层循环,这样的计算复杂度是 n*m。
如果想快,可以用 HASH JOIN,复杂度会降低 K 倍(如果 HASH 能平均分成 K 份),但这个算法很多应用程序员就不会写。更进一步,如果知道数据有序,还可以使用有序归并,复杂度会降到 n+m,但实现的难度依然很高。
这还只是两个表,如果是多个表就会会更困难,最后通常会采用好写的低性能算法,导致用 Java 实现的常规运算跑不过 SQL 的情况。
Java 实现这些算法之所以很难写,一方面是因为这些算法本身有难度,另一方面也是因为 Java 过于原生,没有提供足够丰富的结构化计算类库,要实现这些数据计算几乎都要从头来做,实现起来很繁琐,对人员要求很高,很多人不会,即使会也可能懒得去写。比如常见的分组汇总用 Java 实现:
Map<Integer, Double> sellerAmountMap = new HashMap<>();
Iterator<Order> iterator = orders.iterator();
while (iterator.hasNext()) {
Order order = iterator.next();
int sellerId = order.getSellerId();
double amount = order.getAmount();
if (sellerAmountMap.containsKey(sellerId)) {
double currentAmount = sellerAmountMap.get(sellerId);
amount += currentAmount;
}
sellerAmountMap.put(sellerId, amount);
}
for (Map.Entry<Integer, Double> entry : sellerAmountMap.entrySet()) {
System.out.println("Seller ID: " + entry.getKey() + ", Total Amount: " + entry.getValue());
}
Java8 以后的 Stream 提供了一些计算类库:
Map<Object, DoubleSummaryStatistics> c=Orders.collect(Collectors.groupingBy(r->r.SellerId,Collectors.summarizingDouble(r->r.Amount)));
for(Object sellerid:c.keySet()){
DoubleSummaryStatistics r =c.get(sellerid);
System.out.println("group is:"+sellerid+" sum is:"+r.getSum());
}
多字段分组汇总(按年份和 Client 分组):
Calendar cal=Calendar.getInstance();
Map<Object, DoubleSummaryStatistics> c=Orders.collect(Collectors.groupingBy(
r->{
cal.setTime(r.OrderDate);
return cal.get(Calendar.YEAR)+"_"+r.SellerId;
},
Collectors.summarizingDouble(r->{
return r.Amount;
})));
for(Object sellerid:c.keySet()){
DoubleSummaryStatistics r =c.get(sellerid);
String year_sellerid[]=((String)sellerid).split("_");
System.out.println("group is (year):"+year_sellerid[0]+"\t (sellerid):"+year_sellerid[1]+"\t sum is:"+r.getSum());
}
Java 还有一个问题,没有通行的高效存储,经常还要借助数据库作为存储介质,这也会导致计算性能低下。Java 通过 JDBC 将数据读出来才能算,但大部分数据库提供的 JDBC 效率并不高,当数据量较大时 IO 成本就很高,即使最后计算很快,整体时间仍然很长(IO 成本大于计算成本)。
如果有一套不依赖数据库的高效存储(如文件系统)机制,就可以避免数据库的 IO 效率问题了。
即使有些场景下数据源或目标必须使用数据库,必须忍受数据库的低效 IO,高效的自有存储仍然对提高性能仍有意义。大数据计算有时会涉及写缓存(如大排序),还有一些复杂业务在计算过程中要将中间结果落地,这些缓存或中间数据如果还写入数据库成本就太高了,数据库写入比读取要慢得多,写完还要读一来一回时间开销非常大,借助独立存储来写缓存或存储中间结果就可以有效解决数据库频繁读写带来的问题。
此外,一些数据库尤其是为 AP 服务的数据仓库,历史冷数据通常可以从数据库复制一份到外部存储以获得更高计算性能,如果总是要从数据库中读取大量历史数据参与计算,那就会常常发生 IO 时间远远超过计算时间的尴尬局面。
采用文件来做缓存是否可行?毕竟 Java 读写文件也并不费劲。文件的读写效率也远高于数据库,理论上是没有问题的,但实现一个高效的文件存储机制依然像上面的算法一样,又难又繁。
最简单的办法是使用文本,这很通用,但读写效率却很低。原因在于数据类型解析上,各种数据要解析成对应的类型非常耗时,其中又以字符串和日期最慢。
那么把对象序列化后写成二进制的是不是就可以了?也没那么简单。虽然这解决了数据类型解析的问题,但未必比文本高效,其占用的空间有可能比文本更大(比如 long 的 1 会占 8 个字节,比文本的 1 还长),虽然减少了数据类型解析时间,但又多了硬盘读写时间,很难说是否能提升性能。一个高效存储还要考虑压缩编码、列存甚至索引等一切可以提升使用效率的手段,综合这些方面以后开发工作可以预见到非常困难。
全新的解决方案:SPL
我们看到,SQL 和 Java 的优缺点基本是对着的,SQL 的优点恰恰是 Java 的缺点,反之亦然。但二者都面临性能问题,SQL 性能低时我们没法干预,Java 虽然可以干预但过于困难,也相当于没法做了。
那有没有办法同时兼顾 SQL 和 Java 的优点呢?既能享受 SQL 的简单,又能拥有 Java 的灵活,同时还能获得高性能。
SPL(Structured Process Language)提供了这样的能力。作为专门用于结构化数据处理的编程语言,SPL 提供了丰富的结构化数据计算类库,封装了众多高性能算法可以直接使用,充分保持了 SQL 优势;由于 SPL 语法简单灵活,可以比 Java 更方便实现各类复杂计算。SPL 提供了自有的文件存储,可以有效克服数据库 IO 效率问题。同时,SPL 具备良好的集成性,可以嵌入在应用内实施数据处理,获得与 Java 一样的灵活性。
计算类库
SPL 提供了专业的结构化数据对象序表,并在序表的基础上提供了丰富的计算类库。包括常规的过滤、分组、排序、去重、连接等计算,比如一般的:
Orders.sort(Amount) // 排序
Orders.select(Amount*Quantity>3000 && like(Client,"*S*")) // 过滤
Orders.groups(Client; sum(Amount)) // 分组
Orders.id(Client) // 去重
join(Orders:o,SellerId ; Employees:e,EId) // 连接
……
有了这些丰富的数据类型和计算类库,SPL 实施计算不仅代码简洁,性能也高。像前面提到的 TopN 问题,可以这样写:
T.groups(y;top(-5;x))
再比如前面的漏斗计算,用 SPL 来做:
这段 SPL 代码的运行性能也很出色。同样数据规模(4 亿行),SPL 在一个 12 核 1.7G 的低端服务器上仅用了不到 10 秒,比数据库快了至少 20 倍,如果算上硬件的差异这个差距还要更大。
类似的,SPL 还提供了很多这样的高性能算法。包括:
有了这些算法,我们根据不同的应用场景直接使用即可,不必像 Java 一样从头开发,而且 SPL 提供的算法比 SQL 更为丰富。比如像前面提到的连接运算,SPL 做了更细致的划分。对于多对一的外键关联可以使用外键地址化、临时地址化、外键序号化等方式,如果维表比较大则可以使用大维表查找技术完成连接,如果维表和事实表都很大则可以采用单边分堆算法;而对于一对一的同维关联和多对一的主子关联则可以采用有序归并,如果数据量过大还可以通过分段并行的方式进一步提升性能。
从计算类库的角度来看,SPL 有了这些比 Java 和 SQL 丰富的算法以后,数据处理的工作就是针对这些算法的选用和组合,简单方便的同时性能也更高。
自有存储
只有计算类库还不够,如果像 Java 一样使用数据库作为存储仍然会面临 IO 开销大于计算开销的问题。为此,SPL 设计了自有格式的二进制文件存储,不仅是简单的二进制化,而是集编码、压缩、列存、索引、分段等多种机制于一身的高效文件格式。
目前 SPL 提供了两种高性能文件类型:集文件和组表。集文件采用了压缩技术(占用空间更小读取更快),存储了数据类型(无需解析数据类型读取更快),支持可追加数据的倍增分段机制,利用分段策略很容易实现并行计算,保证计算性能。组表支持列式存储,在参与计算的列数(字段)较少时会有巨大优势。组表上还实现了 minmax 索引,同时支持倍增分段,这样不仅能享受到列存的优势,也更容易并行提升计算性能。
有了高性能存储,我们就可以把数据从数据库迁移到文件中以获得更高计算性能。即使数据无法从数据库迁出,使用 SPL 高性能存储用来写缓存或中间计算结果也很有意义。而历史冷数据也可以复制一份到文件存储中用于高效计算。
除了性能上的优势,使用文件存储还可以获得更低廉的成本,文件采用文件系统的树状目录也更方便管理。使用文件存储也更容易实现存算分离,针对存储或计算单独进行扩展,更符合现代应用架构的需要。
SPL 并不强制要求使用自有格式文件存储,SPL 具备良好的开放性可以对接多种数据源。在注重计算性能和数据实时性时,可以将冷数据固化到 SPL 存储,热数据仍然从数据库中读取,从而实现 T+0 查询以及 HTAP。
答案是肯定的。
SPL 具备良好的集成性,可以将 SPL 嵌入集成到应用中作为计算引擎使用,获得与 Java 一样的灵活性,在微服务等应用场景中发挥作用。
SPL 提供了标准 JDBC 接口,可以像调用存储过程一样请求 SPL 计算结果。
JDBC 调用 SPL 代码示例:
Class.forName("com.esproc.jdbc.InternalDriver");
Connection conn =DriverManager.getConnection("jdbc:esproc:local://");
CallableStatement st = conn.prepareCall("{call splscript(?, ?)}");
st.setObject(1, 3000);
st.setObject(2, 5000);
ResultSet result=st.execute();
GitHub:https://github.com/SPLWare/esProc