先说个让我挺意外的实测结果:在单表 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.36,Docker 容器分配了 8G 内存。
docker run --name mysql-test \ -e MYSQL_ROOT_PASSWORD=test123 \ -e MYSQL_DATABASE=bench \ -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.11,DuckDB 版本为 1.0.0。启动连接、设置线程数后就可以直接使用:
import duckdb con = duckdb.connect('bench.db') con.execute('PRAGMA threads=8') con.execute('PRAGMA memory_limit="6GB"')内存限制设为 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(seconds=random.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.2G,DuckDB 端整个数据库文件约 2.8G。注意这个差异——同样的数据,列式存储比行式存储节省了一半以上的空间。
提示:如果你的机器配置比这低,可以把数据量缩小到 1000 万行,结论方向基本一致,只是耗时等比缩小。
3. 四组典型查询的实测:热身、聚合、关联与窗口函数
3.1 热身查询:全表 COUNT 与 AVG 的差距有多夸张
第一组查询看起来最“无脑”:统计订单总数、订单总额、平均订单金额。这个查询没有索引可用,MySQL 必须完整扫描全表。
| 查询 | MySQL 耗时 | DuckDB 耗时 | 备注 |
|---|---|---|---|
SELECT COUNT(*) FROM orders | 7.8s | 0.04s | DuckDB 无需扫描所有列 |
SELECT COUNT(*), SUM(amount), AVG(amount) FROM orders | 12.6s | 0.18s | MySQL 全列扫描 |
SELECT COUNT(*) FROM orders WHERE status='paid' | 9.4s | 0.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.2s | 0.22s |
| 按用户分组(200 万组) | 42.8s | 0.61s |
| 按商品分组(5 万组) | 31.5s | 0.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.3s | 3.1s |
| JOIN + GROUP BY(product_id 加索引) | 68.7s | 3.1s |
| JOIN + WHERE 过滤 + ORDER BY | 87.5s | 2.6s |
MySQL 在 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.4s | 7.2s |
| 外层过滤 Top 3 | 128.9s | 2.8s |
MySQL 在窗口函数上的表现非常吃力,原因在于它需要把全量数据按 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 'host=localhost user=root password=test123 port=3306 database=bench' AS mysqldb (TYPE mysql); CREATE TABLE orders AS SELECT * FROM mysqldb.orders;这种方式适合小批量数据。我在测试中先把 MySQL 的数据导出为 Parquet 文件,再导入 DuckDB,速度远远快于通过 MySQL 协议直接读取。导出用mysqldump转成 CSV 或者用 Python 分批从 MySQL 读出再写 Parquet:
import pandas as pd import pymysql import pyarrow.parquet as pq import pyarrow as pa conn = pymysql.connect(host='localhost', user='root', password='test123', database='bench') offset = 0 while True: sql = f"SELECT 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_path='orders_parquet', partition_cols=['status']) offset += 1000000 print(f"exported {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 归 OLTP,OLAP 归 OLAP
对比完两种引擎,我想强调一个容易被忽略的事实:快并不是一切。
MySQL 在并发写入、事务隔离、数据持久化、备份恢复、权限管理这些能力上,积累了十几年生态,是线上业务系统的可靠基石。你在 MySQL 里跑慢查询,不该上来就想着“换掉 MySQL”,而是先分析查询模式是否适合 OLTP 引擎。
DuckDB 也有自己的短板。它的定位是嵌入式分析数据库,不适合高并发写入场景,也不提供完整的用户权限体系和网络服务模型。同一时刻多个应用通过 JDBC 连接去并发读写 DuckDB 是不推荐的,生产数据仍应该落在 MySQL 这类 OLTP 数据库里。DuckDB 更适合作为分析侧的下游引擎:从 OLTP 库把数据同步过来,统一做聚合、报表、数据导出,跑完的结论再回写业务库。
如果你只是想做一个轻量级的数据报表工具,或者 MySQL 里的单表真的到了亿级、分析查询频繁到影响线上性能,那时候把分析工作负载迁移到 DuckDB 才是合理的时机。我的经验法则是:凡是 OLTP 在线事务,留在 MySQL;凡是 OLAP 离线分析,交给 DuckDB。两块引擎各司其职,才能发挥最大价值。
整个测试跑下来,我对“查询快”这件事有了更具体的理解:它不是一个单纯的引擎参数问题,而是一个系统性的架构选择。下次业务方跟我说“报表查询太慢”,我大概率会先问一句:“这个查询一定要现在跑在 MySQL 上吗?”