同样的查询,快了两倍多
Posted by quentin 在 Sunday, 11 October 2026
前两天在 Hacker News 上看到一篇 MotherDuck 的技术博客,讲的是 DuckDB 2.0 为什么比 1.x 快。我点进去本来是想看新特性列表的,看完之后发现,真正让我停下来想了半天的,不是某个具体的优化技巧,而是一个简单到有点无聊的事实。
同一条 SQL 查询,同一台机器,同一份数据,DuckDB 2.0 比 1.5.5 快了 2.4 倍。不是改了算法,不是换了数据结构,只是把工作流的组织方式换了一下。
一个查询,两种命运
先说具体场景。DuckDB 是一个嵌入式分析数据库,你可以把它理解为"数据领域的 SQLite"——不需要启动服务,一个进程就能跑,专门做 OLAP 查询。这两年增长很快,尤其是在数据工程和 AI 数据预处理场景里,几乎成了标配工具。
MotherDuck 那篇博客给的测试用例很直白:在 S3 上放一个 2.2GB 的 Parquet 文件(Stack Overflow 的投票数据,2.28 亿行),用一条简单的 GROUP BY 查询统计每种投票类型的数量。就这一条 SQL,没有任何复杂的 join 和子查询。
1.5.5 跑了 18.8 秒,2.0 alpha 跑了 7.7 秒。同样的查询,同样的机器,同样的网络,同样的数据。没有新算法,没有新索引。
差别在哪?在"谁在等谁"。
下载和解析,以前是串行的
Parquet 文件在 S3 上,查询的时候需要把数据从 S3 下载到本地内存,然后 CPU 再对下载下来的数据做解析和计算。这是两步操作:网络 I/O 和 CPU 计算。
在 1.5.5 里,DuckDB 的 18 个工作线程每个都是串行地干这两件事:下载一个行组(row group),等数据到了,解析计算,然后再下载下一个。就像一个人做饭:从冰箱拿菜,等手空了再切菜,切完再去拿下一样。
这意味着什么?意味着在等网络的时候,CPU 是闲着的;在算数据的时候,网络是闲着的。两个资源始终没法同时干活。
2.0 的改法也不复杂:加了一个独立的下载线程池,专门负责从 S3 持续拉数据放到缓冲区里。工作线程只管从缓冲区拿数据来算。下载和解析变成了两条流水线,各自独立运转。缓冲区里永远有待解析的数据,CPU 不再需要停下来等网络。
这个改法在前端领域其实早就司空见惯了。React 18 搞并发渲染,本质上就是把渲染任务从主线程拆出来,放到后台去做,主线程不被渲染阻塞,用户交互就不会卡顿。Node.js 的事件循环也是同一个思路——把 I/O 操作从 CPU 计算中分离出来,让 CPU 永远有事干。
数据库领域为什么到现在才做这个事?我猜是因为以前数据都在本地磁盘,I/O 延迟低,串行串着也不觉得疼。到了云存储时代,S3 的延迟比本地 SSD 高几个数量级,等网络的时间远远超过算数据的时间,串行模式的代价才真正暴露出来。
行组:为什么这个改进特别值钱
DuckDB 2.0 的异步 I/O 主要针对 Parquet 文件的行组(row group)。Parquet 是列式存储格式,一个文件会按行分成多个行组,每个行组内部再按列组织。DuckDB 的查询引擎可以并行处理不同行组,但前提是——行组数据得先到内存里。
那个 2.2GB 的文件有 2268 个行组,每个行组大约 12 万行。旧版本里,同时进行的下载数量受限于工作线程数(18 个),大量时间花在等网络上了。新版本里,下载线程池独立运转,同时 in-flight 的下载数量可以远超 18 个。
前几年做过一个数据看板,首页加载特别慢,一个聚合查询要 12 秒才能返回。团队花了两星期优化 SQL,最后发现瓶颈根本不在 SQL 上——是数据从 S3 加载到内存的时间占了 9 秒。当时的解法是把热数据预热到本地 SSD,绕过了网络 I/O。如果那会儿有 DuckDB 2.0 的异步 I/O,可能连预热都不需要了。不过话说回来,当时团队里没人想到"把下载和解析拆开"这个方向。大家本能地觉得慢就是查询引擎不行,没人去 profiling 时间到底花在哪一步了。
这个教训我记了很久:性能问题的根因往往不在你以为的地方。你以为代码慢了,其实是等待慢了。
不只是快,还有自适应执行
DuckDB 2.0 另一个值得关注的改进是自适应执行(adaptive execution)。传统数据库的执行计划是编译时确定的——查询优化器在跑查询之前就规划好了先扫哪张表、怎么做 join、用什么索引,然后严格按计划执行。这就像导航软件给你规划了路线,你就严格按路线走,哪怕前面堵车了也不绕道。
DuckDB 2.0 允许执行引擎在运行时根据实际数据分布调整策略。比如某个 join 的实际数据分布跟统计信息不符,引擎会动态切换 join 算法;某个过滤条件过滤掉的数据比预期多,后续扫描会提前终止。
导航软件的类比依然成立——这就是实时路况重新规划路线。
数据库领域在这个问题上一直走两个极端:要么严格按计划执行(可预测但僵硬),要么动态重编译(灵活但开销大)。DuckDB 走的是中间路线:保持计划框架,但允许局部调整。这个中间路线在前端也有对照——React 18 的并发渲染不是完全放弃同步渲染,而是在同步渲染的基础上加了中断和恢复能力。好的技术方案往往不是最先进的,而是在务实和理想之间找到了那个恰到好处的平衡。
一个让我想了一会儿的问题
看完这篇博客我一直在想一个问题:如果 DuckDB 的架构师从来没写过前端,他是怎么独立发现"分离下载和解析"这个思路的?
后来想明白了——这个思路不难发现,难的是意识到这是个问题。系统慢了,大部分人的第一反应是"代码不够快",很少有人会去想"工作流的组织方式不对"。这种思维惯性在哪都有。做前端的时候,页面卡了会本能地觉得是组件渲染慢了,实际上可能是主线程被 JSON 解析阻塞了。做后端的时候,接口延迟高会本能地觉得是数据库查询慢了,实际上是连接池满了,请求在排队。
我见过一次线上告警,某个列表页的 P95 加载时间从 800ms 跳到了 3 秒。排查了两天,最后发现根因不是前端也不是 BFF,是上游某个依赖接口的超时时间从 200ms 被改成了 2 秒——上游觉得原来的超时太激进了,加了容错,结果这个改动沿着调用链一路放大,到了前端就变成了 3 秒。
DuckDB 的作者 Mark Raasveld 在 commit 里提到,他们是在 profiling 的时候发现 worker 线程有大量时间花在等 I/O 上,才决定把 I/O 分离出来。答案一直在 profiler 的数据里,只是之前没人往那个方向看。
性能优化的核心不是"写出更快的代码",而是"看清楚时间都花在哪了"。CPU 忙吗?网络忙吗?I/O 忙吗?谁在等谁?找到那个"闲着的",消灭它。
说起来容易。但大部分性能优化工作,其实都没有认真看过 profiler 的数据。我们在优化自己看得见的代码——让循环更快、让算法更好、让索引更精准。但那些看不见的时间——等网络、等锁、等 I/O、等队列——往往才是大头。
DuckDB 2.0 这个改进,本质上不是代码优化,是工作流重组。同样的 CPU,同样的网络,只是让它们不再互相等。这种优化,不看代码是发现不了的,得看 profiler。因为它不在代码层,在架构层。