ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

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

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 vCPUAMD EPYC 7K62主频 2.6GHz内存64 GB DDR4磁盘1 TB NVMe SSD操作系统Ubuntu 22.04 LTSMySQL8.0.36InnoDB 引擎DuckDB1.1.3通过 Python 客户端调用Python3.10.12MySQL 8.0 和 DuckDB 1.1.x 都是目前两个项目的主线稳定版本用它们对比代表的是 2025 年左右的真实水平。注意一点DuckDB 的 Python 包内置了最新稳定版直接用pip install duckdb就能装不需要单独部署服务。MySQL 端我按生产环境常规方式配置innodb_buffer_pool_size设为 16 GBinnodb_flush_log_at_trx_commit2允许每秒刷盘换吞吐。DuckDB 这边主要调了两个参数memory_limit设为 48 GBthreads设为 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) ) ENGINEInnoDB;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 GBDuckDB4 分 20 秒约 28 GBDuckDB 装载快有两个原因一是列式压缩极大减小了写入量二是它按列批量写入的格式天然比 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 和 UVSELECT 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 倍第三组分组 TopN10 亿行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只占行大小的一部分读磁盘时却要把id、user_id、page_id、action_type、event_time、city_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_id和idx_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 123456MySQL 用主键索引 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(host127.0.0.1 userroot password*** databasetest, 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 也不是目标。每个引擎都有自己的生态位找到匹配的生态位远比追求单一性能数字更重要。这是我做这次对比测试最大的收获也是我最想分享给同样被慢查询困扰的开发者的一句话。
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进