怎样用 esProc 加速 COUNT DISTINCT

SQL 中的去重计数 COUNT DISTINCT 一直比较慢。

去重本质上是分组运算,需要把遍历过的分组字段值都保持住,用于后续的比对。结果集太大时,还要把数据写到硬盘上做缓存,性能低下。

如果先将数据按照去重字段排序,计算有序去重就会简单很多,只要在遍历过程中,把与上一条不相同的字段保存下来,并把计数值加 1 即可,不需要保持结果集,更不必做外存缓存。

但数据库无法保证存储的次序,很难实施这样的有序去重算法。esProc SPL 可以把数据导出后有序存储,实现这种高性能的有序去重计数。

下面,通过实际例子比较一下 esProc SPL 和 MYSQL 数据库计算去重计数的性能。

测试环境:普通笔记本电脑,2 核 CPU,8G 内存,SSD 硬盘。操作系统是 Win11,MYSQL 版本是 8.0。

数据库中的事件表 events 有 1 千万条记录,包括字段:事件号 event_id、用户号 user_id、时间 event_time、事件名称 event_name。

用户表 users,12 万数据,包括字段:user_id、用户级别 user_level。

先下载 esProc https://www.esproc.com/download-esproc/,用标准版就可以了。

安装 esProc 后,试一下 IDE 是否可以正常访问数据库。先把 MYSQL 数据库的 JDBC 放到目录 "[安装目录]\common\jdbc",这是 esProc 的类路径之一:

..

在 esProc 中建立 MYSQL 数据源:

..

..

返回到数据源界面并连接刚才配置的数据源,如果数据源名变成粉色,说明配置成功。

在 IDE 中新建脚本,编写 SPL 语句,连接 数据库,通过简单 SQL 加载 users 表的数据:


A B
1 =connect("mysql16")
2 =A1.cursor@x("select user_id,user_level from users order by user_id")

数据量比较大,这里采用游标方式。按 ctrl-F9 执行,点击 A2 格后,在右边点击 Load data 可以看到前 100 条数据:

..

从数据库简单加载数据后,就可以用 SPL 导出数据有序存储了:


A B
1 =connect("mysql16")
2 =A1.cursor("select user_id,user_level from users order by user_id")
3 =file("users.ctx").create(#user_id,user_level)
4 =A3.append(A2) >A3.close()
5 =A1.cursor("select user_id,event_id,event_time,event_name from events order by user_id")
6 =file("events.ctx").create@p(#user_id,event_id,event_time,event_name)
7 =A6.append(A5) >A6.close()
8 >A1.close()

A3 和 A6 中定义 SPL 的组表,字段前面带 #表示维,维必须有序。

A6 中 create 函数的 @p 参数表示第一个字段 user_id 是分段键,这样可以防止分段读取时,把一个 user_id 的记录拆分开。

例一,先看最简单的情况。对 events 表的 user_id 去重计数。

SQL:

select count(distinct user_id) from events;

执行时间:16 秒。

SPL 则采用有序去重计数的写法:


A
1 =file("events.ctx").open().cursor@m(user_id).group@1(user_id).skip()

user_id 有序,游标的 group 函数采用有序分组方式,归并相邻且相同的 user_id。@1 选项表示每组只取第一条记录,这样不用建立分组子集,速度更快。

执行时间:0.4 秒。

SPL 有序去重计数的性能优势非常明显。

@m 表示按照 option 里配置的并行选项进行多线程计算。

同时把这个并行选项打开。

..

例二,再看稍复杂的情况。events 和 users 关联,按照 user_level 分组汇总,对 user_id 去重计数。

SQL:

select u.user_level,count(distinct e.user_id)
from events e
    left join users u on e.user_id = u.user_id
group by u.user_level

执行时间:38 秒。

SQL 在关联、分组的情况下计算去重计数,速度明显更慢了。

SPL 写法:


A
1 =file("events.ctx").open().cursor@m(user_id)
2 =file("users.ctx").open().cursor(user_id,user_level;;A1)
3 =A1.group@1(user_id).joinx@m(user_id,A2:user_id,user_level)
4 =A3.groups(user_level;count(1))

A2 游标的最后一个参数是 A1,表示多线程并行时,users 会跟随 events 分段,保证后面两个表有序归并的正确性。

A3 中 events 有序分组后,和 users 有序关联归并。

执行时间:0.5 秒。

SPL 有序去重计数的性能基本上没什么变化,优势仍然非常显著。

例三,更复杂的情况。同一用户,按照时间顺序连续的事件如果名称相同就是重复事件。去掉重复事件后,发生事件个数大于 5 的用户数量。

SQL 比较难写了,这里不再给出,有兴趣的同学可以尝试写一下。

SPL 代码:


A
1 =file("events.ctx").open().cursor@m(user_id,event_name,event_time).group(user_id)
2 =A1.select(~.sort(event_time).group@o1(event_name).len()>5).skip()

group 默认是建立分组子集的。

A2 中的 ~ 就表示当前分组子集。每组 user_id 数据量不大,可以全组读入内存。用序表的分组函数 group@o1,归并相邻且相同的 event_name。

执行时间:

2 秒。

这个任务相对复杂,而且数据量达到千万级。esProc 标准版在配置较差的笔记本上,仍然能达到秒级的计算速度。

测试结果:


MYSQL esProc
例一 16 秒 0.4 秒
例二 38 秒 0.5 秒
例三 - 2 秒

利用有序存储的 esProc 计算 COUNT DISTINCT 性能完胜 MYSQL。

esProc 提速方案的前提是数据按照 user_id 有序存储,如果有新增数据的话,可能会包括任意 user_id,就不能在有序的历史数据后直接追加了。而重新生成全量有序数据的过程耗时较长,不能频繁进行。

这时候需要麻烦一些,把新数据和旧数据分开有序存储,经过较长时间周期后定时合并。进行去重计数等计算时,把新旧数据有序归并后再计算,仍能利用有序快速去重。

实际上,针对固定不变的历史数据的计算场景就非常多了,也有不少 COUNT DISTINCT 计算亟需加速。这些场景采用 SPL 有序存储可以有效加速 COUNT DISTINCT,而且实施起来非常方便。