从 TOPN 和 COUNT DISTINCT 来看 DuckDB 的性能优化

这两年 DuckDB 已经成了不少数据分析场景下的热门选择,轻量、好上手、易集成,也适合搞本地分析的程序员,直接用 SQL,省心省力。

不过光“写着舒服”还不够,跑得快,优化智能,也是决定体验的关键。

下面用 TOPN 和 COUNT DISTINCT 作为用例,来看看 DuckDB 性能优化的表现。

测试环境与数据准备

测试硬件:

配置项

参数

CPU

16 核(8 核心 16 线程,主流笔记本)

内存

16GB

存储

SSD

配置并不算高端,反而更接近开发者日常使用的环境,测试结果更具参考性。

测试数据:

数据表

字段说明

总行数

原始 csv 文件大小

topn

id:10 位大写字母随机,amount:0~10000000 随机实数

10 亿行

22GB

orderdetail

orderid:升序字符串,detailid:整数,amount:实数

24 亿行

50GB

测试数据都明显超过内存,确保真实模拟大数据运算场景,避免缓存“胜之不武”。

全集 TOPN:表现优秀

首先是最经典的 TOPN 操作,取 amount 字段最大的前 100 条:

select * from topn order by amount desc limit 100;

如果按照 SQL 的直观意思,数据库应该先把表全排序,再挑出前 100 条。这么一来,算法复杂度就是 n*logn,数据量大到内存装不下,性能直接跳水。

但测试下来,DuckDB 在这个场景下的表现相当不错。面对 10 亿行、22G 原始文本数据,导入后压缩成 8G 的数据库文件。查询第一次运行用时约20 秒(主要受首次加载和缓存影响),后续运行稳定在4 秒左右。

期间没有产生临时文件,说明 DuckDB 的优化器已经规避了全表排序,采用了优化算法:只维护一个大小为 N 的小集合,遍历一次搞定。这个策略算法复杂度从 n*logn 降到 n*logN,N 很小时,性能相差天壤之别。

这一项,DuckDB 的表现算是优秀,既聪明又高效。

分组 TOPN:暴露短板

接下来,稍微加点难度:分组 TOPN。

具体问题是:把表按 id 字段的首字母分组,每组挑出 amount 最大的前 100 条。SQL 写法大致是这样:

select * from (
  select left(id,1) as gid, amount,
         row_number() over (partition by left(id,1) order by amount desc) rn
  from topn
) where rn <= 100;

按道理,这种场景和普通 TOPN 类似。每个分组只有几万个数据,维护一个小的前 N 名集合遍历一遍,理论上同样可以避免全排序。

但实际运行中,DuckDB 似乎在“忠实执行 SQL 语义”:每个分组先完整排序,再通过 row_number 编号筛选。这一策略意味着大量的内外存读写,测试时临时文件暴涨到 40 多 G,10 分钟过去还没能算完。

也就是说,尽管逻辑上可以优化,DuckDB 并不会主动把“分组 TOPN”识别成“分组聚合”场景,而是老老实实执行排序。这种情况下,想优化就只能靠用户自己绕开 SQL 写法,或者换工具解决。

比如 esProc SPL 支持直接用 groups 搭配 top 函数,将 TOPN 看作聚合行为,遍历一遍就能得到结果,测试中只用了31 秒完成同样的查询,性能差距相当明显。

SPL 脚本 - 全集 TOPN:


A

1

=file("e:/duckdb1.1.1/topn.ctx").open().cursor@m(amount)

2

=A1.total(top(100;-amount))

SPL 脚本 - 分组 TOPN:


A

1

=file("e:/duckdb1.1.1/topn.ctx").open().cursor@m()

2

=A2.groups@u(left(id,1);top(100;-amount))

测试结果汇总,单位(秒)

测试类型

全集TOPN

分组TOPN

esProc SPL

13

31

Duckdb

20

600以上

COUNT DISTINCT:不会利用有序数据

再来说说另一个常见问题:去重计数(COUNT DISTINCT)。

在 SQL 中,统计满足某条件的不同订单号数量,一般会这样写:

select count( distinct orderid ) from orderdetail where amount>50;

在 DuckDB 里,这个操作的逻辑很简单粗暴:遍历所有数据,收集符合条件的 orderid 构成一个 DISINCT 集合,每次碰到新值都要和当前 DISTINCT 集合比较一轮以决定是否加入,最后统计 DISINCT 集合大小。

如果数据是乱序的,这种方式没毛病,符合大多数数据库的做法。

但在测试里,刻意将数据按 orderid 升序的次序插入。这时候,如果能利用这个特点,算法可以大幅提速——只要相邻两行的 orderid 相同,直接跳过,不需要维护 DISTINCT 集合。

可惜 DuckDB 还不会主动识别和利用数据的有序性,测试结果也印证了这一点:24 亿行数据,单线程跑了200 多秒。

而如果换用 esProc SPL,调用 icount@o 函数,专门针对有序数据进行去重计数,测试下来只要50 秒左右,比 DuckDB 的性能提升约 4 倍。

如果进一步复杂,比如计算两个不同条件的 COUNT DISTINCT:

select count( distinct case when amount>50 then orderid else null end ),
       count( distinct case when amount>100 then orderid else null end )
from orderdetail;

DuckDB 会重复构建 DISTINCT 集合,时间直接飙升到800 秒以上。而 esProc SPL 能复用扫描过程,时间几乎没变化。

这个差距的根本原因,就是 SQL 无法描述“有序”信息,优化器也就无从利用。而像 esProc SPL 这种可以显式表达有序性、并通过特定函数调用的语言,在此类场景下天然占优。

SPL 脚本 - 单个 count distinct:


A

1

=file("f:/temp/yxfz.ctx").open().cursor@m()

2

=A1.total(icount@o(if(amount>50,orderid)))

SPL 脚本 - 两个 count distinct:


A

1

=file("f:/temp/yxfz.ctx").open().cursor@m()

2

=A1.total(icount@o(if(amount>50,orderid)), icount@o(if(amount>100,orderid)) )

测试结果汇总,单位 (秒):

测试类型

一个DISTINCT查询

两个DISTINCT查询

esProc SPL

53

59

Duckdb

222

865

DuckDB 作为一款轻量级分析数据库,易用性很出色,尤其在全集 TOPN 查询里,优化得相当不错,日常使用体验很好。

但当问题稍微复杂一些,比如分组 TOPN,或者数据本身具备有序特征的去重计数,SQL 语义的“笨拙”就暴露了:优化器无法识别的逻辑,性能差距会非常明显。

这些场景下,如果换成 esProc SPL 这种能描述计算细节、支持有序特性表达的工具,性能可以大幅提升。

所以说,DuckDB 是好工具,前提是场景简单,能让优化器自由发挥;如果问题涉及有序特征或特殊聚合逻辑,想跑得快,还是得看工具能否表达“更聪明”的计算方式。