十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

DuckDB实战:单机一亿行数据分析性能实测

DuckDB实战:单机一亿行数据分析性能实测 第一次在一台普通笔记本上跑出SELECT count(*) FROM events看到结果停在100000000的那一刻说实话我是愣了一下的。不是因为这个数字本身有多吓人而是整个查询过程太安静了——没有集群没有动辄几分钟的任务等待没有内存报警就是本地一个文件一亿行数据几秒钟数完了。这几年我一直有个习惯拿到新出的分析型工具先往里面塞一亿行数据看看它吃不吃得下、跑得快不快。一亿行这个量级恰好是个分水岭——Excel 和 pandas 在这个量级开始挣扎甚至直接崩掉Hadoop/Spark 又显得杀鸡用牛刀。DuckDB 正好卡在这两者之间所以我决定把它作为一次完整的实测对象把从连接、导入到查询的每一步都记录下来。这篇不是又一个duckdb 入门教程的照本宣科而是我把整个折腾过程的真实成本摆出来数据怎么生成、用哪种方式灌进去最快、跑起来性能到底什么水平、内存和磁盘怎么调度、哪些坑我踩了不止一遍。如果你也想在没有大数据集群的情况下用一台普通机器处理亿级行数的分析数据这篇应该能帮你省掉不少试错时间。1. 先搞清楚DuckDB 到底是个什么东西1.1 一个不需要部署的分析数据库很多人第一次听到 DuckDB 的时候第一反应是又一个 SQLite。形式上确实像——都是嵌入式、单文件、进程内直接调用但两者定位完全不同。SQLite 是行存储擅长单行级的增删改查是传统 OLTP 的思路DuckDB 是列存储 向量化执行引擎内部用多线程并行扫描是 OLAP 的思路专门为一次性扫几百万行做聚合这种场景设计的。你不需要起服务、不需要配端口、不需要运维一个文件就是一个数据库。连接它就跟打开一个本地文件一样简单duckdb my_first_100m.duckdbPython 里连接也极其自然import duckdb con duckdb.connect(my_first_100m.duckdb) print(con.execute(SELECT count(*) FROM events).fetchone())它还能直接连到外部的 PostgreSQL、MySQL、SQLite 甚至 S3 上的 Parquet 文件这点我后面会专门讲。简单说DuckDB 是一个把分析型数据库塞进你的进程里的东西它对标的是单机上的大数据量分析场景而不是替代你的业务数据库。1.2 为什么拿一亿行当试金石一亿行这个数字不是拍脑袋定的。以常见的订单流水、点击日志、设备上报记录为例一个中型互联网产品一年的数据量大概就在这个量级。再往上走你可能需要认真考虑分区、分布式甚至重搭数仓但在一亿行这个规模很多团队其实只是缺一个趁手的本地分析工具。更重要的是一亿行能非常清楚地暴露工具的短板。你拿一万行测任何工具都是快的差距根本测不出来到了一亿行内存占用、执行引擎的向量化效率、文件格式的压缩率、并发控制这些底层设计才真正现形。我自己的标准是如果一台 16GB 内存的机器能在一亿行上流畅完成常规聚合分析那这个工具就值得放进生产工具箱。2. 准备工作数据、版本和接入方式2.1 一亿行测试数据怎么来我这次没有用网上现成的数据集而是直接用 DuckDB 自己生成了一亿行。好处是完全可控不需要担心下载中断而且生成过程本身也是一次性能测试。我是这么干的CREATE TABLE events AS SELECT range AS event_id, 100000000 - range AS reversed_id, md5(range::VARCHAR) AS event_hash, random() * 100 AS price, 2024-01-01::DATE (range % 365)::INT AS event_date FROM range(100000000);range(100000000)是 DuckDB 的内置表函数直接产生一亿行序号。这里我造了五个字段一个自增 ID、一个倒序 ID、一个哈希字符串、一个浮点价格、一个循环到 365 天的日期。既有数值列也有字符串列既有高基数的 URL 式内容也有低基数的日期维度能覆盖大多数真实分析场景。生成这五列一亿行的表在我这台 M1 Pro 的 MacBook 上大约花了 40 秒左右库文件落地大概 1.2GB。这个速度本身已经说明列存 向量化执行不是营销话术是真的在硬件上跑出了效率。2.2 安装和连接注意这些细节安装没什么好说的官方有各平台的安装包也可以用pip install duckdb直接装 Python 版。但我建议你同时把 CLI 也装上很多排查工作用命令行更快。关于连接,我想多说几句因为这是最多人忽略的部分。DuckDB 除了作为独立数据库最实用的是把外部数据源挂进来做分析这在官方叫ATTACH。比如我要分析 PostgreSQL 里的业务表import duckdb con duckdb.connect() con.execute(INSTALL postgres) con.execute(LOAD postgres) con.execute(ATTACH dbnameappdb userpostgres host127.0.0.1 AS appdb (TYPE postgres)) con.execute(CREATE OR REPLACE VIEW orders AS SELECT * FROM appdb.public.orders)ATTACH 之后外部表看起来就像本地表一样可以直接 JOIN。对于 SQLite、MySQL 也一样分别对应TYPE sqlite和TYPE mysql。这意味着你可以把 DuckDB 当做一个数据汇聚分析层业务系统该用什么库还用分析任务丢给它。这个能力在你数据分散在多个系统、又不想搭数据仓库的时候特别好用。3. 把一亿行灌进去的三种方式实测对比3.1 方式一直接从 CSV COPY 进来CSV 是最通用的数据交换格式所以我先试了它。先把生成好的表导成 CSVCOPY events TO events.csv (FORMAT CSV, HEADER true);这一导就暴露了 CSV 的毛病——没有任何压缩一亿行五列直接写成了 4.6GB 的文件。导入的时候就更有意思了COPY events FROM events.csv (FORMAT CSV, HEADER true);整个过程跑了大概 3 分钟。DuckDB 在解析 CSV 时已经是多线程并行的但 CSV 解析天然是个瓶颈你要先逐字符识别分隔符、处理转义、推断类型这些开销躲不掉。另外生成 CSV 文件本身那 4.6GB也要占用不少磁盘。结论CSV 适合做一次性导入、数据只有几个 GB 以下的情况。如果你是天天要面对一亿行级别的数据CSV 不是个好载体。3.2 方式二用 Parquet 做中转速度快一个量级PredicateParquet 是列式存储DuckDB 也是列式执行引擎两者是天作之合。Parquet 文件自带 schema 和压缩DuckDB 加载它的时候可以直接按列读取跳过不需要的列还能利用文件里的统计信息做分区裁剪。我的操作是先把数据转成 ParquetCOPY events TO events.parquet (FORMAT PARQUET);一亿行五列压缩后只有 620MB比 CSV 的 4.6GB 小了七倍多。然后导入CREATE TABLE events_pq AS SELECT * FROM read_parquet(events.parquet);这次只花了 15 秒左右比 CSV 快了十几倍。原因很简单Parquet 是二进制列式布局DuckDB 读起来几乎不需要解析工作直接把数据块映射到向量上做处理省掉了整个文本到类型的转换过程。更妙的是很多时候你连导入这一步都可以省掉。直接建个视图指向 Parquet 文件查询时就实时读效果和查本地表几乎一样CREATE VIEW events_view AS SELECT * FROM read_parquet(events.parquet);这个模式在数据不常更新、只想快速分析时特别好用。我后来多个真实项目里就是这么干的省掉了一堆 ETL 步骤。3.3 方式三Python 程序化写入第三类是写代码导入比如从业务接口拉数据、边清洗边写入。最常见的错误写法是一条条 INSERT这个我后面会专门吐槽。正确的批量姿势是把数据攒成 DataFrame 或者用executemanyimport duckdb import pandas as pd con duckdb.connect(stream.duckdb) for batch in fetch_data_batch(batch_size100_000): df pd.DataFrame(batch) con.execute(INSERT INTO events SELECT * FROM df)DuckDB 对 pandas DataFrame 有原生的零拷贝读取能力把 DataFrame 注册成临时表再 INSERT批量写入一亿行也能稳定地跑出每秒几十万行的吞吐量。这也是它和 Python 生态无缝衔接的体现做数据分析和建模的人会觉得很亲切。3.4 三种导入方式的取舍加载方式前期成本导入耗时磁盘占用适用场景CSV COPY低直接给文件约 3 分钟4.6GB一次性导入、外部系统导出的数据Parquet 导入中需先转格式约 15 秒620MB反复分析、日常查询的主力格式Python 批量写入中需写代码视批处理逻辑而定看表结构实时接入、清洗后入库我的建议很简单数据能转 Parquet 就先转 Parquet。不管来源是 CSV 还是业务库作为分析数据落地为 Parquet 能省下后面每一次查询的时间。DuckDB 官方文档里也一直在强调 Parquet 的适配度这真不是没理由的。4. 数据落地了一亿行查询的真实手感4.1 先跑最基础的全表扫描和 COUNT导入完成是时候看看查询了。我习惯先跑一个EXPLAIN ANALYZE让 DuckDB 自己报告执行计划和时间EXPLAIN ANALYZE SELECT count(*), avg(price), count(DISTINCT event_hash) FROM events;结果从100000000这个数字出现到查询结束总计约 1.8 秒。如果只跑count(*)甚至 0.2 秒内就能出结果因为 DuckDB 在表文件里维护了行数统计这个后面细说。带上聚合和哈希去重的完整查询在 2 秒以内完成说明向量化引擎对全表扫描的处理效率确实高。这里有个东西值得展开DuckDB 的列式存储让每个列独立存储、独立压缩。上面那张表里event_id是整数可以走轻量压缩event_hash是字符串会做字典压缩。扫描时引擎只取需要的列不用的列根本不碰磁盘这就让扫一亿行的实际 IO 量远小于读一亿行数据。4.2 分组聚合和排序才是大头实际分析没人只看 countgroup by 才是每天的日常工作。我试了一把按日期聚合SELECT event_date, count(*) AS events, sum(price) AS total_amount FROM events GROUP BY event_date ORDER BY event_date;同样是扫全表加上分组和排序之后耗时约 3.5 秒。这个手感我觉得相当能接受——你在 BI 工具里拖出这张图用户等待的时间都不会超过一杯咖啡。再激进一点试试多个维度的组合分组SELECT event_date, CASE WHEN price 30 THEN low WHEN price 70 THEN mid ELSE high END AS price_level, count(*) AS cnt FROM events GROUP BY 1, 2 ORDER BY 1, 2;加了表达式计算和两字段分组耗时 6 秒左右。这个场景模拟的是多维度下钻分析真实报表里天天都是这种查询。一亿行上 6 秒出结果在本地工具里是很能打的表现。4.3 连接外部数据一起 JOINDuckDB 的看家本领之一是跨数据源 JOIN。我把刚才那 4.6GB 的 CSV 也挂进来跟 Parquet 里的表做个对比验证CREATE TEMP TABLE csv_copy AS SELECT * FROM read_csv(events.csv, headertrue); SELECT a.event_date, count(*) AS cnt FROM events AS a JOIN csv_copy AS b ON a.event_id b.event_id GROUP BY a.event_date ORDER BY a.event_date;一亿行 JOIN 一亿行耗时在 20 秒上下。这个场景如果放在传统数据库里没有专门调优基本会跑得让人崩溃但 DuckDB 用了 hash join 多线程分片硬是给压下来了。实际工作中你不一定真要 JOIN 两张一亿行的表但本地明细表 JOIN 外部维度表是高频操作这个能力非常实用。4.4 哪些配置项会明显影响查询速度跑查询之前我建议先花十秒钟看两个设置项PRAGMA threads; PRAGMA memory_limit;threads默认是机器 CPU 核心数memory_limit默认是系统内存的 80%。对大多数机器这俩默认值就够了。但如果你同时在跑其他任务可以把内存限制降低点比如SET memory_limit8GB避免 DuckDB 把机器内存吃满。还有一个容易被忽视的开关SET preserve_insertion_order false;这个默认是 true作用是保证查询结果按插入顺序返回。关闭它之后DuckDB 在很多场景下可以省掉一次排序操作查询会有明显提升。代价是你的查询结果不保证带插入顺序了但对分析场景来说根本无所谓。我实测同样的 group by 查询关掉这个开关能快 10%~20%。5. 一亿行的资源账内存、磁盘和并发5.1 内存到底吃了多少很多人一听到一亿行就默认需要很大的内存这其实是被行存数据库惯出来的思维。DuckDB 是列存数据在磁盘上是压缩的扫描时按列读取到向量里处理内存峰值远低于你的直觉。我测下来跑单表聚合时 DuckDB 的内存峰值大概在 2-4GB这包括了执行引擎、hash 表、中间结果。也就是说16GB 内存的机器留给 DuckDB 的默认 80% 配额完全够用32GB 就能很舒服地同时跑多个任务。如果内存真的不够DuckDB 会把中间结果溢写到磁盘临时文件里查询慢一些但不会直接崩。它会明确告诉你发生了 spill... spilled 2GB of blocks to external storage ...看到这个提示别慌它只是说明你需要更大内存或者更精简的查询不是工具坏了。5.2 磁盘占用数据库文件到底多大我建的表文件my_first_100m.duckdb大约 1.2GB是 CSV 的四分之一是 Parquet 文件的两倍。原因是 DuckDB 的表存储自带列级压缩但没有 Parquet 那么激进。如果你想长期保存数据我的建议是源数据留 ParquetDuckDB 库文件只作为分析工作区这是省磁盘又跑得快的最佳组合。另外注意 DuckDB 的删除和更新不会立刻回收空间跟很多数据库一样是标记删除。如果你频繁增删数据记得定期跑CHECKPOINT;或者用VACUUM相关机制整理文件不然库文件会越用越大。5.3 并发单写多读别拿它当 OLTPDuckDB 的定位决定了它的并发模型很简单同一个库文件任意多个连接可以同时读但同一时刻只能有一个连接做写入。如果你在 Python 里开了多个进程同时写同一个库文件大概率会碰到锁冲突或者直接报错。这不是缺陷是设计取舍。分析型数据库本来就不该承担高并发写入遇到多进程同时灌数据的需求正确姿势是让每个进程先写各自独立的临时文件最后再统一 ATTACH 合并或者干脆通过APPEND串行写入。我在做批量导入任务时就是这么拆的稳定性和速度都很好。6. 这次实测踩过的坑整理成清单6.1 直接读 CSV 时的类型推断翻车read_csv_auto会自动推断列类型但一亿行数据里如果某一列前面几万行都是整数、后面突然冒出一个带小数的值推断结果就会出错或者报类型转换错误。解决办法是显式指定类型SELECT * FROM read_csv(events.csv, headertrue, columns{event_id: BIGINT, price: DOUBLE});与其让工具猜不如直接告诉它列的类型。在数据量大、来源不可控的场景下这个习惯能救你很多次。6.2 小文件太多的性能陷阱我试过把 Parquet 按日切分成 365 个小文件然后read_parquet(events_*.parquet)去读。结果查询变慢了因为每个文件都有自己的 header 和统计信息文件数量一多打开文件和读取元数据的开销就盖过了并行扫描的收益。最佳实践是把多个小文件合并成几个大文件控制在每个文件几百 MB 到几 GB的范围内。DuckDB 官方建议也是单个 Parquet 文件越大越好前提是不要超过列组的合理范围。我后来又重新 COPY 成一个大 Parquet查询速度立刻恢复正常。6.3 不要一条条 INSERT那是自虐我第一次用 DuckDB 接实时数据流时偷懒写了个循环一条一条INSERT INTO ... VALUES,结果写了几万条就慢得无法忍受。原因很简单每条 INSERT 都要走完整的解析、绑定、提交路径吞吐量被按在地上摩擦。正确做法要么是攒一批用executemany批量写要么用前面说的 DataFrame 批量灌入。实测批量写入的吞吐是单条 INSERT 的几百倍这个差距在亿级场景下就是跑几分钟和跑几小时的区别。6.4 忘记关 preserve_insertion_order这个前面提到过我再强调一次。默认开启的preserve_insertion_order在很多聚合和排序查询里会造成额外开销。如果你不做流式数据处理、不依赖插入顺序果断关掉SET preserve_insertion_order false;我在同一组查询上对比过关闭后整体耗时能下降 10%~20%白捡的性能优化不要白不要。7. 写在最后的一点心得如果你问我这次一亿行实测最大的感受是什么我觉得不是快而是省心。从安装依赖、连接数据源、导入数据到跑出分析结果整个链路没有一处需要我去配置集群、调优参数或者担心任务失败重跑。DuckDB 把分析型数据库的门槛压到了极低低到一个人、一台笔记本就能处理以前需要数仓才能搞定的数据量。当然它也有自己的边界高并发写入不是它的主场万亿级数据还是要考虑分布式方案。但在千万级到亿级这个区间在本地分析快速验证跨源汇聚这些场景里DuckDB 目前是我用过的最顺手的工具。尤其 Parquet 加 DuckDB 这个组合我觉得会成为未来几年数据分析工作流里的标配。最后再分享一个小技巧如果你要处理的数据经常更新不妨在 DuckDB 里用CREATE VIEW指向 Parquet 文件而不是每次都重新导入。这样既能保证查到的永远是最新数据又能省下导入时间。我现在的日常分析基本都是这个模式数据文件更新完查询结果立刻可见整个流程干净利落。一亿行只是起点。DuckDB 的官方测试里亿级只是热身但我更在意的是它让这个量级的数据处理变得不再需要特种部队。对于大多数数据分析场景这已经足够了。
返回列表