非常规聚合

标准 SQL 中提供了五种最常用的聚合运算:SUM/COUNT/AVG/MIN/MAX。观察这几个运算,我们发现它们都可以看成是一个以集合为参数返回单值的函数,我们就先把这个共同点理解为聚合运算的定义,把集合变成单值,多个值变成一个值,也就是发生了 " 聚合“,所以叫聚合运算。
那么很显然,有集合的时候就可以应用聚合运算了,所以 SUM/COUNT 这些运算可以针对一个数据表(记录集合)实施。
分组运算的结果是一批分组子集,那么每个子集上也可以应用聚合运算,这就是 SQL 的分组运算了。其实针对全集的聚合运算也可以理解为只分了一个组的特殊分组(也是个完全划分),这样理解后,我们可以认为聚合运算总是发生在分组运算之后(但分组运算后不一定总有聚合运算,前面已说过)。而且,还可以反过来说,只要被认定为是聚合运算(符合前述定义的运算),就一定可以用在分组之后。我们在下面会看到,这个理解将大幅度地扩展分组 + 聚合运算的应用范围。

除了这五种聚合运算外,有的数据库还提供了方差、标准差等聚合函数,其性质和这五种差不多,可以称为是常规的聚合运算。我们下面来研究业务上有意义的其它形式聚合运算。

返回记录
上述的常规聚合都是针对数值的运算,特别地,对于结构化数据来说,是针对某个字段(或表达式)的运算,返回值也是这些数值的运算结果。但有时候我们关心的不是结果数值本身,而是与结果数值相关的信息。
比如我们想从日志表中找出某个用户第一次登录时用的 IP 地址,而不是登录时刻。用标准 SQL 写这个运算大概是这样:

SELECT ip_address FROM  LogTable WHERE user=? AND logintime=
    (SELECT MIN(logintime) FROM LogTable WHERE user=?)

用子查询先计算出该用户的第一次登录的时刻,再查找出该时刻时用到的 IP 地址,这要把数据集遍历两次。
ORACLE 提供了一个 KEEP 函数,可以不用子查询写出这样的运算:

SELECT MIN(ip_address) KEEP(DENSE_RANK FIRST ORDER BY logintime) FROM LogTable WHERE user=?

但是,我们关心的可能还不止是 IP 地址,还可能是日志表中的其它字段,比如所用浏览器、是否移动端等,其实就是关心最小值对应的那条完整记录。而由于 SQL 缺乏离散性,即使有 KEEP 函数,也不容易写出这种运算,要么每个字段分别用 KEEP,要么还是用子查询遍历两次,都很繁琐。

如果有一个用于返回最大值 / 最小值对应记录而非值本身的聚合函数,那这个运算写起来就简单了,也只要遍历一次。SPL 就提供了一个 minp 函数,上面任务就可以这样写:

LogTable.select(user=?).minp(logintime)

象前面说的,这样的聚合运算还可以用在分组中,比如找出每个用户首次登录的日志记录:

LogTable.group(user).(~.minp(logintime))

类似地,SPL 还可以有 maxp 方法用于返回最大值对应记录。

日志记录常常本来就是按事件发生时刻有序,利用这个特点时就不需要再用比较来计算最小值了,而是直接取出第一条即可。

LogTable.select(user=?).m(1)

在分组中也可以,这要利用到我们前面讲过的有序分组结果集的次序:

LogTable.group(user).(~.m(1))

这种运算较为常见,SPL 把它做成了函数的选项,可以更简单地写出来:

LogTable.select@1(user=?)
LogTable.group@1(user)

SQL 建立在无序集合概念上,无法保证返回记录的次序,想写出这种运算就又需要人为制造序号后再用过滤条件来做。

返回集合
我们再把上面的问题改一下:找出一群人中年龄最小的那些人的姓名。
和前述问题不同的是,同一个用户不会有多个相同的登录时间,但一批人中则可能有年龄相同的人,年龄最小的人可能不止一个。minp 函数的返回值应当是一个集合才合理。
重新考虑我们开始对聚合运算的定义,我们会发现,其实返回单值的要求并无必要,只要参数是集合,随便返回什么东西都可以认定为是聚合运算,这种定义下,允许 minp/maxp 函数返回集合、并仍然作为聚合运算处理就可以了。

需要返回集合的聚合运算中,更常见是 TopN。
SQL 并不把 TopN 理解成一种聚合运算,而只是返回结果集时的一种修饰符。原理上,SQL 会先把完整的结果集计算出来,然后再只取前 N 条返回。TopN 总是在排序动作之后,大集合的排序是个时间成本很高的动作,但其实只做 TopN 并不需要全集的排序,这时候只能指望数据库的优化器了。另外,只作为结果集的修饰,那就不能把这个运算实施到分组子集上了,分组 TopN 和全集 TopN 的写法完全不同,而且这时候运算复杂化了,优化器也常常会失效,结果会跑得很慢。

SPL 把 TopN 理解成聚合运算,很多事就会变得很轻松。比如想计算用户最后两次登录的时间间隔:

a=LogTable.select(user=?).top(logingtime,-2), a(2)-a(1)			//某用户最后的两次登录时间间隔
LogTable.groups(user;(a=~.top(logintime,-2),a(2)-a(1)))			//每个用户最后的两次登录时间间隔

而且实施计算也不需要刻意地工程上优化,在分组后使用也能获得高性能。

TopN 也有返回记录的情况,即取出某个字段(表达式)在前 N 名的对应记录。和 minp/maxp 类似,SPL 的 top 函数也支持这种功能。
同样的,有序情况也会发生,象前面的日志计算,如果假定日志表已经针对事件时刻有序,那计算 TopN 可以不必再做比较运算了。

a=LogTable.select(user=?).top(0,-2),a(2)-a(1)				// 用0表示不再比较
LogTable.groups(user;(a=~.top(0,-2),a(2)-a(1)))

这里讨论了非常规聚合的两种常见情况:返回记录和返回集合,都是 SQL 不直接支持的。当然按照定义还会有更多形式的聚合运算,即使这两种情况也还会有许多变种,比如取出排序位置居中的成员、取出针对某一字段的唯一值(DISTINCT)集合等。深入理解聚合运算及其与分组运算的关系,将能够扩展这些运算的应用范围,对计算的描述和实施都有不小的意义。
也能够理解 SPL 与 SQL 的不同。