第一次在一台普通笔记本上跑出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 '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 sqlite和TYPE 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 的官方测试里亿级只是热身,但我更在意的是它让这个量级的数据处理变得不再需要"特种部队"。对于大多数数据分析场景,这已经足够了。