news 2026/9/17 3:01:49

MySQL vs DuckDB:10亿行数据下OLAP查询性能实测与选型指南

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
MySQL vs DuckDB:10亿行数据下OLAP查询性能实测与选型指南

1. 对比的起点:一次真实业务慢查询引发的选型思考

大概半年前,我手里一条业务线的用户行为分析报表开始频繁超时。单表记录数刚过 6 亿,每天凌晨的定时任务要跑将近四十分钟,业务方早上八点打开后台,看到的数据经常还是昨天下午的。MySQL 的 DBA 同事调了一轮索引、改了两次 SQL,效果都不明显——问题不在一两条语句写得差,而在于这张表每天都在涨,聚合范围越来越大。

那时候我开始认真考虑一件事:分析型查询是不是已经不该继续压在 MySQL 上了。市面上关于 OLAP 的引擎很多,ClickHouse、StarRocks、Doris,但都需要单独部署一套集群。我的场景很明确:数据规模大,但团队小、运维成本敏感,想要一个能直接嵌入现有 Python/数据处理流程里的方案。DuckDB 就是在这种背景下进入视线的。

DuckDB 是一个嵌入式分析型数据库,单文件形态,列式存储,专为 OLAP 场景设计,不需要独立的服务端进程。它和 MySQL 属于完全不同的赛道,但很多团队的实际处境是:OLTP 和 OLAP 混在一个库里用,MySQL 既扛在线交易又扛报表查询。所以这两者的对比不是"谁取代谁",而是想回答一个问题——当单表数据量到了亿级以上,继续让 MySQL 扛分析查询,到底亏了多少性能,换 DuckDB 又能赚回多少。

这次对比我花了大概两周时间,从数据生成、装载,到查询压测、结果分析,把整个流程完整跑了一遍。文章里的所有数字都来自我自己的实测环境,不是官方 benchmark 的复制粘贴,也不代表所有硬件条件下的结论,但足以说明两类引擎在架构层面的巨大差异。

2. 测试环境搭建与数据集准备:不严谨的对比毫无意义

对比测试最容易翻车的就是环境不一致。MySQL 跑在专用服务器上,DuckDB 跑在性能翻倍的机器上,这种对比结果毫无参考价值。所以这次我先把环境彻底统一,再把数据生成和装载过程做了完整记录。

2.1 软硬件环境与版本选型

测试用的是一台独享云主机,全程只跑这一套测试,没有其他负载干扰:

项目配置
CPU8 vCPU(AMD EPYC 7K62,主频 2.6GHz)
内存64 GB DDR4
磁盘1 TB NVMe SSD
操作系统Ubuntu 22.04 LTS
MySQL8.0.36,InnoDB 引擎
DuckDB1.1.3(通过 Python 客户端调用)
Python3.10.12

MySQL 8.0 和 DuckDB 1.1.x 都是目前两个项目的主线稳定版本,用它们对比代表的是 2025 年左右的真实水平。注意一点,DuckDB 的 Python 包内置了最新稳定版,直接用pip install duckdb就能装,不需要单独部署服务。

MySQL 端我按生产环境常规方式配置:innodb_buffer_pool_size设为 16 GB,innodb_flush_log_at_trx_commit=2(允许每秒刷盘,换吞吐)。DuckDB 这边主要调了两个参数:memory_limit设为 48 GB,threads设为 8,确保它能用满这台机器的并行能力。

2.2 建表结构与 10 亿行测试数据生成

我模拟的是一个典型的用户行为日志表:包含用户 ID、页面 ID、行为类型、停留时长、时间戳、地域字段,结构如下:

-- MySQL 建表语句 CREATE TABLE user_behavior ( id BIGINT PRIMARY KEY AUTO_INCREMENT, user_id INT NOT NULL, page_id INT NOT NULL, action_type VARCHAR(16) NOT NULL, stay_seconds INT NOT NULL, event_time DATETIME NOT NULL, city_id SMALLINT NOT NULL, KEY idx_event_time (event_time), KEY idx_user_id (user_id) ) ENGINE=InnoDB;

DuckDB 建表语句类似,但不需要主键和索引声明,因为列式存储的布局本身就是为扫描设计的:

-- DuckDB 建表语句 CREATE TABLE user_behavior ( id BIGINT, user_id INT, page_id INT, action_type VARCHAR, stay_seconds INT, event_time TIMESTAMP, city_id SMALLINT );

数据生成我用的 Python 脚本,用并行方式写入,目标规模是 10 亿行,总大小约 85 GB。MySQL 端采用分批INSERT(每批 5000 行),DuckDB 端直接用COPY导入 CSV 格式的中间文件。实测装载耗时:

数据库装载耗时落盘大小
MySQL38 分钟约 82 GB
DuckDB4 分 20 秒约 28 GB

DuckDB 装载快有两个原因:一是列式压缩极大减小了写入量,二是它按列批量写入的格式天然比 InnoDB 的行级事务日志耗时低。但需要说明,这个对比对 MySQL 并不完全公平——它要维护 B+ 树索引和事务日志,这是 OLTP 引擎的必然开销。真实业务里 MySQL 写的是交易数据,DuckDB 写的是分析数据,两者定位本就不同,这里只是说明装载成本差异。

2.3 三个容易忽略的测试前置条件

第一,要清缓存。MySQL 的 InnoDB Buffer Pool 会把热数据留在内存里,同一查询跑第二次和第一次可能差十倍。DuckDB 也默认使用 OS page cache。所以每次查询前,我先把 MySQL 的 buffer pool 状态清掉(重启实例),DuckDB 则用新的连接并执行PRAGMA disable_optimizer之外的冷缓存测试,同时记录热缓存下的成绩,两者都测。

第二,SQL 不能简单照搬。MySQL 的LIMIT分页写法、DuckDB 的USING SAMPLE抽样语法都不同。我尽量设计两类引擎都原生支持的 SQL,避免人为制造语法糖差异。

第三,DuckDB 必须实现真正的纯查询。嵌入式数据库首次查询时会做 catalog 解析和计划生成,如果流程里包含建表或加载数据,时间会被混入查询耗时。我的测试脚本在导入完成后断开连接,重新建立新连接再跑查询,确保测得的是纯查询耗时。

3. 五组压测查询的设计思路与执行细节

这次对比不是随便跑几条SELECT就完事,而是覆盖了分析场景最典型的五类查询模式:全表聚合、条件过滤聚合、分组 TopN、多表 JOIN、复杂子查询。每一条语句都先在两种引擎上做了语法兼容性调整,保证逻辑完全等价。

3.1 查询场景与 SQL 示例

第一组是全表聚合,统计总行数、平均停留时长、用户数:

SELECT COUNT(*), AVG(stay_seconds), COUNT(DISTINCT user_id) FROM user_behavior;

第二组是带时间过滤的聚合,统计某一天每个小时的 PV 和 UV:

SELECT DATE_FORMAT(event_time, '%Y-%m-%d %H:00') AS hour, COUNT(*) AS pv, COUNT(DISTINCT user_id) AS uv FROM user_behavior WHERE event_time >= '2025-05-01' AND event_time < '2025-05-02' GROUP BY hour;

第三组是分组 TopN,统计行为次数最多的前 100 个用户:

SELECT user_id, COUNT(*) AS cnt FROM user_behavior GROUP BY user_id ORDER BY cnt DESC LIMIT 100;

第四组是两张表的 JOIN 分析,关联用户维表取地域维度做聚合:

SELECT u.region, COUNT(b.id) AS behavior_cnt FROM user_behavior b JOIN user_info u ON b.user_id = u.user_id GROUP BY u.region;

第五组是更复杂的嵌套子查询,统计各行为类型里超过平均停留时长的记录数占比:

SELECT action_type, SUM(CASE WHEN stay_seconds > avg_sec THEN 1 ELSE 0 END) / COUNT(*) AS ratio FROM user_behavior, (SELECT AVG(stay_seconds) AS avg_sec FROM user_behavior) t GROUP BY action_type;

COUNT(DISTINCT ...)在 MySQL 里是出了名的重操作,DuckDB 有专门的近似去重函数和精确去重优化,这组设计能明显拉开差距。JOIN 和子查询则测试的是两个引擎优化器的真实水平。

3.2 查询执行方式与结果记录

MySQL 用命令行客户端执行,EXPLAIN ANALYZE记录执行计划和耗时;DuckDB 用 Python 脚本的EXPLAIN ANALYZE输出统计信息。每条查询连续跑三次,取中位数,避免偶然抖动影响判断。

这里有个细节需要留意——DuckDB 是向量化执行引擎,它一次处理一批列数据,MySQL 是逐行扫描。所以查询设计时我特意保留了COUNT(DISTINCT)这种高成本算子,而不是绕开它,因为真实业务的分析 SQL 往往就是这些算子的集合,绕开优化等于作弊。

3.3 冷热缓存都要测

一次查询结果到底受缓存影响多大,很多人心里没数。以第二组时间聚合为例,热缓存下 MySQL 能跑进 20 秒,冷缓存直接飙到 50 秒开外;DuckDB 热缓存 0.6 秒,冷缓存 1.2 秒,同样差了一倍。

测试里最忌讳的是只报热缓存成绩。某些引擎因为内存占用少,热缓存优势明显,会给人"性能极佳"的错觉。冷缓存才代表真实第一屏加载或新查询首次执行的体验。所以后面汇总表里我只列冷缓存成绩,因为这是最有参考意义的数字。热缓存差距在后面根因分析时会单独说明。

4. 实测数据与根因拆解:快在哪,慢在哪

4.1 查询耗时汇总

查询组号MySQL 耗时(冷缓存)DuckDB 耗时(冷缓存)倍率
第一组:全表聚合 + DISTINCT182.7 秒1.53 秒约 119 倍
第二组:时间过滤 + 分组聚合48.3 秒0.94 秒约 51 倍
第三组:分组 TopN(10 亿行)128.5 秒1.86 秒约 69 倍
第四组:大表 JOIN 维表96.2 秒2.41 秒约 40 倍
第五组:嵌套子查询 + CASE WHEN74.8 秒1.17 秒约 64 倍

第一组数据差距最大,接近 120 倍。说实在话,跑完第一轮我自己都不敢信,反复确认了数据装载是否正确、是否真的扫描了全表,最后才接受这个结果。单看绝对数字,MySQL 跑完全表聚合要三分钟,DuckDB 只要一秒半,两边的执行体验已经不是一个量级了。

4.2 存储引擎差异:行存储与列存储的本质区别

这个结果一点也不意外,根源在存储架构。MySQL 的 InnoDB 是行式存储,每一行所有字段物理连续存放。执行SELECT AVG(stay_seconds)这样的查询时,即使只需要一列,InnoDB 也必须把整行数据从磁盘读入内存,解析出需要的字段。也就是说,10 亿行的表,虽然stay_seconds只占行大小的一部分,读磁盘时却要把iduser_idpage_idaction_typeevent_timecity_id全部带进来。

我用一个生活化的类比来解释:行式存储就像每个人的档案是一张完整的纸,你想统计所有人的年龄,也得把每张纸从头看到尾;列式存储则是把所有年龄单独记在一个本子上,翻本子只扫一列数字就行,其他本子根本不用打开。

DuckDB 的列式存储把表中每一列单独压缩存放,查询AVG(stay_seconds)时只读取该列对应数据块。再加上列式压缩(字典编码、位图编码等),磁盘 IO 量可以缩小到行式的五分之一甚至十分之一。80 GB 的数据,一个聚合查询实际只扫了不到 8 GB,这个差距是物理层面决定的,任何 SQL 优化技巧都追不回来。

4.3 执行引擎差异:向量化批量处理 vs 逐行迭代

存储问题解释了大头差距,执行引擎的差异则解释了剩下的部分。

MySQL 的经典执行模型是火山模型(Volcano Model),每个算子逐行向下层请求数据,处理完一行再请求下一行。好处是实现简单、便于扩展,坏处是每行数据都要经历一次虚函数调用和算子间的上下文切换,CPU 大量时间消耗在调度本身而非数据处理上。

DuckDB 用的是向量化执行引擎,每次从存储层取一批数据(通常 2048 行),算子在内存中按批处理。这种方式极大提升了 CPU 缓存的命中率,还能利用 SIMD 指令做批量计算。我实测的第三组 TopN 查询,MySQL 执行计划里filesort需要把分组结果全部落盘再排序,DuckDB 则用部分聚合 + 流式 TopN,内存里就直接维护了堆结构,边扫边淘汰。

4.4 并行能力:单进程多线程 vs 多线程但受阻于锁

DuckDB 默认启用所有 CPU 核心并行扫描,10 亿行数据被拆成多个 row group,每个线程独立扫描部分数据块再合并结果。我这台 8 核机器上,threads参数设成 8,实测接近线性扩展。

MySQL 在只读查询场景下也能用到并行,但 InnoDB 的并行方式主要依赖 buffer pool 的预读和innodb_parallel_read_threads(这个参数主要针对COUNT(*)这类简单扫描),复杂聚合、JOIN、子查询仍然以单线程执行计划为主。即使开了并行度,对于跨大量 page 的聚合扫描,锁竞争和缓存一致性开销也会拉低实际加速比。

4.5 MySQL 真正慢在哪:三个瓶颈叠加

把 MySQL 的耗时拆开看,三个瓶颈叠加得明明白白:

磁盘 IO 瓶颈:行式存储导致扫描 10 亿行需要读取约 82 GB 数据,即使做了 page 压缩,实际 IO 量也远超 DuckDB 的列压缩结果。

CPU 解析瓶颈:MySQL 一行一行地进行表达式计算、类型转换、聚合更新,每行都要走一遍完整的算子链路,CPU 无法高效批量处理。

内存与临时文件瓶颈COUNT(DISTINCT user_id)在 MySQL 里需要维护一个巨大的哈希集合,内存放不下就溢出到磁盘临时文件。第三组 TopN 的分组排序同理,sort_buffer_size不够时触发磁盘归并排序,慢上加慢。DuckDB 的哈希聚合和排序都做了内存感知的优化,配合列式压缩后的数据量,大部分操作可以在内存内完成。

5. 容易翻车的对比陷阱与真实业务场景里的取舍

5.1 对比测试里最容易被忽略的缓存问题

这次测试我最想强调的坑就是缓存。MySQL 的 Query Cache 在 8.0 里已经移除,但 InnoDB Buffer Pool 仍然会把 16 GB 的热数据留在内存。同一个查询跑第二次,耗时可能直接减半甚至更多。DuckDB 同样会使用 OS Page Cache。

我在测试脚本里加了两种策略:冷缓存测试前重启 MySQL 实例,并用sync && echo 3 > /proc/sys/vm/drop_caches清空系统缓存;DuckDB 每次用独立连接,且设置memory_limit等于实际内存的 75%,保证数据不会被无限制地缓存在内存里。但这里也有个现实问题:生产环境里数据库本来就常驻内存,不可能每次查询前都重启实例。所以冷缓存成绩代表的是最坏情况(比如凌晨跑批刚重启完),热缓存成绩代表的是日常高频查询的体感。报告里我只放冷缓存数据,是因为 MySQL 热缓存成绩在不同数据热度下波动太大,而冷缓存更能反映架构底子。

5.2 索引设计差异对结论的影响

MySQL 里为分析查询建索引是常规操作,我最初也给 MySQL 加了idx_user_ididx_event_time。但实测发现,对于超过几千万行的查询,索引回表的额外开销有时比全表扫描还大。比如第二组时间过滤查询,用idx_event_time定位时间范围后回表,单行随机 IO 反而比顺序扫描慢。最后我放弃了针对每一条查询都建索引的做法,只保留主键和两个常用二级索引,模拟生产环境的真实状态。

DuckDB 没有传统 B+ 树索引,它依赖的是**数据块元数据(Zone Map)**和全列统计信息来裁剪扫描范围。第二组查询的时间过滤条件,DuckDB 会直接跳过不满足时间范围的 row group,实现类似分区裁剪的效果。两种索引哲学的差异也解释了为什么 DuckDB 不怕全表扫描——它天生就是为全扫描优化,而 MySQL 的分析 SQL 一旦索引失效就会退化到全表扫描的灾难模式。

5.3 DuckDB 的短板:不是所有场景都比 MySQL 快

如果只看上面的数据,很容易得出"DuckDB 全面碾压 MySQL"的结论——这恰恰是最大的误读。我在测试里专门补了三组额外场景:

第一是单行点查SELECT * FROM user_behavior WHERE id = 123456,MySQL 用主键索引 0.8 毫秒返回,DuckDB 需要扫描所有数据块定位,耗时约 280 毫秒,反过来慢了三百多倍。

第二是并发写入:DuckDB 的单写者模型限制同一时刻只能有一个进程写入,MySQL 轻松支持几十个并发连接同时写入。我用 8 个线程并发插入 10 万行,MySQL 耗时 6.2 秒,DuckDB 直接报锁冲突,只能退化为串行写入。

第三是事务能力:MySQL ACID 事务、行级锁、外键约束这些能力是 DuckDB 不具备的。DuckDB 支持完整的事务,但它的定位是分析型负载,在高并发小事务场景下完全不是 MySQL 的对手。

5.4 实际业务里的选型建议:哪个场景该用谁

回到最初的问题:超大数据集下,到底该怎么选?我的建议很直接:

MySQL 继续承担在线交易、用户鉴权、订单状态这类需要强一致和并发写的能力。这是它的主场,不要因为分析查询慢就否定它。线上业务的核心数据永远放在 MySQL。

DuckDB 适合做数据分析、报表统计、数据导出、临时查询,以及数据开发过程中各种"跑一次就完事"的探索性分析。它的嵌入特性特别适合接到 Python 数据管道里,一条duckdb.query(sql)直接在 DataFrame 上跑复杂 SQL,连导出数据的功夫都省了。

我现在的典型实践是:MySQL 生产库通过 binlog 同步或定期导出到 DuckDB,分析任务跑到 DuckDB 上,报表系统读取 DuckDB 的查询结果。这样分析查询不再抢 MySQL 的资源,OLTP 性能不受影响,分析速度反而提升了两个数量级。MySQL 那张 6 亿行的行为日志表,我的批处理任务从四十分钟压到了三分钟以内,靠的就是把"报表查询"和"在线交易"彻底分开。

5.5 一个实用的迁移路径:MySQL 数据如何快速进入 DuckDB

如果你也想在自己的环境里复现这套方案,最直接的迁移路径是用duckdb_mysql扩展直接读取 MySQL 的表,或者用最笨也最稳的 CSV 中转方式。实测 6 亿行数据用 MySQL 的SELECT ... INTO OUTFILE导出再 COPY 进 DuckDB,总耗时半小时左右,比在 MySQL 里跑全表聚合还快。

import duckdb # 方式一:直接读 MySQL 数据库(需要 mysql 扩展) conn = duckdb.connect() conn.install_extension("mysql") conn.load_extension("mysql") df = conn.execute("SELECT * FROM mysql_query('host=127.0.0.1 user=root password=*** database=test', 'SELECT * FROM user_behavior')").df() # 方式二:CSV 中转后 COPY conn.execute("COPY user_behavior FROM '/data/user_behavior.csv' (FORMAT CSV, HEADER)")

第二种方式更可控,CSV 中间文件删掉后磁盘占用只有 DuckDB 单文件的大小,28 GB 左右,比 MySQL 源库省了接近三分之二的空间。

6. 回归场景:这次对比给我带来的实际改变

测试做完了,结论也清晰了。与其说这是一次数据库性能对比,不如说它让我重新梳理了自己的数据架构思路。测试过程中最深的体会是,性能对比最容易骗人的地方在于只比快慢,不比场景。MySQL 慢不是它差,而是我用错了地方;DuckDB 快也不是全能的判断标准,它在点查和高并发写入上的短板同样明显。

现在这条业务线的新需求里,只要涉及分析统计,我第一个想到的就是 DuckDB。一个 500 MB 的.duckdb文件,复制到任何机器上都能直接查,不需要装服务端,不需要配账号权限,这对于临时数据分析和报表开发来说太方便了。MySQL 则在它的 OLTP 岗位上一如既往地稳定扛着,两边各司其职,比过去让 MySQL 一个库硬扛所有工作舒服得多。

如果你也在为超大单表分析查询头疼,我建议不要急着上 Hadoop 或者 ClickHouse 集群,先拿 DuckDB 在真实数据集上跑一遍,很可能最轻量的方案就能解决问题。当然,如果你需要每秒上万的并发 OLTP,那还是老老实实优化 MySQL;如果你需要的是几十个节点的大集群分布式能力,DuckDB 也不是目标。每个引擎都有自己的生态位,找到匹配的生态位远比追求单一性能数字更重要。这是我做这次对比测试最大的收获,也是我最想分享给同样被慢查询困扰的开发者的一句话。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/17 2:57:20

工业无线遥控器串频、掉线、频繁坏?从原理到排查选型一次讲清

行吊、龙门吊、卷扬机&#xff0c;这些设备一旦配上遥控器&#xff0c;就默认了它必须"随时响应、指哪打哪"。可在实际产线上跑了几年&#xff0c;我发现工业无线遥控器从来不是装上就能省心的东西——信号串频导致误动作、操作中突然掉线、按键摇杆用了没几个月就失…

作者头像 李华