告别窗口函数,迎来 esProc SPL
早期 SQL 对有序计算极端不适应,理论上可以写,但实际的麻烦程度基本上等同于不能用。
在引入窗口函数后,有序计算得到了一定的改善,但 SQL 的基础还是无序集合,即使打了窗口函数这样的补丁也还是很麻烦。
但也没有什么更好的办法,基于数据库的运算还是要用 SQL 加窗口函数。把数据搬出来用 Python 可以缓解这些麻烦,但 Python 集成性不好,和 Java 应用不容易配合。直接用 Java 写的话,会比用窗口函数还麻烦。
用 esProc SPL 就可以从根本上解决这些问题。SPL 代码比 SQL 窗口函数更易理解,编写和调试都很容易,我们可以和麻烦的窗口函数说再见了。
这里通过具体的计算场景来看 SPL 如何完胜窗口函数。
比如要计算某股票上市后用了多少个交易日涨到 100 元以上。简单的思路是:把数据按照交易日排序后,找到第一个 100 元以上的记录位置,对应的序号就是计算目标。
用 SQL 窗口函数写出来:
SELECT MIN(NO)
FROM (
SELECT Price, ROW_NUMBER() OVER ( ORDER BY Date ) NO
FROM stock
)
WHERE Price>100
先用窗口函数计算出序号,再用外层查询做过滤并求最小值。
SQL 基于无序集合,数据表本身没有顺序和位置概念,对数据表排序也无法被窗口函数利用,只能把 ORDER BY 写到窗口函数中。这是 SQL 固有的问题,窗口函数也只能继承 SQL 这个缺点。
麻烦还不止于此,SQL 语句中的 NO 是一个计算列,却不能直接求最小值,要再嵌套一层查询才行。
不包含窗口函数的计算列是可以直接求最小值的,比如收盘价乘以当天汇率后求最小值:
SELECT MIN(Price*Rate) FROM stock
窗口函数只能基于其它运算的结果集再运算,经常要再嵌套一层查询,语法比普通 SQL 麻烦。
SPL 数据表有序,且有位置概念。对于日期有序的股票数据,可以按照前面提到的简单思路计算,写出来的代码极致简洁:
stock.pselect(Price > 100)
pselect 是 SPL 的位置计算函数,可以得到满足条件的第一个成员的位置,也就是该股票第一次涨到 100 元以上所用的交易日个数。
现在要进一步计算该股票第一次涨到超过 100 元以上时的涨幅。我们可以先找到第一条收盘价大于 100 元的记录位置,用这条记录收盘价和它前一条的相减就可以了,仍是很自然的思路。
用 SQL 窗口函数就更绕了:
WITH T AS (
SELECT Price,
ROW_NUMBER() OVER ( ORDER BY Date ) NO,
Price - LAG(Price) OVER ( ORDER BY Date) Rising
FROM Stock
)
SELECT Rising
FROM T WHERE NO = (
SELECT MIN(NO) FROM T WHERE Price>100
)
SQL 不能实现上面的先找到位置再算相邻差值的思路,要改成先计算所有行相关的差值再寻找位置的方法。而且由于 ORDER BY 只能写到窗口函数中,结果就要重复写两次。如果还有 PARTITION 之类也要重复写,这就造成代码中有很多重复信息,编写和阅读代码都会受到影响。
SPL 支持位置计算,就非常简单:
stock.calc(stock.pselect(Price>100),Price-Price[-1])
先用 pselect 计算出需要的记录位置,再计算这个位置上的收盘价和上一条记录收盘价的差值。SPL 支持跨行计算,Price[-1] 就是上一条记录的收盘价。
这种情况在现实中很常见,我们继续用股票计算来看更多例子。
求股票最长连涨区间的起止日期。
窗口函数的解法:
with t1 as (
select Date, Price,
case when Price>lag(Price) over(order by Date) then 0 else 1 end UpDownTag
from stock
),
t2 as (
select Date, Price, sum(UpDownTag) over(order by Date) NoRisingDays
from t1
),
t3 as (
select NoRisingDays, count(*) ContinuousDays
from t2 group by NoRisingDays
),
t4 as (
select *
from t2
where NoRisingDays in (
select NoRisingDays
from t3
where ContinuousDays =(select max(ContinuousDays) from t3)
)
)
select min(Date) start, max(Date) end
from t4
group by NoRisingDays;
嵌套多层子查询,窗口函数中相同的 order by 重复出现,很麻烦。
SPL 避免了难懂的窗口函数,代码非常简捷:
stock.group@i(Price<Price[-1]).maxp@a(~.len()).new(~(1).Date:start,~.m(-1).Date:end)
group@i 是按照条件变化有序分组,每组都是连涨区间。maxp@a 返回所有最长的连涨区间。~ 符号代表循环计算的当前成员。
找出所有股票中,最长连涨天数超过 5 天的股票。
SQL 解法:
select Code, max(ContinuousDays) as longestUpDays
from (
select Code, count(*) as ContinuousDays
from (
select Code, Date,
sum(UpDownTag) over (partition by Code order by Code, Date) as NoRisingDays
from (
select Code, Date,
case when Price > lag(Price) over (partition by Code order by Code, Date) then 0
else 1 end as UpDownTag
from stock
)
)
group by Code, NoRisingDays
)
group by Code
having max(ContinuousDays)>5;
嵌套四层子查询,相同的 partition 和 order 信息重复出现。
SPL 仍然很简单:
stock.group(Code;~.group@i(Price<=Price[-1]).max(~.len()):max_increase_days).select(max_increase_days>5)
计算股价高于前后 5 天时当天的涨幅。
窗口函数这样写:
SELECT Price/Price_pre-1 AS raise
FROM(
SELECT Date, Price, ROWNUM AS rn,
MAX(Price) OVER (
ORDER BY Date ROWS BETWEEN 5 PRECEDING AND 1 PRECEDING) AS max_pre,
MAX(Price) OVER (
ORDER BY Date ROWS BETWEEN 1 FOLLOWING AND 5 FOLLOWING) AS max_suf,
LAG(Price,1) OVER (ORDER BY Date) AS Price_pre
FROM stock
)
WHERE rn>5 AND rn<=(select count(*) FROM stock)-5 AND Price>max_pre
AND Price>max_suf;
窗口函数中相同的 order by 信息重复了三次,还要嵌套子查询。
SPL 仍然很简捷:
A |
|
1 |
=stock.pselect@a(#>4&&#<=stock.len()-4&&Price>max(Price[-4:-1])&&Price>max(Price[1:4])) |
2 |
=stock.calc(A2,Price/Price[-1]-1) |
#表示循环计算中当前的序号。Price[a:b] 表示股票有序集合中,从 #+a 到#+b 的收盘价集合。
其它业务场景中这样的情况也很普遍,不能尽述。
利用 SPL 的计算能力能避免窗口函数,这样可以让 SQL 只负责取数,复杂逻辑都交给 SPL 处理。比如上个例子,假设数据来自于 Mysql 数据库:
A |
|
1 |
=connect("mysql").query("select * from stock order by Date") |
2 |
=A1.pselect@a(#>4&&#<=A1.len()-4&&Price>max(Price[-4:-1])&&Price>max(Price[1:4])) |
3 |
=A1.calc(A2,Price/Price[-1]-1) |
A1 连接数据库,用 SQL 语句取数。
A2 开始用非常简捷的 SPL 代码完成复杂计算。
采用 SPL 后,应用中只要写简单 SQL,不必再使用复杂难懂的窗口函数了。而且,不同数据库窗口函数的写法是有差别的,这样做,还能解决异构数据库的兼容性问题,一举多得。