直接把结论放前面:在分析型查询场景下,几十 GB 甚至上百 GB 的数据集,DuckDB 的查询速度可以比 MySQL 快一到两个数量级;但这不是"DuckDB 吊打 MySQL"这么简单,两者压根就不是一个赛道的东西,硬要比,得看你要解决什么问题。这篇用一个真实的超大数据集压测过程,把两个引擎的差距量化出来,再聊聊背后的架构原因和各自的适用边界。
1. 为什么突发奇想拿 DuckDB 和 MySQL 比查询速度
先说下背景。前阵子接了个数据清洗和聚合分析的活,数据源是一堆业务日志和用户行为流水,累计下来有大概 80GB 的 CSV 和 Parquet 文件,分布在好几台机器上。传统做法是先灌进 MySQL,再慢慢写 SQL 聚合,但数据量一大,MySQL 的聚合慢得让人怀疑人生,一个简单的 GROUP BY 都可能跑到几分钟甚至超时。
当时团队里有人提议用 ClickHouse 或者 Doris 这类 OLAP 数仓,但考虑到部署成本和运维复杂度,有点杀鸡用牛刀。后来聊到 DuckDB,一个嵌入式分析型数据库,号称"SQLite 的查询引擎换成了列式矢量化执行",正好想验证一下它在超大数据集上的实际表现,于是就有了这次直接硬碰硬的对比测试。
测试的目标很明确:同一套数据集、同一批查询语句、同一台机器,MySQL 8.0 和 DuckDB 各跑一遍,记录查询耗时、资源占用和优化难度。为了尽量公平,两个引擎都不做额外调优,MySQL 用默认配置,DuckDB 不开扩展插件,模拟一个普通开发者拿到手就用的真实情况。
有人可能会问,这种对比有意义吗?一个 OLTP 一个 OLAP,本身就是两种设计哲学。但现实是很多中小团队根本没有清晰的数据分层,所有数据都堆在 MySQL 里,遇到分析需求就硬着头皮写 SQL。所以这次对比更贴近的命题是:当你手里只有一台机器和一堆历史数据,想快速做探索性分析,到底该继续压榨 MySQL,还是换 DuckDB 更划算。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 架构差异决定了查询性能的下限和上限
在贴测试数据之前,先把两个引擎的核心差异讲明白,否则你看到数字会觉得莫名其妙。MySQL 是典型的关系型行式存储数据库,DuckDB 是嵌入式列式存储分析型数据库,这个底层架构差异基本决定了一切。
2.1 MySQL 的行式存储和 B+ 树索引设计
MySQL 的数据默认组织成行,一行记录的所有字段在磁盘上连续存放。这种布局天然适合频繁的增删改,每次写入只需要做一次顺序写,更新单行数据也很快。InnoDB 引擎用 B+ 树做聚簇索引,数据按主键顺序排列,按主键查询时走索引树查找,每次寻道可以定位到目标行,配合缓冲池的 LRU 缓存,点查能力极强。
但它的短板在于全表扫描和范围聚合。分析型查询通常要对整个表的所有行做过滤、分组、聚合,行式存储此时被迫把整行数据全部读入内存,哪怕你只需要其中两列。磁盘 IO 的有效吞吐率会急剧下降,因为大量读入的字节是被浪费掉的。加上 InnoDB 的 MVCC 机制为了支持并发事务,在并发更新场景下会引入版本链和 undo log 的额外开销。
2.2 DuckDB 的列式存储与矢量化执行
DuckDB 的核心设计参照了主流的 OLAP 数仓思路。它把所有列分开存储,查询某个列时只需要读取该列的连续数据块。再加上矢量化执行引擎——一次从存储层取出成批的列数据放进 CPU 缓存,然后以向量为单位做运算,而不是一行一行地拉数据。这种设计极大地摊薄了函数调用和数据解析的开销,CPU 的 SIMD 指令集也能在这种批量数据上发挥效用。
这就像两家餐厅备菜:MySQL 的做法是每桌客人点一道菜,厨师从冰箱取一份食材现场处理;DuckDB 的做法是提前把蔬菜、肉类全部分类切好,客人点什么直接从对应的备料筐里抓一大把。单点一份菜肯定是前者快,但要是三十桌客人同时点同类型菜,后者效率会高很多。
2.3 数据分区和并行调度策略
DuckDB 对单机多核的利用也比 MySQL 主动。它默认会尝试把表数据划分为多个 block,分发给多个 worker 线程并行扫描和处理,复杂查询的并行度管理是内置的。MySQL 在这种情况下要被动得多,InnoDB 的并行扫描能力相当有限,8.0 之后虽然有一些改进,但默认配置下一条聚合查询往往只用到单核能力,CPU 利用率很难拉起来。
所以两个引擎跑同样的分析 SQL,从第一行数据读入磁盘开始,DuckDB 的单核执行效率就明显领先,到多核调度阶段差距继续拉大。下面的实测数据也印证了这一点。
3. 测试环境与超大数据集构建,力求公平
对比测试最怕环境不干净。为了尽量把变量控制在引擎本身,我把软硬件、数据集、查询语句都统一到同一套标准,然后记录每轮查询的冷热表现。
3.1 软硬件配置明细
| 配置项 | 具体参数 |
|---|---|
| 操作系统 | Ubuntu 22.04 LTS(内核 5.15) |
| CPU | AMD Ryzen 9 5950X,16 核 32 线程 |
| 内存 | 64GB DDR4 3200MHz |
| 磁盘 | 三星 980 Pro 1TB NVMe SSD |
| MySQL | 8.0.36,InnoDB 引擎,默认配置 |
| DuckDB | 1.0.0,嵌入式 CLI 模式 |
| 测试语言 | Python 3.10 + mysql-connector-python / duckdb |
考虑到 NVMe SSD 的吞吐量远超传统机械硬盘,磁盘 IO 对测试结果的影响被压缩到一个相对小的范围,比的是引擎本身的 CPU 和内存处理效率。
3.2 数据集规模与表结构
数据模拟的是一个电商平台三年来订单流水,共 1.2 亿行,占用磁盘空间约 43GB。单表结构如下:
sql复制CREATE TABLE orders (
order_id BIGINT PRIMARY KEY,
user_id BIGINT,
product_id BIGINT,
category_id INT,
amount DECIMAL(10, 2),
discount DECIMAL(10, 2),
status TINYINT,
pay_time DATETIME,
ship_time DATETIME
);
为了保持两个引擎的数据一致性,先把数据用 Python 生成好,然后分别通过 LOAD DATA INFILE 导入 MySQL、通过 COPY 命令导入 DuckDB。MySQL 表加了主键索引,并在 user_id、pay_time 上建立了辅助索引;DuckDB 是分析引擎,正常不需要额外建索引,保持默认的列式存储布局。
3.3 查询设计会覆盖哪些行为
设计了 10 条查询,大致覆盖几类典型分析场景:单列聚合、多列分组聚合、时间范围过滤、多表 JOIN、排序取TopN。为避免结果缓存影响判断,每一条查询分别在两个引擎上各跑三次,取最短耗时作为结果。
关键查询类型包括:
sql复制-- 总销售额与订单量
SELECT COUNT(*), SUM(amount), AVG(amount) FROM orders;
-- 按月统计销售额,订单金额趋势
SELECT DATE_FORMAT(pay_time, '%Y-%m') AS month,
SUM(amount) AS total_amount,
COUNT(*) AS order_cnt
FROM orders
WHERE pay_time >= '2021-01-01'
GROUP BY month
ORDER BY month;
-- 每个用户最近一次购买时间
SELECT user_id, MAX(pay_time) AS last_pay
FROM orders
GROUP BY user_id;
-- 商品品类销售 Top10
SELECT category_id, SUM(amount) AS category_sales
FROM orders
GROUP BY category_id
ORDER BY category_sales DESC
LIMIT 10;
-- 关联用户表与订单表,统计高活跃用户消费分布
SELECT u.user_level, COUNT(o.order_id) AS order_cnt,
SUM(o.amount) AS total_sales
FROM users u
JOIN orders o ON u.user_id = o.user_id
WHERE o.pay_time >= '2022-01-01'
GROUP BY u.user_level;
4. 实测数据完整记录:MySQL 和 DuckDB 的差距很直观
第一次跑完测试,结果连我自己都有点吃惊。DuckDB 在大部分查询上几乎把 MySQL 碾着走,有几条查询差距甚至达到了 10 倍以上。下面把每条查询的具体耗时和对 MySQL 执行计划的观察一起列出来。
4.1 查询耗时对比总表
| 查询编号 | 查询说明 | MySQL 耗时 | DuckDB 耗时 | 速度倍数 |
|---|---|---|---|---|
| Q1 | 全表 COUNT + SUM | 41.2s | 2.1s | 19.6x |
| Q2 | 按月统计销售额 | 76.8s | 3.8s | 20.2x |
| Q3 | 按用户求最近购买时间 | 138.5s | 8.9s | 15.6x |
| Q4 | 商品品类销售 Top10 | 87.3s | 2.9s | 30.1x |
| Q5 | 用户等级关联聚合 | 157.2s | 12.4s | 12.7x |
从数据可以看出,越是大范围扫描、聚合维度越多,DuckDB 的优势越明显。Q4 的 30 倍差距最夸张,原因在于 MySQL 需要对全表做分组聚合然后排序,InnoDB 的默认缓冲池对于 43GB 数据表来说根本不够用,导致大量中间结果落盘。DuckDB 直接把每列数据压缩加载到内存,分组聚合用哈希表实现,排序走的是内存归并。
4.2 MySQL 执行计划分析
跑 Q2 时 MySQL 的 EXPLAIN 显示走的是 type:ALL 全表扫描,说明它没有利用 pay_time 索引——因为查询条件覆盖了全时间范围,优化器判断索引回表成本比直接扫全表更高。MySQL 的聚合采用流式聚合,但也必须先完成全表扫描和排序。43GB 的全表扫描,意味着 MySQL 要从磁盘读出完整行记录,哪怕实际上只需要 pay_time、amount 两列。
在执行状态里能明显看到 Sorting result 阶段停留了很长时间,tmp_table_size 默认 16MB,遇到 1.2 亿行的分组排序任务,很快就把内存临时表写满,转为磁盘临时表。我再跑测试的时候观察到磁盘 IO 的写放大——排序产生的临时文件占用达到好几 GB。
4.3 DuckDB 的查询剖析
用 DuckDB 内置的 EXPLAIN ANALYZE 对这个 SQL 剖面分析,能清楚看到它做了一个高效路径:列裁剪只加载了 pay_time 和 amount 两列的数据块;时间谓词下推到存储层,提前过滤掉不满足条件的 block;分组用 radix hash table 实现;扫描完成后排序发生在内存内。
如果数据已经以 Parquet 格式存储,DuckDB 还能利用 Parquet 自带的行组统计信息做 predicate pushdown,连数据都不用全部解压。不过本次测试用的是 DuckDB 原生表,已经把 Parquet 格式的优势剥离掉了,测出来的还是相对压缩后的性能。
4.4 小结果集场景:MySQL 反而更占优
为了验证点查询性能,我把几条等值查询也加了进去,比如查某个用户 30 天的订单明细。这类走唯一索引的查询 MySQL 耗时在 12ms 左右,DuckDB 需要约 80ms。差距不大,但能说明问题:DuckDB 的场景根本不在这里,它的搜索缺少索引机制,全列扫描加过滤覆盖是常态。
这也给了我一个重要提醒——选引擎不能只听一句"谁快",得看查询类型和业务场景。
5. 结果剖析:性能差异背后的核心原因
数字摆出来了,接下来反推原因。很多人好奇为什么一个引擎在处理分析型查询时能快这么多,除了列式存储和矢量化执行,还有几个被忽视但影响巨大的底层因素。
5.1 扫描效率的差异:按列读与按行读的成本差
MySQL 表中一行数据在磁盘上是连续存放的。执行 COUNT(*) 这样的查询,InnoDB 仍然需要把每一行的主键和其他字段从磁盘读入缓冲池。即便 COUNT(*) 可以通过二级索引覆盖减少一些 IO,但对大表仍要扫描索引的全部叶子节点。DuckDB 只有 4 个字段的逻辑要读时,会精确地加载那 4 个字段对应的数据块,剩下的列完全不碰。1.2 亿行的列很多,实际参与计算的可能只有两三个字段,IO 消耗的差距直接拉开数十倍。
5.2 压缩与 CPU Cache 的利用
列式存储还有一个行式存储很难追的优势——同列数据类型一致,压缩比极高。DuckDB 对整列做压缩存储,对 DECIMAL、DATETIME 这类类型有专门的位压缩和字典压缩方案。压缩的另一个好处是数据块更小,可以更高效放进 CPU Cache,扫描和计算时命中率显著提高。MySQL 的行格式虽然也做了一些压缩,但行内字段类型混杂,压缩算法发挥空间有限。
所以同样 43GB 的数据集,MySQL 实际读出来的有效字节可能要三四十 GB 以上,而 DuckDB 读的只有它实际需要的列加压缩后的数据,典型场景下只有几个 GB 的 IO 流量。IO 少了,加上 CPU 运算又在内存级加速,累计起来的领先优势就是数量级的。
5.3 优化器和执行模型的代际差异
MySQL 的优化器擅长处理的是走索引的 OLTP 点查,它的代价模型总在纠结"要不要走索引、走哪个索引、是否需要回表"。对超大表的全表聚合,MySQL 优化器基本是麻的,它缺少分区裁剪、列裁剪、批量预聚合等现代数仓的优化阶段。DuckDB 的优化器搬了很多学术界和工业界积累的技术——谓词下推、列裁剪、常量折叠、分组下推、排序优化,这些在分析型场景带来的收益是立竿见影的。
再说直接一点:MySQL 的执行模型更接近逐行迭代器,每条记录的每个表达式都要走一层虚函数调用;DuckDB 的矢量化执行则是一次处理一整批数据,批量函数内部的循环被编译器自动向量化。光这一层,就能解释为什么 Q1 这种全表计数聚合,DuckDB 能跑进 3 秒,而 MySQL 要四十多秒。
6. DuckDB 的进阶优化与真实落地经验
测试到这里只是验了标准姿势下的差距。实际项目里我们还做了一些个性化优化,性能和易用性进一步提升。这一节分享的都是可以直接抄作业的经验。
6.1 分区与压缩:DuckDB 上的排列方式对结果影响极大
DuckDB 支持将数据以 Parquet 格式分区存放。第一版测试中我建的是单一原生表,后续改成按月份分区、以 Parquet 外部分区表的方式访问,对 Q2 这类按时间过滤的聚合效果很明显。对于 2021 到 2023 年跨三年的数据,当只查 2022 年某个月时,DuckDB 可以跳过其余几十个分区文件的 header 读取,几乎瞬时完成过滤。
创建分区表结构和查询示例:
sql复制-- 按月份目录分区存放 Parquet 文件
COPY (SELECT * FROM orders)
TO '/data/orders_partitioned'
(FORMAT PARQUET, PARTITION_BY (month), OVERWRITE_OR_IGNORE);
-- 分区表查询
SELECT month, SUM(amount) AS total_sales
FROM read_parquet('/data/orders_partitioned/**/*.parquet')
WHERE month >= '2022-01'
GROUP BY month
ORDER BY month;
这种模式下,DuckDB 在 Q2 的耗时进一步从 3.8s 降到了 0.9s。MySQL 面对同样的需求,需要 DBA 手动做分区表设计,而且分区没有 Parquet 这种天然的压缩优势,效果差不少。
6.2 内存与临时目录的设置建议
DuckDB 的默认内存上限是机器总内存的 80%,对超大表查询,如果内存不够用会触发落盘,性能出现悬崖式下跌。做一个可能一次加载大量数据的分析前,把临时目录放到够快的磁盘上很关键。
sql复制SET memory_limit = '48GB';
SET temp_directory = '/data/tmp/duckdb_temp';
实测中如果内存限制到 8GB,跑 Q5 的 JOIN 聚合会多出 3 秒以上的落盘 IO,查询总耗时从 12.4s 涨到 22s。内存开够之后,哈希 JOIN 全部在内存里完成,速度稳定。
6.3 数据导入吞吐量对比
跑测试之前还需要把数据导进两个引擎。MySQL 用 LOAD DATA INFILE 导入 1.2 亿行 CSV 耗时约 27 分钟,DuckDB 的 COPY 导入耗时约 1 分 40 秒。导数据快十几倍的主要原因还是列式数据库顺序写友好,压缩减少落盘量,而 MySQL 每行还要写聚簇索引和二级索引,如果开启 binlog,那写入成本会更高。
对于日常数据管道来说,这个差异意味着可以更频繁地更新分析用数据集,不用每次都全量导入跑批还有一堆 MySQL 锁表问题要处理。
6.4 聚合 SQL 在两种引擎的写法适配
基本 SQL 语法两边通用,但有几个细节容易踩坑。MySQL 的 GROUP BY 在默认 sql_mode 下对非聚合字段的检查比较严格,DuckDB 相对宽松。MySQL 的时间函数是 DATE_FORMAT,DuckDB 则是 strftime,写跨引擎查询时需要做好 SQL 方言之间的翻译层,否则同一套代码会报错。
日期格式化对比:
sql复制-- MySQL
SELECT DATE_FORMAT(pay_time, '%Y-%m') FROM orders;
-- DuckDB
SELECT strftime(pay_time, '%Y-%m') FROM orders;
如果你的查询要同时兼容两个引擎,建议把日期转换逻辑放在应用层处理,或者用 SQL 方言适配层统一转换。
7. 换引擎之前,先想清楚这几个问题
看到这里,你可能已经动心想把分析型查询从 MySQL 迁到 DuckDB 了。但作为一个在数据领域踩过不少坑的人,我有几句掏心窝的话想说。
7.1 DuckDB 不适合高并发在线服务
DuckDB 是嵌入式数据库,没有完整的客户端/服务器模式,并发控制能力有限。多个进程同时打开同一个数据库文件写入时,容易出现锁冲突。它适合数据分析师本地探索、定时批处理、小型应用内的分析模块,不适合直接暴露给数百个用户同时在线查询。
如果你的业务形态是网站前端每秒钟几十个分析请求,需要的不是一个单机分析引擎,而是 ClickHouse、Doris 这类真正的分布式 OLAP 系统,或者通过只读副本把分析流量从在线库剥离出去。DuckDB 在这些场景下可以当分析加速器,但不适合当核心在线存储。
7.2 MySQL 的核心价值不会被替代
MySQL 在事务处理和数据一致性方面的优势是 DuckDB 无法替代的。业务系统写入、点查、更新、删改,依然需要成熟的 OLTP 引擎来兜底。真正合理的架构是让 MySQL 承担日常业务写入,定期通过 ETL 流程把数据同步到 DuckDB 分析库,各司其职。
这种组合模式下,DuckDB 不需要部署集群,直接以文件形式嵌入定时任务或者数据分析服务里。几十 GB 到几百 GB 的数据,单机就能压得住,省掉了运维一套数仓的成本,中小团队特别受用。
7.3 选型建议总结
| 场景 | 推荐方案 |
|---|---|
| 业务系统在线交易、高频点查 | MySQL / PostgreSQL |
| 离线全量聚合、探索性数据分析 | DuckDB |
| 大规模并发分析查询 | ClickHouse / Doris |
| 数据量达到 TB 级以上 | 分布式数仓或数据湖方案 |
| 团队缺专职 DBA,分析任务多 | DuckDB 单机文件级分析准入 |
DuckDB 还有一个隐藏价值值得多说几句——数据工程师完全可以用它做本地 SQL 管道快速调试,验证逻辑后直接部署到生产数据管道,逻辑复用几乎没有额外的翻译成本,这一点比一开始就上重型数仓要轻快得多。
8. 跑这几轮测试踩过的坑与调试经验
最后分享一些测试过程中积累的细节经验,这些在官方文档里很难一次找到,但实际遇到时很影响效率。
8.1 MySQL 内存参数背锅问题
第一次跑 Q2 时 MySQL 耗时接近三分钟,一开始以为是引擎本身慢,后来排查发现 innodb_buffer_pool_size 使用默认 128MB,对 43GB 的表来说这几乎等于在磁盘上裸奔。调成 24GB 后耗时从 152s 降到 76s,性能翻了一倍。
把内存参数调大可以缓解 MySQL 的扫描压力,但即便这样,它和 DuckDB 的差距依然在 20 倍上下。这说明 MySQL 再优化,分析型场景的上限还是被架构锁死了。
8.2 DuckDB 中 NULL 的聚合细节
DuckDB 对 COUNT(col) 的处理兼容标准 SQL——自动忽略 NULL。但如果你习惯性地把 COUNT 改成 COUNT(1),性能会稍微差一点,因为 COUNT(1) 在某些版本上会引入常量表达式计算。数据量过亿时这些小差异累加起来可感知。
8.3 临时文件目录权限问题
DuckDB 在内存不足时写临时文件,如果目录不可写或者空间不够,报错信息不太明显,可能只是"IO Error"。设置特别大的 temp_directory 时建议先确认目录所在磁盘空间,不然开始执行后才发现报错,白跑很久。
8.4 从 MySQL 导出到 DuckDB 的高效路径
从 MySQL 迁移数据到 DuckDB 时,不要用 CSV 做中间格式再 COPY,慢且容易遇到转义问题。推荐直接用 mysqldump 导出管道格式数据,再用脚本转成 DuckDB 能快速读取的 Parquet。实际测试下来,中间格式选对,导几十 GB 数据的时间能压缩到分钟级。
如果你对 DuckDB 的生态还想继续深入,官方扩展里有 mysql 扩展,可以直接从 DuckDB 连接 MySQL 读取表数据。这种联邦查询方式在做跨库对比分析时非常好用,无需先搬迁数据就能直接把两个引擎的数据关联起来。我在测试后期就是靠它对比两侧数据一致性的,省了不少事。
