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

英文版