news 2026/9/9 5:26:37

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

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
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.duckdb

Python 里连接也极其自然:

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 'dbname=appdb user=postgres host=127.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 sqliteTYPE mysql。这意味着你可以把 DuckDB 当做一个"数据汇聚分析层",业务系统该用什么库还用,分析任务丢给它。这个能力在你数据分散在多个系统、又不想搭数据仓库的时候,特别好用。

3. 把一亿行灌进去的三种方式,实测对比

3.1 方式一:直接从 CSV COPY 进来

CSV 是最通用的数据交换格式,所以我先试了它。先把生成好的表导成 CSV:

COPY 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 做中转,速度快一个量级

Predicate:Parquet 是列式存储,DuckDB 也是列式执行引擎,两者是"天作之合"。Parquet 文件自带 schema 和压缩,DuckDB 加载它的时候可以直接按列读取,跳过不需要的列,还能利用文件里的统计信息做分区裁剪。

我的操作是先把数据转成 Parquet:

COPY 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 或者用executemany

import duckdb import pandas as pd con = duckdb.connect("stream.duckdb") for batch in fetch_data_batch(batch_size=100_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 分组聚合和排序才是大头

实际分析没人只看 count,group 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 连接外部数据一起 JOIN

DuckDB 的看家本领之一是跨数据源 JOIN。我把刚才那 4.6GB 的 CSV 也挂进来,跟 Parquet 里的表做个对比验证:

CREATE TEMP TABLE csv_copy AS SELECT * FROM read_csv('events.csv', header=true); 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_limit='8GB',避免 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 那么激进。如果你想长期保存数据,我的建议是源数据留 Parquet,DuckDB 库文件只作为分析工作区,这是省磁盘又跑得快的最佳组合。

另外注意 DuckDB 的删除和更新不会立刻回收空间,跟很多数据库一样是标记删除。如果你频繁增删数据,记得定期跑:

CHECKPOINT;

或者用VACUUM相关机制整理文件,不然库文件会越用越大。

5.3 并发:单写多读,别拿它当 OLTP

DuckDB 的定位决定了它的并发模型很简单:同一个库文件,任意多个连接可以同时读,但同一时刻只能有一个连接做写入。如果你在 Python 里开了多个进程同时写同一个库文件,大概率会碰到锁冲突或者直接报错。

这不是缺陷,是设计取舍。分析型数据库本来就不该承担高并发写入,遇到"多进程同时灌数据"的需求,正确姿势是让每个进程先写各自独立的临时文件,最后再统一 ATTACH 合并,或者干脆通过APPEND串行写入。我在做批量导入任务时就是这么拆的,稳定性和速度都很好。

6. 这次实测踩过的坑,整理成清单

6.1 直接读 CSV 时的类型推断翻车

read_csv_auto会自动推断列类型,但一亿行数据里,如果某一列前面几万行都是整数、后面突然冒出一个带小数的值,推断结果就会出错或者报类型转换错误。解决办法是显式指定类型:

SELECT * FROM read_csv('events.csv', header=true, 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 的官方测试里亿级只是热身,但我更在意的是它让这个量级的数据处理变得不再需要"特种部队"。对于大多数数据分析场景,这已经足够了。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/9 5:26:33

开源生产级模型落地指南:从部署架构到业务实践与避坑

1. 先说清楚&#xff1a;为什么“内部生产级模型开源”这件事值得追最近在技术社区看到这个标题时&#xff0c;我的第一反应是先去确认消息源。因为它同时踩中了两个关键词&#xff1a;生产级模型和开源。圈内人都知道&#xff0c;很多团队在对外分享时喜欢用“我们有一个模型”…

作者头像 李华
网站建设 2026/9/9 5:26:19

BMS SOC越界惩罚机制:从边界区保护到状态机工程实践

1. 越界惩罚不是保护动作&#xff1a;它在SOC估算体系里到底治什么把“SOC越界”和“惩罚”放在一起&#xff0c;我第一次看到这个字段时心里其实有点疑惑&#xff1a;SOC只是一个通过电流积分、电压查表、卡尔曼滤波算出来的状态估计值&#xff0c;状态本身越界了&#xff0c;…

作者头像 李华
网站建设 2026/9/9 5:26:12

hermes-agent:轻量级智能体调度中枢架构解析

1. 项目概述&#xff1a;一个被低估的轻量级智能体调度中枢“hermes-agent”这个词最近在技术社区里冒头的频率明显变高&#xff0c;但翻遍主流文档、GitHub仓库和教程平台&#xff0c;你会发现它既不是某个知名开源框架的官方子项目&#xff0c;也不属于任何大厂公开发布的AI基…

作者头像 李华
网站建设 2026/9/9 5:26:10

YOLOv9遥感烟囱检测:从影像批量采集到坐标回算的完整实践

先说清楚一个事&#xff1a;这个项目做的不是“给一张图让模型猜有没有烟囱”的玩具Demo&#xff0c;而是一条完整的自动化链路——从按经纬度批量采集Google Earth影像&#xff0c;到整理成训练集&#xff0c;再到用YOLOv9把烟囱目标训练出来&#xff0c;最后落到一个能批量扫…

作者头像 李华
网站建设 2026/9/9 5:26:02

Go语言中如何模拟C++的封装、继承与多态:结构体嵌入与接口实战

很多从C过来的朋友&#xff0c;第一眼看到Go都会觉得别扭&#xff1a;结构体上写个方法好像可以接受&#xff0c;可一旦提到继承、多态&#xff0c;文档往往直接甩你一句“Go不支持继承&#xff0c;请用组合”。这个结论本身没错&#xff0c;但落到真实项目里并没有那么简单。我…

作者头像 李华
网站建设 2026/9/9 5:25:58

opencode 终端 AI 编程助手:安装、模型配置与实战指南

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华