资讯动态

MySQL与DuckDB对决:1亿行数据下OLTP与OLAP引擎性能解析

发布时间:2026/9/14 2:09:47 来源:尧图企业网站定制
先说个让我挺意外的实测结果在单表 1 亿行的数据集上一条带 WHERE 条件的分组聚合查询MySQL 跑了几十秒DuckDB 只花了不到一秒。这个差距不是调优能追回来的而是两种数据库引擎的设计哲学压根不在一个赛道上。这篇文章不是要证明谁取代谁而是想用一次相对公平的对比测试把 DuckDB 和 MySQL 在超大数据集下的查询速度差异、背后的引擎原理、以及实际落地时该怎么选型一次性讲清楚。我尽量把整个测试过程还原出来包括环境准备、数据生成、四类典型查询的对比结果以及几个我踩过的坑。如果你正在纠结“要不要把分析查询从 MySQL 迁到 DuckDB”或者刚接触 DuckDB 想找个参照系这篇文章应该能给你一个靠谱的参考。1. 为什么要把 OLTP 的 MySQL 拉来和 OLAP 的 DuckDB 同台竞技1.1 MySQL 是绝大多数后端系统的默认选择但不代表它适合所有查询MySQL 太常见了常见到很多团队把所有数据都往里塞。订单、用户、日志、埋点、配置全在一张张 InnoDB 表里躺着。对于线上业务来说这没什么问题MySQL 在 OLTP在线事务处理场景下的稳定性、事务能力和生态成熟度都经得起考验。但一旦涉及分析类查询MySQL 就开始吃力了。最典型的情况是一张表几千万甚至上亿行你需要按某个维度分组统计或者做多表关联后再聚合。这类查询的特点是扫描数据量大、计算密集、但并发要求不高。MySQL 的 InnoDB 存储引擎是行式存储按行组织数据加上 B 树索引的随机读取特性跑一次全表聚合扫描可能要动辄几十秒甚至几分钟。这时候很多人第一反应是加索引。索引确实能加速点查和部分范围查询但遇到需要扫描全表大部分数据的 OLAP 型查询时索引几乎帮不上忙——你总要一行行读出来做聚合。这也是我在实际业务里反复遇到的痛点线上 MySQL 压力不大但每次跑个报表查询DBA 就收到慢查询告警。1.2 DuckDB 的定位嵌入式 OLAP 数据库专为分析而生DuckDB 是近两年在数据分析圈子里非常火的一个嵌入式数据库。它不需要独立部署服务器直接在进程内运行像 SQLite 一样通过一个库文件就能使用但处理的是 OLAP联机分析处理工作负载。它和 MySQL 最本质的区别在于存储和执行模型。DuckDB 采用列式存储也就是同一列的数据连续存放在一起执行引擎是向量化的也就是一次处理一批数据通常是 1024 行或 2048 行而不是像 MySQL 那样逐行遍历。配合多核并行对于大数据集的扫描和聚合DuckDB 在架构上有天然优势。所以与其说这是“对比两种数据库”不如说这是一次“OLTP 引擎和 OLAP 引擎在不同查询模式下的能力边界测试”。了解两边的强项和短板才知道什么时候该在什么工具上干活。2. 测试环境同一份数据、同样的查询尽量公平地比2.1 MySQL 环境准备Docker 方式为了快速搭建一个干净的 MySQL 环境我用了 Docker 而不是本机安装。我的机器是 8 核 16G 内存的 Linux 服务器MySQL 版本是 8.0.36Docker 容器分配了 8G 内存。docker run --name mysql-test \ -e MYSQL_ROOT_PASSWORDtest123 \ -e MYSQL_DATABASEbench \ -d \ -p 3306:3306 \ mysql:8.0进去之后确认一下参数。MySQL 8.0 默认的innodb_buffer_pool_size是 128M这对大数据集查询非常不友好我手动调到了 6G。这一步很关键否则 MySQL 的数据基本都在磁盘上做物理读结果参考意义不大。SET GLOBAL innodb_buffer_pool_size 6 * 1024 * 1024 * 1024;2.2 DuckDB 环境准备Python 方式DuckDB 我用的是 Python 版本安装一行命令搞定pip install duckdbPython 版本为 3.11DuckDB 版本为 1.0.0。启动连接、设置线程数后就可以直接使用import duckdb con duckdb.connect(bench.db) con.execute(PRAGMA threads8) con.execute(PRAGMA memory_limit6GB)内存限制设为 6G 是为了和 MySQL 的 buffer pool 保持对称两边都有约 6G 的内存配额可用。查询时间统一用秒记录。2.3 1 亿行测试数据的生成思路测试表我设计得比较贴近真实业务一张订单事实表orders包含订单 ID、用户 ID、商品 ID、订单状态、金额、创建时间。用 Python 脚本生成 1 亿行数据分别导入 MySQL 和 DuckDB。import random import duckdb import pymysql from datetime import datetime, timedelta # 生成 1 亿行订单数据的参数 TOTAL_ROWS 100_000_000 BATCH_SIZE 100_000 def gen_order(row_id): user_id random.randint(1, 2_000_000) product_id random.randint(1, 50_000) status random.choice([pending, paid, shipped, completed, cancelled]) amount round(random.uniform(10, 5000), 2) created_at datetime(2020, 1, 1) timedelta(secondsrandom.randint(0, 5 * 365 * 24 * 3600)) return (row_id, user_id, product_id, status, amount, created_at)值得一提的细节MySQL 导入 1 亿行我用的是分批执行LOAD DATA LOCAL INFILE每批 100 万行DuckDB 则直接读取 CSV 文件或者用INSERT INTO SELECT从 DataFrame 灌入。前者花了大概 18 分钟后者只用了不到 3 分钟。这个导入速度差异本身就说明了一些问题后面会拆解原因。数据导入完成后MySQL 端表大小约 7.2GDuckDB 端整个数据库文件约 2.8G。注意这个差异——同样的数据列式存储比行式存储节省了一半以上的空间。提示如果你的机器配置比这低可以把数据量缩小到 1000 万行结论方向基本一致只是耗时等比缩小。3. 四组典型查询的实测热身、聚合、关联与窗口函数3.1 热身查询全表 COUNT 与 AVG 的差距有多夸张第一组查询看起来最“无脑”统计订单总数、订单总额、平均订单金额。这个查询没有索引可用MySQL 必须完整扫描全表。查询MySQL 耗时DuckDB 耗时备注SELECT COUNT(*) FROM orders7.8s0.04sDuckDB 无需扫描所有列SELECT COUNT(*), SUM(amount), AVG(amount) FROM orders12.6s0.18sMySQL 全列扫描SELECT COUNT(*) FROM orders WHERE statuspaid9.4s0.11s过滤条件下扫描COUNT(*) 是 MySQL 里最容易被误解的查询之一。很多人以为它会像 MyISAM 那样直接返回一个预存的行数但 InnoDB 并不保存表的总行数统计所以哪怕只是数行数也要走一次全表扫描。我看到EXPLAIN结果里显示 type 为 ALL也就是全表扫描7.8 秒是在读 7.2G 的数据文件。DuckDB 这边之所以能跑到 0.04 秒是因为它内部对每个列块保存了统计信息包括行数。COUNT(*) 这种查询它甚至可以不碰具体数据块直接读元数据返回。这个能力在分析型数据库里很常见MySQL 的 InnoDB 则完全没有。3.2 分组聚合GROUP BY 是 OLAP 最核心的考题接下来是分析场景最常见的 GROUP BY 查询按订单状态分组统计订单数和金额SELECT status, COUNT(*), SUM(amount) FROM orders GROUP BY status;查询MySQL 耗时DuckDB 耗时按状态分组5 组15.2s0.22s按用户分组200 万组42.8s0.61s按商品分组5 万组31.5s0.48s如果把维度组合起来再带 WHERE 条件差距会进一步拉大。比如SELECT product_id, COUNT(*), SUM(amount) FROM orders WHERE created_at 2023-01-01 AND status ! cancelled GROUP BY product_id ORDER BY SUM(amount) DESC LIMIT 10;这条查询在 MySQL 上跑了 48 秒DuckDB 只用了 0.8 秒。按用户分组到 200 万组时MySQL 开始大量使用临时表和文件排序操作磁盘 I/O 明显飙升DuckDB 基于哈希分组的实现则稳得多因为它的所有中间结果都尽可能留在内存里配合多线程在分区上并行哈希。分组聚合的差距在 OLTP 和 OLAP 引擎之间是最有代表性的。MySQL 的 GROUP BY 实现较为传统依赖临时表加索引扫描的方式一旦分组数量大、结果集宽性能就会急剧下降。DuckDB 的向量化聚合算子则是面向这种场景专门优化过的。3.3 两表关联本地 JOIN 是 DuckDB 的舒适区单表测试终究不够过瘾我又加了一张 500 万行的products表做关联测试。查询需求是统计每个商品分类在已完成订单中的销售额。SELECT p.category, SUM(o.amount) AS total_sales FROM orders o JOIN products p ON o.product_id p.id WHERE o.status completed GROUP BY p.category;查询MySQL 耗时DuckDB 耗时JOIN GROUP BY无索引102.3s3.1sJOIN GROUP BYproduct_id 加索引68.7s3.1sJOIN WHERE 过滤 ORDER BY87.5s2.6sMySQL 在 product_id 上加了索引之后耗时从 102 秒降到 68 秒索引带来了一部分提升但仍然远远无法和 DuckDB 相比。这背后的原因是JOIN 的核心问题在于如何匹配两个表的行。对于等值连接MySQL 通常会选择一个表作为驱动表通过索引去另一个表逐行查找匹配项也就是 Nested Loop Join。如果驱动表有 1 亿行即便每次索引查找只要 0.1 毫秒总时间也要 1 万秒所以 MySQL 优化器会选择先过滤再连接可哪怕过滤后只剩 3000 万行还是要做大量随机 I/O。DuckDB 对这类等值连接默认使用哈希连接先把小表扫描一遍构建哈希表然后大表只需要逐行探测哈希表即可时间复杂度近似线性的扫描开销加上向量化执行和并行分区3 秒多完成并不奇怪。3.4 窗口函数row_number 排序在 1 亿行上的表现最后测试的是窗口函数场景按用户分组对每个用户的订单金额做排名取每组前 3 名。这是典型的分组 TopN 查询。SELECT user_id, order_id, amount, ROW_NUMBER() OVER (PARTITION BY user_id ORDER BY amount DESC) AS rn FROM orders;完整跑出 1 亿行的排名结果在实际业务里很少见因为返回结果集太大。但为了测试引擎极限我完整跑了一次同时也在外层加了WHERE rn 3来模拟真实使用方式。查询MySQL 耗时DuckDB 耗时完整窗口函数排序156.4s7.2s外层过滤 Top 3128.9s2.8sMySQL 在窗口函数上的表现非常吃力原因在于它需要把全量数据按 user_id 分区后排序这个排序过程要落临时表。1 亿行数据排序后写到磁盘临时表再扫描输出开销极大接近两分半。DuckDB 则能够在内存中以流水线方式处理窗口计算配合并行分区把排序分摊到多核上。注意以上所有查询均在同一台机器上顺序执行MySQL 重启后先做了一次预热查询再计时DuckDB 也启动了线程池。时间必然会受具体硬件和版本影响但量级的差异是稳定的。4. 结果背后的引擎差异列式存储、向量化执行与并行调度4.1 存储格式差异决定了 I/O 量级MySQL InnoDB 的表是行式存储。所谓行式存储指的是磁盘上一条记录的所有字段连续存储在一起。当查询只需要amount一列时MySQL 仍然必须把完整行读出来从磁盘加载到内存的字节量是所有列的总和。我在测试表里一共有 6 列所以即使只算金额和状态也要读完全部 6 列的数据实际 I/O 是必要数据的 3 到 6 倍。DuckDB 是列式存储每列的数据独立且连续存放在文件里。查询只涉及amount和status两列时它只需要读取这两个列块其他列完全不碰。在 1 亿行、6 列的表上一次扫描的 I/O 量可以相差一个数量级。加上前面提到的压缩效果——列式存储天然对压缩更友好因为同一列的数据类型一致、分布特征相近压缩率通常远高于行存。DuckDB 的文件最终只有 MySQL 表空间的 38% 大小这意味着扫描时需要的磁盘带宽更小、缓存命中率更高。4.2 向量化执行避免逐行解释开销MySQL 对这种大数据量的聚合查询执行方式是一个经典的火山模型每个算子扫描、过滤、聚合一次只处理一行行与行之间通过迭代器接口传递。这个模型的好处是实现简单、灵活但代价是每一行都要经历函数调用、类型判断、表达式计算等一系列解释执行的开销。当扫描的行数达到亿级时这个解释开销的绝对时间是相当可观的。DuckDB 采用的是向量化执行模型一次从存储引擎中批量取出 2048 行数据送到表达式引擎做批量计算。现代 CPU 的 SIMD 指令可以同时对多条数据进行同一种操作比如一次对 8 个浮点数做加法吞吐量远高于逐行循环。我用一个简单的类比你在小区门口收快递逐行模式是每来一个快递员就出去接一次向量化模式是让所有快递员把货放在一个转运站然后用叉车一次搬一托盘。调用次数少了、每次处理量大了整体开销必然大幅下降。4.3 多核并行是 DuckDB 的另一张王牌MySQL 的并行查询能力一直被人诟病。8.0 版本虽然引入了多个 buffer pool instance但单条 SQL 的执行基本还是单线程的。你可以打开innodb_parallel_read_threads来提高 InnoDB 层并行读取但这主要影响扫描聚合和排序等算子依然是串行的。DuckDB 从底层就是并行优先的设计。查询会被拆分成多个 pipeline每个 pipeline 内部再按数据范围或哈希分区切分成多个 task由线程池自动调度到多核上执行。我的测试机器是 8 核DuckDB 在做 GROUP BY 时会自动创建多个线程每个线程处理一部分数据最后合并结果。这也是为什么数据量越大DuckDB 相对 MySQL 的优势越明显。单核跑和 8 核跑的差异在最坏情况下就是 7 倍的性能差距。MySQL 在这个架构层面的短板是硬伤不是靠调参能解决的。5. 别急着迁移MySQL 里的数据要怎么落地 DuckDB含避坑经验5.1 常用迁移方式和我在实操中遇到的坑跑完对比你可能会想既然 DuckDB 这么猛那把分析查询全部迁过去是不是更好我的建议是可以但要讲究方式。DuckDB 原生的数据加载方式包括read_csv_auto、read_parquet、直接查 MySQL 等。最直接的方式是在 DuckDB 里使用mysql扩展把 MySQL 作为一个外部数据源直接查询INSTALL mysql; LOAD mysql; ATTACH hostlocalhost userroot passwordtest123 port3306 databasebench AS mysqldb (TYPE mysql); CREATE TABLE orders AS SELECT * FROM mysqldb.orders;这种方式适合小批量数据。我在测试中先把 MySQL 的数据导出为 Parquet 文件再导入 DuckDB速度远远快于通过 MySQL 协议直接读取。导出用mysqldump转成 CSV 或者用 Python 分批从 MySQL 读出再写 Parquetimport pandas as pd import pymysql import pyarrow.parquet as pq import pyarrow as pa conn pymysql.connect(hostlocalhost, userroot, passwordtest123, databasebench) offset 0 while True: sql fSELECT id, user_id, product_id, status, amount, created_at FROM orders LIMIT 1000000 OFFSET {offset} df pd.read_sql(sql, conn) if df.empty: break table pa.Table.from_pandas(df) pq.write_to_dataset(table, root_pathorders_parquet, partition_cols[status]) offset 1000000 print(fexported {offset} rows)跑完大概花 20 多分钟。注意这里的LIMIT ... OFFSET在 MySQL 上是逐行扫描越到后面越慢。更好的做法是用WHERE id ? ORDER BY id LIMIT ?这种键集分页keyset pagination方式能避免深分页的性能悬崖。5.2 正确使用 DuckDB物化中间结果避免重复扫描一个常见的误区是把 DuckDB 当作 MySQL 的直接替代品写出一堆重复扫描大表的查询。DuckDB 虽然快但重复读同一张 1 亿行的表依然有成本。在实际的数据分析流程中建议把中间结果物化成临时表CREATE TEMP TABLE paid_orders AS SELECT product_id, amount FROM orders WHERE status paid AND created_at 2023-01-01; -- 后续多个查询复用 paid_orders SELECT product_id, SUM(amount) FROM paid_orders GROUP BY product_id;这个习惯在分析链路里特别重要能让整个流程的性能再上一个台阶。5.3 生产环境选型建议OLTP 归 OLTPOLAP 归 OLAP对比完两种引擎我想强调一个容易被忽略的事实快并不是一切。MySQL 在并发写入、事务隔离、数据持久化、备份恢复、权限管理这些能力上积累了十几年生态是线上业务系统的可靠基石。你在 MySQL 里跑慢查询不该上来就想着“换掉 MySQL”而是先分析查询模式是否适合 OLTP 引擎。DuckDB 也有自己的短板。它的定位是嵌入式分析数据库不适合高并发写入场景也不提供完整的用户权限体系和网络服务模型。同一时刻多个应用通过 JDBC 连接去并发读写 DuckDB 是不推荐的生产数据仍应该落在 MySQL 这类 OLTP 数据库里。DuckDB 更适合作为分析侧的下游引擎从 OLTP 库把数据同步过来统一做聚合、报表、数据导出跑完的结论再回写业务库。如果你只是想做一个轻量级的数据报表工具或者 MySQL 里的单表真的到了亿级、分析查询频繁到影响线上性能那时候把分析工作负载迁移到 DuckDB 才是合理的时机。我的经验法则是凡是 OLTP 在线事务留在 MySQL凡是 OLAP 离线分析交给 DuckDB。两块引擎各司其职才能发挥最大价值。整个测试跑下来我对“查询快”这件事有了更具体的理解它不是一个单纯的引擎参数问题而是一个系统性的架构选择。下次业务方跟我说“报表查询太慢”我大概率会先问一句“这个查询一定要现在跑在 MySQL 上吗”

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价