
1. 起因一张8000万行的流水表逼我开始对比 DuckDB 与 MySQL1.1 一个周五下午的翻车现场上个月底运营同事扔给我一张接近 8000 万行的订单明细表让我按渠道、按天做一次销售汇总。我习惯性打开 MySQLSQL 写好后敲下回车然后就是漫长的等待。第一次跑到 90 秒还没出结果我当时还以为是线上业务在抢资源又等了半分钟最终 MySQL 给出结果但整个临时表膨胀得厉害连带同库的其他查询都受了影响。这不是我头一回遇到这种情况。早年数据量只有几百万行的时候MySQL 做点聚合、报表查询都很舒服但随着公司业务量增长单表几千万行甚至上亿行后原本好用的 InnoDB 开始力不从心。那次周五下午我用一整个晚上研究替代方案最后在一个技术群里被人安利了 DuckDB。我先是拿同一张表在本地跑了一下同样的聚合逻辑MySQL 跑一分多钟的查询DuckDB 两秒多就回来了。这个差距让我开始认真做一轮“超大数据集下 DuckDB 与 MySQL 查询速度对比”的实测才有了这篇文章。1.2 DuckDB 到底是什么定位很多人第一次听到 DuckDB 会以为它又是一个“新型数据库”需要部署服务端、配置连接池、做高可用像 ClickHouse 那样搞一套集群。其实 DuckDB 是嵌入式分析型数据库简单说它不是一个服务而是一个库你可以把它嵌入到 Python、R、Java 程序里也可以直接通过 CLI 使用。它的核心特性是列式存储、向量化执行引擎、自动并行扫描能直接查询 Parquet、CSV 甚至本地的 MySQL 业务库。这意味着你不需要像 ClickHouse 那样搭建多个节点只要一台“稍微大一点的机器”就能在单机上处理几十 GB 到几百 GB 的分析数据。在我当时考虑的几个工具里ClickHouse 和 Presto 都需要额外运维DuckDB 几乎零成本落地最适合这个场景。这篇对比文章我会用 TPC-H 生成的 10GB 基准数据在同一台服务器上分别部署 MySQL 8.0 和 DuckDB测试三类典型分析查询的耗时差异然后从架构层面解释为什么会有这么大的差距最后给出我的选型建议和生产环境配合方案。如果你也正在被“超大数据集 MySQL 聚合慢”折磨这篇文章应该能帮到你。2. 测试设计与数据准备要对比就在同一批数据上打2.1 测试环境与版本选择做性能对比最忌讳“夹带私货”比如一边是 SSD 一边是机械盘或者一边内存 64G 一边 8G。我把两个数据库放在同一台服务器上确保硬件条件完全一致。项目配置CPUIntel Xeon E5-2680 v48 核分配内存32 GB DDR4存储NVMe SSD顺序读约 1.5 GB/s操作系统Ubuntu 22.04 LTSMySQL8.0.36InnoDB 引擎DuckDB1.1.x通过 Python 3.10 客户端调用数据集TPC-H SF10约 10 GB 原始文本lineitem 表约 6000 万行MySQL 我用了官方 APT 源安装DuckDB 直接用pip install duckdb没有做任何特殊参数优化尽量模拟一个开发/数据分析人员拿到默认数据库时的真实体验。2.2 用 TPC-H 造一份 10GB 的基准数据我没有直接用业务流水表因为那张表数据虽然多但字段分布不一定典型而且涉及到公司内部数据不适合公开分享。TPC-H 是数据库领域公认的决策支持基准测试集包含订单、客户、商品、供应商等 8 张表能模拟真实的分析查询场景。用 dbgen 生成 SF10 的数据命令如下./dbgen -s 10 -f生成后你会得到一堆以.tbl结尾的文本文件其中lineitem.tbl约 7.5 GBorders.tbl约 1.5 GB其余小表加起来不到 1 GB。这里有一个需要注意的点dbgen 生成的表结构是标准的MySQL 和 DuckDB 都能支持非常适合做跨数据库对比。2.3 建表差异MySQL 要加索引DuckDB 免索引在 MySQL 里我按照 TPC-H 标准建表主键和外键部分按规范添加索引。TPC-H 的 lineitem 表比较特殊主键是联合主键(l_orderkey, l_linenumber)另外我会给常用的l_shipdate、l_partkey等列建上二级索引因为后面的测试查询要用。建表语句大致如下CREATE TABLE lineitem ( l_orderkey INTEGER NOT NULL, l_partkey INTEGER NOT NULL, l_suppkey INTEGER NOT NULL, l_linenumber INTEGER NOT NULL, l_quantity DECIMAL(15,2) NOT NULL, l_extendedprice DECIMAL(15,2) NOT NULL, l_discount DECIMAL(15,2) NOT NULL, l_tax DECIMAL(15,2) NOT NULL, l_returnflag CHAR(1) NOT NULL, l_linestatus CHAR(1) NOT NULL, l_shipdate DATE NOT NULL, l_commitdate DATE NOT NULL, l_receiptdate DATE NOT NULL, l_shipinstruct CHAR(25) NOT NULL, l_shipmode CHAR(10) NOT NULL, l_comment VARCHAR(44) NOT NULL, PRIMARY KEY (l_orderkey, l_linenumber), KEY idx_shipdate (l_shipdate), KEY idx_partkey (l_partkey) ) ENGINEInnoDB;DuckDB 这边就简单很多它是分析型引擎默认就是列式存储不需要像 InnoDB 那样建二级索引。我直接建表或者更干脆在测试时直接用 SQL 查询原始.tbl文件。DuckDB 支持对外部文件的“零导入查询”这本身就是它的一大卖点。为了公平对比导入后的查询速度我同时导了一份进 DuckDB。2.4 导入耗时的对比谁也别想“白嫖”把 10GB 文本数据导入 MySQL我用的是LOAD DATA INFILE命令类似LOAD DATA INFILE /tmp/lineitem.tbl INTO TABLE lineitem FIELDS TERMINATED BY |;实测导入 lineitem 表大概花了 7 分多钟如果算上建索引的时间整体在 10 分钟左右。DuckDB 的导入可以用COPY语句COPY lineitem FROM /tmp/lineitem.tbl (DELIMITER |);同样的数据DuckDB 的 COPY 导入大约花了 50 秒。这里有人可能会说“这不公平MySQL 要维护索引”但这也是现实中的使用成本——MySQL 要支撑 OLTP 业务必然要建索引导入慢是它整个设计路线的一部分。3. 实测跑分三组典型分析查询的真实耗时3.1 宽表聚合GROUP BY 与多列统计第一组测试我选了 TPC-H 里最经典的 Q1 查询它在 lineitem 表上做一次大范围过滤然后按l_returnflag和l_linestatus分组分别计算总量、金额、折扣和行数。这个查询属于典型的全表扫描 分组聚合最能体现分析引擎的扫描和计算能力。SELECT l_returnflag, l_linestatus, SUM(l_quantity) AS sum_qty, SUM(l_extendedprice) AS sum_base_price, AVG(l_discount) AS avg_disc, COUNT(*) AS count_order FROM lineitem WHERE l_shipdate DATE 1998-09-02 GROUP BY l_returnflag, l_linestatus ORDER BY l_returnflag, l_linestatus;在同时清空系统缓存的前提下MySQL 第一次执行耗时 81.6 秒第二次执行因为 lineitem 表数据大多进入了 InnoDB Buffer Pool耗时降到 46.3 秒。DuckDB 第一次执行耗时 2.8 秒第二次执行 1.9 秒。我把两个数据库分别冷、热状态下的耗时都列在下面。场景MySQLDuckDB冷缓存首次81.6 秒2.8 秒热缓存二次46.3 秒1.9 秒这个差距已经不能用“一点点”来形容了。DuckDB 几乎快了 20 到 40 倍。更大的问题是MySQL 执行期间占用了大量 CPU 和临时表空间而 DuckDB 跑同样查询时资源占用还更平稳。3.2 范围过滤 排序取 TopN有索引也未必救得回来第二组测试是业务里最常见的“按时间范围查订单并按金额排序取前 100 条”。我用的 orders 表约 1500 万行查询某三个月内的订单按照订单总金额倒序取前 100。SELECT o_orderkey, o_orderdate, o_totalprice FROM orders WHERE o_orderdate DATE 1995-01-01 AND o_orderdate DATE 1995-04-01 ORDER BY o_totalprice DESC LIMIT 100;MySQL 这边我在o_orderdate上有索引理论上可以走 range scan但问题是符合条件的行数太多MySQL 优化器最后选择了全表扫描 filesort。实测耗时 14.7 秒热缓存DuckDB 耗时 0.9 秒。如果数据量再大一些MySQL 的 filesort 会把临时结果放到磁盘耗时会更恐怖。这个案例让我意识到MySQL 的二级索引只对“能显著缩小结果集”的查询有效。当一个范围条件命中超过表数据的 10% 甚至 20% 时优化器宁愿全表扫因为回表随机 I/O 太贵了。而 DuckDB 根本不依赖索引它靠的是大批量顺序扫描列数据这个设计差别直接决定了性能走势。3.3 多表 JOIN 后聚合三方关联一场大戏第三组测试是 TPC-H 里比较重的一个变体把 customer、orders、lineitem 三张表 JOIN 起来按客户市场分段和月份统计营收。这个查询涉及 150 万客户行、1500 万订单行和 6000 万明细行。SELECT c_mktsegment, strftime(o_orderdate, %Y-%m) AS yyyymm, SUM(l_extendedprice * (1 - l_discount)) AS revenue FROM customer, orders, lineitem WHERE c_custkey o_custkey AND l_orderkey o_orderkey AND l_shipdate DATE 1996-01-01 AND l_shipdate DATE 1996-04-01 GROUP BY c_mktsegment, yyyymm ORDER BY yyyymm, c_mktsegment;MySQL 8.0 已经支持 hash join但受限于执行器和线程模型这个查询实测跑了 312 秒将近 5 分钟。DuckDB 同样逻辑跑了 6.8 秒。中间我还特意用EXPLAIN ANALYZE看过 MySQL 的执行计划它确实用了 hash join但临时表和物化结果消耗了大量时间而且单查询没有真正的多线程并行能力。这三组测试综合下来在“超大数据集 分析类查询”这个靶向上MySQL 和 DuckDB 完全不是一个量级。我后面会专门讲为什么先继续聊聊测试方法里一个容易翻车的地方。3.4 冷缓存与热缓存的区分别把不公平测试当结论网上很多对比测试只跑一次然后就把数字放出来这是不严谨的。MySQL 有个 InnoDB Buffer Pool默认会缓存数据页第一次查询慢、第二次查询明显加快DuckDB 虽然有自己的内存池但数据文件也会被操作系统的 page cache 缓存。如果测试时不区分冷热很容易得到“被冤枉”的结论。我这里“冷缓存”的操作方法是MySQL 重启前先记录innodb_buffer_pool_size然后执行SET GLOBAL innodb_buffer_pool_dump_at_shutdownOFF重启实例确保 Buffer Pool 是空的同时用sync; echo 3 /proc/sys/vm/drop_caches清空操作系统文件缓存。DuckDB 侧通常不需要重启清空 OS page cache 就够了因为 DuckDB 不太依赖自己的持久化缓存主要靠 OS 缓存。为什么要强调这一步因为如果你的业务是“同样的查询反复跑”热缓存就是真实体验但如果你是做 BI 看板、临时取数每次查询的数据范围可能都不同冷缓存才是常态。两者都测了才能对数据库能力有客观认识。4. 为什么 DuckDB 会快这么多架构差异不是玄学4.1 列式存储只要一列数据就别把整行都翻出来MySQL 的 InnoDB 是典型的行式存储一行的所有列在磁盘上是连续存放的。当你执行SUM(l_quantity)时InnoDB 需要把符合条件的数据行整行读出来哪怕里面 15 个字段里你只要 1 个字段也要把整行从磁盘拉到内存。磁盘 I/O 是性能瓶颈行式存储在这个场景下等于“为了一颗白菜把整个菜市场搬回家”。DuckDB 是列式存储同一列的数据在磁盘上连续存放。查询只涉及l_quantity和l_returnflag就只读这两列其他 13 列完全不碰。我测过在同样的 SSD 上DuckDB 读取一列时的实际磁盘 I/O 可能是 MySQL 的几十分之一。这个差异在宽表上尤其明显表越宽行式存储越吃亏。4.2 向量化执行与批量计算如果说列式存储减少了数据读取量那向量化执行就是让“处理数据”这个环节变快。MySQL 传统执行器是“一行一行处理”的迭代模型每拿到一行数据就执行表达式计算、聚合更新这种模型的优点是实现简单缺点是 CPU 分支预测和函数调用开销巨大。DuckDB 的向量化引擎一次处理一批数据通常是一批 1024 行或 2048 行。你可以理解为 MySQL 像一个工人逐个检查档案袋而 DuckDB 是一条流水线每次把一箱档案推过去批量完成校验、计算和归类。批量方式能充分利用 CPU 的 SIMD 指令让一次计算同时处理多个数据这是 10 倍以上性能差距的一个重要来源。4.3 压缩、并行扫描与聚合的协同作战DuckDB 在列式存储之上还会做轻量级压缩比如字典压缩、位图压缩、RLE 压缩等。压缩的直接收益是磁盘 I/O 更少数据从磁盘读到内存后在内存里解压并进行向量化计算整体吞吐量反而更高。MySQL 也有表压缩和页压缩但它的主要场景是 OLTP压缩和解压的 CPU 开销可能拖慢写路径所以默认并不会启用。并行扫描方面DuckDB 默认会把一张表的数据划分成多个范围用多个线程同时扫描和预聚合最后再做合并。我的机器是 8 核跑聚合查询时 CPU 能跑到 6 到 7 个核的利用率。MySQL 8.0 在单条查询上虽然也有一些并行改进但对这种大范围聚合查询实际执行中很难把多核用起来经常是一个线程在单打独斗。4.4 那 MySQL 的 B 树索引去哪了为什么不救场有人说“MySQL 不是有大名鼎鼎的 B 树索引吗为什么还会这么慢”关键在于索引只擅长快速定位少量数据。WHERE id 123这种点查询索引能让 MySQL 在毫秒级返回可一旦查询变成“扫描 6000 万行并做聚合”索引基本不起作用只能全表扫描而且行式存储的全表扫描效率远低于列式存储的顺序扫描。更要命的是如果查询条件命中了二级索引MySQL 还得根据索引里的主键值回表读取整行数据这会产生大量随机 I/O。随机 I/O 比顺序 I/O 慢一到两个数量级。所以我看到不少朋友给 MySQL 的每个查询字段都建了索引结果大查询一样慢原因就在这里分析型查询要的是全量扫描和并行计算不是一条一条精确查找。5. 该用 DuckDB 还是 MySQL以及让两者协作的实战方案5.1 OLTP / OLAP 十字路口选型判断清单读到这里你可能会想“那我是不是可以直接把 MySQL 换成 DuckDB”我的答案是得分场景。DuckDB 在超大数据集的分析查询里确实强但它并不是 MySQL 的“全能替代品”。场景MySQLDuckDB高频小事务写入推荐InnoDB 为写入做了大量优化不推荐嵌入式引擎不适合高并发写入点查询按主键找一行推荐B 树索引毫秒级返回可以但并发能力有限宽表聚合、报表分析容易慢尤其大表强烈推荐列存 向量化优势巨大复杂多表 JOIN慢临时表膨胀推荐执行引擎更适合哈希连接高并发在线服务推荐连接池和权限体系成熟不推荐单进程多线程模型压不住高并发数据导入导出一般LOAD DATA 可接受很强可直接查外部文件如果你做的是 OLTP 业务系统比如电商订单、用户中心、后台管理MySQL 依然是最稳妥的选择。但如果你跑的是数据分析、报表、临时取数、ETL 清洗DuckDB 的高性能和零运维会非常讨喜。5.2 用 DuckDB 的 mysql 扩展直接查业务库DuckDB 有一个 mysql 扩展可以直接把 MySQL 当作外部数据源来查询。这个功能在做“快速临时分析”时特别方便无需导出文件无需同步任务。基本用法如下INSTALL mysql; LOAD mysql; ATTACH host127.0.0.1 userroot passwordyour_password databaseyour_db AS mysqldb (TYPE mysql); SELECT c_mktsegment, SUM(l_extendedprice * (1 - l_discount)) AS revenue FROM mysqldb.lineitem l JOIN mysqldb.orders o ON l.l_orderkey o.o_orderkey JOIN mysqldb.customer c ON o.o_custkey c.c_custkey GROUP BY c_mktsegment;需要注意边界DuckDB 的 mysql 扩展会把数据从 MySQL 拉到 DuckDB 再进行计算如果数据量特别大例如你要拉全表 6000 万行那受制于 MySQL 端扫描和网络传输速度不会像查本地列存文件那么快。所以我的经验是数据量在百万到千万级别、并且有较强过滤条件时这个扩展非常好用如果数据量再大还是建议走“导出 Parquet 本地分析”的路线。5.3 一个可落地的“MySQL 负责生产DuckDB 负责分析”架构综合我实际使用的场景我更推荐一个混合架构MySQL 继续作为业务主库保证在线交易稳定DuckDB 作为分析加速层专门承接报表和复杂查询。具体做法是每天凌晨用脚本把 MySQL 里的超大表导出为 Parquet 文件放到本地磁盘或对象存储再让 DuckDB 直接查询这些 Parquet 文件。导出任务可以是一条简单的 SQL 配合命令行工具mysql -h127.0.0.1 -uroot -p -N -e \ SELECT * FROM lineitem WHERE l_shipdate 1995-01-01 \ lineitem_increment.csv python3 - EOF import duckdb duckdb.sql( COPY (SELECT * FROM read_csv(lineitem_increment.csv)) TO lineitem_increment.parquet (FORMAT parquet); ) EOFParquet 文件不仅比 CSV 更省空间DuckDB 读取列存格式时可以跳过无关列查询速度会再上一个台阶。这个方案的优点是MySQL 的负担大大降低DuckDB 的分析性能能得到充分释放而且整个链路没有引入复杂的分布式组件一个定时脚本就能搞定。6. 实操中的几个高频踩坑点6.1 内存管理DuckDB 把机器打满怎么办DuckDB 默认会使用可用内存的较大比例来做缓存和中间结果。单条分析查询可能几秒跑完但如果同时跑多个 DuckDB 进程或者并行任务太多很容易把机器内存打满甚至触发 OOM。我的习惯是在 DuckDB 会话里显式设置资源上限SET memory_limit 16GB; SET threads 6;threads要根据机器 CPU 核数来定不是越大越好。如果任务并发多适当调低线程数反而能减少调度开销整体吞吐更稳。另外DuckDB 1.1 之后对内存溢出的支持也变好了超内存时会 spill 到磁盘但 spilling 会明显降低性能能通过memory_limit规划好就尽量规划好。6.2 MySQL 这边值得调的参数既然要和 MySQL 共存那就把 MySQL 也调一调。针对分析类查询我主要改了innodb_buffer_pool_size把它从默认的 128MB 调到 16GB也就是机器内存的一半。这个参数决定了 InnoDB 能缓存多少数据页对第二次查询的加速效果非常明显。另外如果你经常跑大聚合可以考虑临时表从内存转磁盘的阈值。MySQL 8.0 的临时表默认使用 TempTable 引擎如果数据量大tmp_table_size和max_heap_table_size的设置会影响是否走磁盘临时表。我一般保持默认因为磁盘临时表虽然慢但至少不会 OOM线上稳定优先。6.3 测试结论的“保质期”版本、硬件、数据分布都会翻盘最后说一个比较容易忽略的点性能对比没有“一劳永逸”的结论。DuckDB 迭代非常快我半年前测的一个查询在旧版本上要 3 秒新版本可能 1 秒就出来了MySQL 同样在持续优化8.0 的 hash join、后续版本的并发能力都在变强。硬件也很关键如果你的机器是普通 HDDMySQL 和 DuckDB 的差距可能没那么夸张因为瓶颈都在磁盘咋转如果是 NVMe SSD 加高主频 CPUDuckDB 的优势会更明显。数据分布也会影响结果。比如一个表的某个字段只有 3 个取值用 DuckDB 的 RLE 压缩会非常受益MySQL 却没法在行式存储里享受这种红利。反过来如果你的查询每次都命中唯一索引、只取几行MySQL 会反超 DuckDB。所以我不建议大家直接照搬我这组数字而是要掌握测试方法在你自己的数据、你自己的硬件上复现一遍。我自己现在的工作习惯是MySQL 继续管业务流水DuckDB 管分析报表两边各司其职。超大数据集下的查询速度对比做完了我更确信一个道理——“快”不光是引擎的功劳更是选型和场景匹配的结果。你拿分析查询去为难 OLTP 数据库它自然会吃力你拿 OLTP 事务去压 DuckDB它也不擅长。搞清楚数据库的脾气把它们放到合适的场景里比单纯鼓吹某个数据库“天下第一”有用得多。