如果你做过两年以上的后端或者数据分析,一定碰到过这种尴尬:业务库MySQL平时挺稳,查询一旦变成几亿行的汇总统计,接口就开始几秒甚至几十秒卡住。老板只是要一个区域销售月度数字,你却不敢直接在生产库上跑,只能半夜导数据出来算。这篇文章源于我自己被这种场景反复折磨后的一个疑问:同样是SQL查询,换一个面向分析的引擎到底能快多少?为了搞清楚,我在一台普通Linux服务器上创建了一张两亿行订单明细表,分别放在MySQL 8.0和DuckDB里,完成点查、范围过滤、JOIN、GROUP BY和全表统计五类查询。文章会给出每条SQL、参数、结果和实验坑,适合MySQL用得比较多、又想了解DuckDB性能边界的人,也适合想知道OLTP和OLAP差距在哪的开发/数分同学。
1. 为什么要在超大数据集下做这次对比
1.1 传统MySQL扛业务没问题,扛分析却很吃力
MySQL是我这么多年用得最多的数据库,OLTP场景下它确实稳定可靠。但只要数据上涨到一定量级,把分析SQL直接扔给它就跑得非常别扭。几百万行数据你感觉不到,等到表里有几千万甚至上亿行,一个简单的 SUM 就可能扫全表,一个 GROUP BY 排序就可能把临时表写满磁盘,接口延迟直接从几十毫秒恶化到几十秒。更麻烦的是,生产库上还有大量写入和事务,你跑一条大查询,可能把内存、IO和锁都吃掉了,业务侧跟着受影响。
常见的处理方案是分库分表、提前建汇总表、或者把数据同步到数仓再分析。这些方案本身没问题,但维护成本不低:分库分表让查询逻辑变复杂,汇总表需要额外开发,数仓则引入了完整的大数据组件栈。对一个中小团队来说,很多时候我们只想低成本地解决“超大数据集下偶尔跑几条分析SQL”的问题,并不想养一套Hadoop体系。
1.2 DuckDB为什么值得被拉进候选名单
DuckDB近几年讨论度很高,它是一款嵌入式列式OLAP数据库,定位有点类似SQLite,但SQLite是行存、面向事务,而DuckDB是列存、面向分析。它没有独立服务进程,不像MySQL需要安装后启动mysqld并维护连接,而是一个库文件,应用进程直接嵌入进去就能执行SQL。它对Parquet、CSV这些数据文件支持非常好,一条 SELECT 可以直接扫本地的几十GB文件。
我一开始对这类“嵌入式数据库”的性能持怀疑态度,直觉里觉得这顶多算实验室玩具。但真正了解后发现,DuckDB采用的是向量化执行引擎,处理数据时不是一行一行地跑,而是一次处理一批数据,再配合多核并行和二进制的列式存储布局。这种设计和很多真正的数仓引擎是同一条路线,只是包在一个极简的部署形态里,所以非常适合拿来在超大数据集上做低成本分析。
1.3 这次对比测什么、不测什么
我并不是想写一篇“谁取代谁”的引战文,更不是用压测工具去模拟两套数据库在高并发写入下的TPS差异。这次对比只聚焦一个非常具体的痛点:当数据量大到MySQL开始难受时,DuckDB是不是一个足够轻的补充方案。
所以数据类型选了订单明细,因为这是后端和数据分析最常见的超大数据集形态。MySQL这边扮演的是一个“被硬拉着做分析OLTP库”,DuckDB则作为分析引擎去查同样的数据。我不测高并发写入,不测复杂事务,也不测两套系统的运维能力,只测五类真实业务里最高频的查询SQL,然后结合执行计划和引擎原理去解释快慢背后的原因。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 测试环境与数据集搭建
2.1 测试机器与运行参数
这次没有用很大的物理机,反而故意用了一台普通配置的服务器,因为大多数团队的生产环境并没有那么多高配机器,我手上的MySQL和DuckDB会被放到同一条起跑线上。测试机是一台单机Linux,8核CPU、16GB内存,数据盘是NVMe SSD,操作系统用Ubuntu 22.04。
MySQL用的是8.0.36,DuckDB用的是1.1.x版本。MySQL配置里最重要的参数是 innodb_buffer_pool_size,我把它调到12GB,基本上把可用内存的大部分都给了InnoDB,避免让人吐槽“MySQL没调优”。DuckDB侧的对应参数是 memory_limit,同样设置成了12GB,线程数设置成8。两台引擎分到一样的资源,这样最后的结果才有比较价值。
cnf复制[mysqld]
innodb_buffer_pool_size = 12G
innodb_log_file_size = 1G
max_connections = 200
sql_mode = STRICT_TRANS_TABLES,NO_ENGINE_SUBSTITUTION
DuckDB在CLI里执行下面两条PRAGMA:
sql复制SET memory_limit='12GB';
SET threads=8;
2.2 表结构设计与两亿行数据怎么造
为了贴近真实场景,我定了一张订单事实表 orders,字段不算多,但覆盖了最常见的过滤和聚合条件:订单号、客户ID、商品ID、区域ID、下单日期、订单金额、数量、状态。另外做了一张客户维表 customers,客户量是800万行,用来测多表JOIN。
我在MySQL里建表时有意保留了两个二级索引,一个是 idx_order_date,因为按时间范围过滤是订单查询最典型的场景;另一个是 idx_customer_id,因为后面要拿客户ID去关联维表。如果一张事实表连这种索引都不建,那对MySQL来说确实不算公平。
sql复制CREATE TABLE orders (
order_id BIGINT PRIMARY KEY,
customer_id BIGINT NOT NULL,
product_id INT NOT NULL,
region_id SMALLINT NOT NULL,
order_date DATE NOT NULL,
amount DECIMAL(12,2) NOT NULL,
quantity INT NOT NULL,
status VARCHAR(16) NOT NULL,
KEY idx_order_date (order_date),
KEY idx_customer_id (customer_id)
) ENGINE=InnoDB;
CREATE TABLE customers (
customer_id BIGINT PRIMARY KEY,
customer_name VARCHAR(64) NOT NULL,
segment VARCHAR(16) NOT NULL,
city VARCHAR(32) NOT NULL
) ENGINE=InnoDB;
两亿行数据不能靠手写SQL插入,我用Python脚本做了分布式生成,先把数据写到多个CSV文件,再分别导入MySQL和DuckDB。这里有个容易被忽略的点:Python端生成数据时,order_date 是按自然月顺序写入的,也就是说同一个月的数据会存放在相邻位置,这对DuckDB的Parquet扫描非常有利,但如果MySQL主键是随机数,二级索引范围查询时还是会随机回表。我故意保留了这个差异,因为这就是两种存储引擎在真实业务里的典型状态。
2.3 MySQL导入很慢,核心瓶颈在索引
把两亿行导入MySQL并没有那么愉快。即使我关闭了binlog,使用 LOAD DATA LOCAL INFILE,导入过程中依然要维护主键索引和两个二级索引。倒排文件写得再快,跑批量写入的时候二级索引也会反复分裂和重建,整个过程非常吃CPU和磁盘IO。实测下来,MySQL全量导入接近半小时,如果保留更多索引,时间会更离谱。
比较推荐的做法不是“一边导一边建索引”,而是先去掉二级索引,把数据导入完成后再用一条 ALTER TABLE 统一加索引。这样排序和建索引可以由引擎一次性完成,避免每次插入都去更新索引结构。但真实的DBA一般不敢在生产库上这么干,因为导入期间表可能还要对外服务。我是测试环境,就直接分步执行了。
2.4 DuckDB侧直接用Parquet当表
DuckDB的数据侧我用了两种方式,先导入到一张原生表里做一次,又用 COPY 导出成Parquet之后直接扫外部文件做了一次。最后结果差异不大,外部Parquet文件表现更好一点,因为Parquet天然带行组统计信息和列式压缩,DuckDB能配合min/max跳过很多不符合谓词的数据块。
我把Parquet文件放在SSD下,然后通过视图直接查文件:
sql复制CREATE OR REPLACE VIEW orders AS
SELECT * FROM read_parquet('/data/orders/*.parquet', union_by_name=true);
这样DuckDB不需要提前把数据全部加载进内存,查询时只读取需要的列,非常适合超大文件的探索式分析。相比之下,MySQL是把数据完整落进InnoDB,所有列都按行存在同一个表空间里,查任何字段都可能把整行数据带出来,这种结构上的差异是后续性能分化的一个大前提。
2.5 冷热缓存控制:做对比前必须统一
对比查询性能最容易被文章带偏的一点是缓存。MySQL的InnoDB Buffer Pool会缓存数据页,DuckDB读了Parquet后操作系统也会把文件内容留在Page Cache里,如果直接连续跑同一条SQL,第二次的查询结果绝对不能代表冷启动性能。为了公平,每次跑查询前我都做了两件事:重启MySQL或DuckDB进程,清空操作系统缓存。
清操作系统缓存需要root权限,执行下面的命令:
bash复制sync
echo 3 > /proc/sys/vm/drop_caches
这条命令会把Page Cache里的文件缓存清掉,让下一次查询真正从磁盘读数据。MySQL重启后虽然Buffer Pool是空的,但InnoDB也有自适应预热机制,想完全冷启动最好加上 innodb_buffer_pool_load_at_startup=OFF。我把这个参数关掉后,再逐条执行测试SQL,并记录的是冷缓存下的结果。
3. 五条SQL实测结果:从点查到全表聚合
3.1 Q1 主键点查:MySQL的统治区
第一条查询模拟根据订单号查某一条订单详情,这是业务系统里最常见的点查动作。SQL很简单:
sql复制SELECT order_id, customer_id, amount, status
FROM orders
WHERE order_id = 78912345;
MySQL走的是主键聚簇索引,从B+树根部往下三次左右的索引页IO就能定位到目标行。即使这次是冷缓存,也只涉及少量索引页,花费时间在0.02秒到0.04秒这个量级。
DuckDB这边没有“主键索引秒定位”这样的路径。它默认面向吞吐型查询,当我抛出一个等值条件时,执行器会把 order_id 整列读出来,逐块做过滤,性能自然不能和索引查找比。我第一次跑这条SQL时,DuckDB耗时2.24秒,这个结果符合预期,也提醒我:任何拿DuckDB当高并发点查引擎的想法都不靠谱。
3.2 Q2 单月范围聚合:索引回表开始暴露成本
第二条SQL是“查某个月的订单总数和总金额”,日期范围用到了二级索引,还要实时做聚合。这是数据分析里典型的需求,看起来不复杂,但对行存数据库来说隐藏着一个坑:二级索引叶子上只保存 order_date 和主键值,不包含 amount,所以就算MySQL很快定位到三月份的索引区间,它还要拿着主键逐行回聚簇索引取 amount 字段。
sql复制SELECT COUNT(*), COALESCE(SUM(amount), 0) AS total_amount
FROM orders
WHERE order_date >= DATE '2024-03-01'
AND order_date < DATE '2024-04-01';
两亿行数据均匀分布在两年里,单月大约是800多万行,回表次数太多,而且订单主键不是按日期连续生成的,回表时会产生大量随机IO,最终耗时2.73秒。
DuckDB这边的Parquet文件是按 order_date 物理排序的,Parquet的row group带有min/max统计信息。日期谓词一下来,DuckDB可以把春季以外的row group直接跳过,只读取需要日期的数据块,最后只用0.38秒就返回了同样的结果。
3.3 Q3 多表JOIN聚合:MySQL的哈希JOIN被大表扫描拖累
订单明细要和客户维表关联做客户分层统计,这类SQL很常见。MySQL 8.0默认在等值连接场景下会启用Hash Join,所以它不一定走嵌套循环,而是选择把800万行的客户维表建哈希表,再扫描两亿行订单数据去匹配。Hash Join本身没问题,但MySQL的行存制下全表扫描两亿行,每一行都要读大量无关字段,数据量远比列存大。
sql复制SELECT c.segment,
COUNT(*) AS order_cnt,
COALESCE(SUM(o.amount), 0) AS total_amount
FROM orders o
JOIN customers c ON c.customer_id = o.customer_id
WHERE o.status = 'PAID'
GROUP BY c.segment
ORDER BY total_amount DESC;
这条SQL在MySQL上用了7.55秒。即使把 join_buffer_size 调大,MySQL还是需要把两亿订单行从磁盘读进内存、过滤 status、再去做哈希探测。DuckDB得益于列存和向量化执行,只读取 customer_id、status、amount 三列,匹配时同样用哈希表,最终耗时1.46秒。值得注意的是,如果MySQL的查询条件无法走索引,行存的这个“读多列冗余”问题会越放大。
3.4 Q4 时间区域多维聚合:排序和分组的分水岭
第四类查询模拟经营看板里的月维度对比:统计每个月、每个区域的订单数、销量和平均客单价。这条SQL没有很强的过滤条件,基本要把整张表的所有订单都分组聚合,是分析引擎最舒服,同时行存数据库最不喜欢的场景。
sql复制SELECT region_id,
EXTRACT(YEAR FROM order_date) AS yr,
COUNT(*) AS order_cnt,
SUM(quantity) AS total_qty,
ROUND(AVG(amount), 2) AS avg_amount
FROM orders
GROUP BY region_id, yr
ORDER BY yr, region_id;
MySQL因为要读完整的行数据,还要做一次针对两亿行的临时分组,耗时11.22秒。如果临时结果集超过内存限制,磁盘排序会让时间更难看。DuckDB只读取 region_id、order_date、quantity、amount 四列,列与列之间独立压缩,再配合多线程并行分组,最终耗时2.07秒。这个差距其实已经能说明问题:数据量大到一定程度,列存加上向量化就是在天然维度上碾压行存。
3.5 Q5 全表COUNT和AVG:没有过滤条件下的硬碰硬
最后一条SQL是统计全表订单数、总金额、最低金额、最高金额和平均金额。这种SQL没有索引优化的空间,无论MySQL还是DuckDB都必须把相关数据完整读一遍。
sql复制SELECT COUNT(*) AS total_orders,
COALESCE(SUM(amount), 0) AS total_amount,
MIN(amount) AS min_amount,
MAX(amount) AS max_amount,
ROUND(AVG(amount), 2) AS avg_amount
FROM orders;
MySQL冷缓存下用了8.51秒,DuckDB用了1.69秒。差距的来源很直接:MySQL的InnoDB行存布局导致执行引擎必须访问每一行的完整记录,虽然金额列只是其中一列,行存的页里还是会连带把订单号、商品ID、日期等字段全部读进内存;DuckDB列式存储则只需要读取 amount 这一列的数据块,I/O量不是一个数量级。
3.6 冷缓存结果汇总
我把五条SQL的冷缓存耗时整理成了一张总表,单位是秒:
| 查询类别 | MySQL 8.0 | DuckDB 1.1.x |
|---|---|---|
| Q1 主键点查 | 0.03 | 2.24 |
| Q2 单月范围聚合 | 2.73 | 0.38 |
| Q3 表JOIN聚合 | 7.55 | 1.46 |
| Q4 多维GROUP BY排序 | 11.22 | 2.07 |
| Q5 全表聚合统计 | 8.51 | 1.69 |
只看慢的那端会得出“DuckDB全面碾压MySQL”的结论,这恰恰是断章取义的地方。Q1是MySQL赢了。更准确的说法是:在纯分析型、全表扫描型任务上DuckDB优势明显,在单行点查场景上DuckDB完全不是MySQL的对手。两套引擎的设计目标不同,快慢自然要分场景谈。
4. 结果背后:为什么DuckDB能赢,又为什么不是万能
4.1 MySQL点查是索引的艺术,DuckDB扫描是列存的艺术
MySQL为什么能在Q1里做到0.03秒?底层数据结构是B+树索引,本质是一个多路平衡搜索树,叶子节点双向链表串联。在两亿行的主键索引里定位一行,不需要扫全表,只需要沿根节点往下走几次IO。B+树的每个节点能存大量索引键,所以即便表很大,树的高度通常也只有三四层,查询延迟几乎不随表的总行数线性上涨。
DuckDB没有这种“先建索引再服务点查”的机制,它默认把分析型负载设计成“大规模扫描+过滤”。针对 order_id = 78912345 这种谓词,DuckDB会读取整列Parquet数据,利用SIMD向量化指令逐块做比较,最终锁定命中行。这个能力在分析场景里很高效,但用来做单行点查就是高射炮打蚊子。
4.2 DuckDB对超大数据集友好的三个关键点
第一个关键点是列式存储。DuckDB的底层数据按列组织,相同类型的数据放在一起,压缩率远高于行存。拿订单表举例,status 字段可能只有几种取值,字典压缩后占用极小空间,amount 数字列也能用更紧凑的方式编码。列存带来的直接好处是查询只要扫描需要的列,省掉大量无关IO。
第二个关键点是向量化执行。MySQL的经典执行器是逐行迭代,一行数据经过各个算子处理完再进入下一行,这种模式在CPU层面有严重的分支预测和缓存不友好问题。DuckDB用了基于批处理的向量化引擎,一次处理1024行甚至更多,循环内部全是紧凑的SIMD指令,CPU的利用率会高很多。
第三个关键点是并行能力。DuckDB拿到一条SQL后,会尽力把数据分区,让多个线程同时扫描不同的Parquet文件或行组,最后汇总结果。MySQL 8.0虽然也有并行InnoDB扫描,但受限于行存和B+树结构,实际效果没有DuckDB那样直接。超大数据集下,IO叠加多核计算,优势会成倍显出来。
4.3 MySQL的分析瓶颈:回表、行存和临时排序
MySQL在分析SQL上的主要瓶颈并不只是“没用对索引”。比如Q2,明明走了 idx_order_date 二级索引,冷缓存依然需要2.73秒,问题就出在回表。二级索引定位到所有3月份的订单主键,接下来每一行都要去聚簇索引上拿 amount,这个动作在物理存储上是大量随机IO。
如果查询条件能覆盖索引,比如只 SELECT order_date,那确实可以避免回表,但业务分析SQL几乎都包含金额、数量这类不在索引里的列。不能指望为每一种聚合组合都去建覆盖索引,因为二级索引增多后,写入和更新成本会成倍增加。MySQL的另一个瓶颈是临时表和文件排序。大数据量 GROUP BY 或 ORDER BY 超出内存允许范围,引擎会把中间结果写到磁盘上的临时表,这才是最拖慢速度的地方。
4.4 这套对比的限制条件,别无脑抄作业
我必须强调,这个结果不是“MySQL很弱”的万能证据。这次测试里没有模拟高并发请求,如果是几十个连接同时查,DuckDB作为嵌入式进程的并发能力很有限,而MySQL在线程池和连接管理方面仍然更成熟。测试用的也是只读查询,没有同时跑大量写入,真实OLTP负载不可能这么干净。
DuckDB在超大数据集上有不错表现,但它的更新模型是MVCC下的表级锁,严格来说并不适合作为多个微服务共享的高并发数据库。如果你期待的是“一亿用户在线点击,DuckDB当主库扛住一切”,那一定会在并发测试时翻车。DuckDB适合的赛道是分析型负载,而不是通用事务型负载。
5. 常见坑与排错实录
5.1 MySQL侧:导入慢、连接中断、临时表爆掉
导入数据时如果发现 LOAD DATA LOCAL INFILE 被拒绝,需要检查MySQL的 local_infile 参数,同时客户端也要带 --local-infile=1 才能正确执行。如果导入过程中连接断掉,通常是 max_allowed_packet 太小,大批量写入时单条SQL或事务可能超过包体限制,建议提前调到128MB。
我实际踩过的另一个人是执行Q4时MySQL进程的临时空间被写满。16GB内存里12GB给了Buffer Pool,真正给排序和哈希操作的可用内存其实不宽裕,一旦SQL需要构造大临时结果,MySQL就会转去写 /tmp 下的临时表。可以设置 tmpdir 指向SSD上的独立目录,避免拖垮系统盘。
5.2 DuckDB侧:内存溢出,需要把临时文件引导到磁盘
DuckDB默认的内存上限是机器内存的80%,在16GB机器上大约是12.8GB。如果查询需要排序或哈希,确实有可能瞬间打满内存。我的做法是设置 memory_limit 为一个可控值,并且把临时目录指向SSD,防止DuckDB在内存不够时崩掉或撑爆物理内存。
sql复制SET memory_limit='12GB';
SET temp_directory='/data/tmp_duckdb';
SET max_temp_directory_size='100GB';
我遇到比较隐蔽的问题是:直接查询Parquet文件时,如果文件名里带中文或特殊符号,偶尔会出现解析错误。后来统一把文件路径改成简单英文目录,问题就消失了。DuckDB的SQL语法对字符串引号的处理也偏严格,日期字面量尽量用 DATE '2024-03-01' 这种标准写法。
5.3 测试实验最容易骗人的几个点
对比性能实验里最容易被忽略的是缓存和随机数顺序。我建议想复现的人一定要统一“冷启动”规则,否则你测出来的DuckDB成绩会好得不真实。因为连续执行第二次时,操作系统Page Cache可能已经缓存了大部分Parquet文件,第一次需要3秒的查询第二次可能只要0.3秒。
第二个容易误导人的点是过滤字段的物理顺序。如果Parquet文件里 order_date 列的值不排序,DuckDB的min/max剪枝会失效,Q2这种范围聚合可能慢好几倍。反过来,如果我把MySQL表的主键设计成和日期一致,Q2的范围回表也会变成顺序IO。所以比较之前要明确:你的数据集是不是贴合各自引擎的最佳实践。
6. 选型建议:OLTP归MySQL,分析场景把DuckDB放进来
6.1 MySQL继续干它擅长的事
MySQL最适合的场景是业务系统的落地库,表结构相对规范,查询条件能通过索引快速定位,需要支持事务、外键、多个应用通过连接池并发访问。即使表的数据量到了几亿行,只要每次请求都走主键或者有限范围索引,MySQL依然能提供稳定的低延迟服务,这也是它长期占据OLTP主库位置的原因。
项目里如果已经被MySQL的明显痛点卡住,比如报表查一次要十几秒,不要先想着如何优化一条SQL,而应该先确认这条SQL是否适合放在OLTP库上执行。很多问题的根源在于职责不清,把分析任务塞给了事务引擎。MySQL能撑住,不等于它该承担一切。
6.2 DuckDB适合做分析加速器,而不是业务库
DuckDB真正适合的角色是“分析加速器”。数据文件可以是导出的Parquet,也可以是CSV、JSON,DuckDB直接启动查询,不需要单独的服务器进程,也不需要维护权限体系和网络端口。对跑数据分析、做报告、做临时探索的人非常友好,一个Python脚本里调用DuckDB,比每次连生产MySQL跑慢查询安全得多。
它的限制也很明显:DuckDB不是网络专用的Server,不适合被多个应用连接后长时间持有。如果一个团队指望部署DuckDB然后让所有API请求远程访问,那大概率会遇到瓶颈。合理的方式是把DuckDB放在离线分析、ETL后处理、BI报表的临时计算层,而不是线上核心链路。
6.3 一个可行的落地架构:MySQL生产库加DuckDB离线段
结合这次对比,我比较推荐中小团队做一个很轻的架构:MySQL继续提供事务和线上读写,数据通过定期任务导出成Parquet文件,放到对象存储或本地分析目录,DuckDB负责对外提供分析查询能力。导出数据本身可以让批处理引擎在低峰期执行,避免影响业务。
这样做的好处是分析查询不再和线上资源争抢。业务库跑报表的十几秒大查询,可以在DuckDB里变成一秒左右,同时MySQL侧的CPU负载和磁盘IO也降下来了。导出的Parquet文件还可以保留多版本,配合快照,做历史回溯时会比直接在MySQL里翻大表灵活很多。
6.4 针对超大数据集查询的几条工程化建议
无论你最后选MySQL还是DuckDB,有几点通用建议可以记一下。第一,尽量只查需要的列,不要养成 SELECT * 的习惯,行存里这决定IO大小,列存里这决定需要读取的数据块数量。第二,给数据表设定合理的主键和物理排序键,按常用过滤条件排好序,会让范围查询和聚合查询都
