1. 为什么需要对比DuckDB与MySQL的性能
第一次听说DuckDB这个嵌入式分析型数据库时,我正在处理一个电商平台的用户行为分析项目。当时我们的MySQL实例已经撑到了16核64G的配置,但面对每天新增的千万级订单数据,一些复杂的JOIN查询仍然需要分钟级的响应时间。这让我开始思考:在OLAP场景下,是否该继续用传统的关系型数据库硬扛?
DuckDB的出现给了我一个新的选择。作为专为分析型负载设计的嵌入式数据库,它号称能在单机上高效处理TB级数据分析。但纸上得来终觉浅,我决定用真实的生产数据做个全面对比测试。这次测试涵盖了从简单的单表扫描到复杂的多表关联,数据集规模从百万行到上亿行,希望能给面临同样困境的开发者一些参考。
2. 测试环境与数据集准备
2.1 硬件与软件配置
测试使用了一台AWS的r5.2xlarge实例(8 vCPU/64GB内存),操作系统为Ubuntu 20.04 LTS。两个数据库均采用默认配置启动,以保证比较的公平性:
- MySQL 8.0.32:安装官方APT源版本,仅调整了
innodb_buffer_pool_size=32G - DuckDB 0.8.1:直接下载预编译二进制,未修改任何配置参数
提示:实际生产环境中,DuckDB通常作为嵌入式库使用,但这里我们统一用CLI方式测试以保持环境一致。
2.2 测试数据集生成
使用Python的Faker库生成了三个具有关联关系的表:
- 用户表(users):1亿条记录,包含user_id、注册时间、地域等字段
- 订单表(orders):10亿条记录,包含order_id、user_id、金额、状态等
- 行为日志表(logs):50亿条记录,包含user_id、行为类型、时间戳等
数据以CSV格式生成后,分别导入两个数据库。这里有个重要细节:DuckDB支持直接查询CSV文件,但为了公平比较,我们仍然将所有数据预先导入到各自的存储引擎中。
python复制# 示例数据生成代码片段
from faker import Faker
import pandas as pd
fake = Faker()
users = [{
'user_id': i,
'name': fake.name(),
'state': fake.state_abbr()
} for i in range(100_000_000)]
pd.DataFrame(users).to_csv('users.csv', index=False)
3. 基准测试设计与执行
3.1 测试查询类型
我们设计了五类典型查询场景:
- 单表全量扫描:
SELECT COUNT(*) FROM logs WHERE timestamp > '2023-01-01' - 带索引的点查:
SELECT * FROM users WHERE user_id = 12345678 - 大表JOIN:订单表与用户表的关联分析
- 聚合计算:按地区统计订单金额分布
- 窗口函数:计算用户消费排名
3.2 索引策略对比
MySQL作为传统RDBMS,我们为所有关联字段创建了B-tree索引:
sql复制-- MySQL索引示例
CREATE INDEX idx_orders_user_id ON orders(user_id);
CREATE INDEX idx_logs_user_id ON logs(user_id);
而DuckDB采用不同的优化策略。虽然它也支持创建索引,但更推荐依赖其列式存储和向量化执行引擎:
sql复制-- DuckDB也可以创建索引(但通常不需要)
PRAGMA create_index('idx_orders_user_id', 'orders', 'user_id');
4. 关键性能对比结果
4.1 单表查询性能
在简单查询场景下,当数据量超过1亿行时,DuckDB展现出明显优势:
| 查询类型 | 数据量 | MySQL耗时 | DuckDB耗时 |
|---|---|---|---|
| 全表COUNT | 1亿行 | 12.3s | 1.8s |
| 条件过滤 | 5亿行 | 28.7s | 3.2s |
这是因为DuckDB的列式存储只需要扫描相关列,而MySQL的InnoDB需要读取整行数据。实测发现,当查询只涉及部分列时,两者的性能差距会进一步拉大。
4.2 多表JOIN性能
测试一个典型分析场景:统计每个州的订单总金额。查询涉及orders和users表的JOIN:
sql复制SELECT u.state, SUM(o.amount)
FROM orders o JOIN users u ON o.user_id = u.user_id
GROUP BY u.state;
结果令人惊讶:
- MySQL:启用索引后仍需要142秒
- DuckDB:仅用9.8秒完成,且无需显式创建索引
DuckDB的hash join实现和向量化执行引擎在这里发挥了巨大作用。当JOIN的右表能完全放入内存时(本测试中users表约5GB),其性能优势尤为明显。
4.3 内存使用对比
通过htop监控发现:
- MySQL在查询期间内存使用稳定在32GB(buffer pool大小)
- DuckDB的内存占用会随查询复杂度波动,最高达到45GB,但查询结束后会立即释放
这意味着对于内存受限的环境,MySQL的稳定内存占用可能更可控,而DuckDB需要预留足够的内存峰值空间。
5. 实战建议与避坑指南
5.1 何时选择DuckDB
根据实测经验,以下场景适合采用DuckDB:
- 交互式分析:需要快速响应复杂查询的数据探索场景
- ETL管道:需要对中间数据进行多次转换处理的场景
- 嵌入式分析:需要将分析能力集成到应用内部的场景
- 机器学习特征工程:需要频繁进行表连接和聚合计算的场景
5.2 MySQL的不可替代性
尽管DuckDB在分析性能上占优,MySQL仍是更好的选择:
- 高并发写入:DuckDB的写入吞吐量远低于MySQL
- 事务完整性:需要严格ACID保证的业务系统
- 已有生态整合:与现有ORM、监控工具的兼容性
5.3 性能优化技巧
对于DuckDB:
- 使用
PRAGMA设置内存限制:PRAGMA memory_limit='64GB' - 对频繁JOIN的字段执行
ANALYZE命令更新统计信息 - 考虑将经常访问的数据转为Parquet格式存储
对于MySQL:
- 为分析查询创建适当的覆盖索引
- 考虑使用派生表替代复杂JOIN
- 对大表启用
innodb_parallel_read_threads
6. 真实案例:电商用户行为分析
最近我们有一个实际需求:找出过去半年购买次数下降的高价值用户。查询涉及三个表的关联和多个窗口函数:
sql复制WITH user_stats AS (
SELECT
u.user_id,
COUNT(DISTINCT o.order_id) FILTER (
WHERE o.created_at BETWEEN NOW() - INTERVAL 180 DAY AND NOW() - INTERVAL 90 DAY
) AS prev_period_orders,
COUNT(DISTINCT o.order_id) FILTER (
WHERE o.created_at > NOW() - INTERVAL 90 DAY
) AS recent_period_orders
FROM users u
LEFT JOIN orders o ON u.user_id = o.user_id
WHERE u.segment = 'premium'
GROUP BY u.user_id
)
SELECT
user_id,
prev_period_orders,
recent_period_orders,
(prev_period_orders - recent_period_orders) AS order_decline
FROM user_stats
WHERE prev_period_orders >= 5
AND (recent_period_orders * 1.0 / prev_period_orders) < 0.7;
在1亿用户、10亿订单的数据规模下:
- MySQL执行耗时:6分42秒
- DuckDB执行耗时:38秒
这个案例生动展示了在复杂分析场景下,DuckDB的性能优势可以达到数量级差异。不过值得注意的是,当我们需要实时更新用户分群结果时,最终还是采用了MySQL + 物化视图的方案,因为DuckDB的写入性能无法满足分钟级更新的需求。