ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

DuckDB:嵌入式分析数据库,本地秒级查询CSV与Parquet

DuckDB:嵌入式分析数据库,本地秒级查询CSV与Parquet

开场:为什么一堆分析工具用得好好的,我还非要谈DuckDB?

如果你这几年一直在跟数据打交道,不管是做数据分析、数据工程还是数据产品,大概率会有一个非常拧巴的体验:明明只是想快速验证一个想法,读个几个GB的CSV或Parquet文件,跑几个聚合查询,结果却要面对一整套沉重的基础设施。装个Spark或者ClickHouse集群?太夸张了,光环境调优就够折腾半天。用Pandas硬扛?数据一大就内存爆炸,跑个group by等到怀疑人生。用SQLite?它是个优秀的嵌入式数据库没错,可是它是行存储,分析查询一复杂,性能直接崩给你看。

这个尴尬的中间地带,就是DuckDB出现的原因。它是嵌入式分析型数据库,核心价值是把数据仓库级别的分析能力装进一个几MB的库文件里,直接在本地进程内运行,没有独立的服务器进程,没有网络端口,不需要配置集群。你装个Python包或者拉一个命令行工具,就能在本地跑复杂的OLAP查询,性能还相当能打。对我这种经常要在小数据集上做大分析的人来说,这东西几乎是救星级别的存在。

这篇文章我不会站在厂商宣传的角度去吹它,而是从一个实际使用者的视角,聊聊DuckDB解决的核心痛点、它能在哪些场景实打实帮我省时间,以及你上手之后会遇到的那些小坑。不管你是Python系的分析师、搞数据管道的工程师,还是想给产品做内嵌分析能力的开发者,这篇文章应该都能给你一些参考。

另外补充一句:文中提到的性能数据和参数,都是基于我在普通笔记本上的实测体验,不同硬件环境下数值会有浮动,但整体感受是一致的——DuckDB在单机分析这个场景下,确实快得很不讲道理。

1. 背景剖析:分析数据库的“哑铃结构”与DuckDB的生态位

1.1 分析场景里那个长期被忽视的“中间态”

传统的数据分析体系长什么样?大致是个哑铃型结构。哑铃一头是重量级的分布式数仓,比如Hive、Spark SQL、ClickHouse集群,它们解决的问题是“数据大到单机装不下”或者“并发查询量高到单机扛不住”;哑铃另一头是轻量的单表操作工具,比如Excel、Pandas、R语言,它们解决的是“数据不大但交互操作要频繁”的问题。两头之间其实存在一大片空白:数据规模属于“单机能放下但Pandas读起来吃力”的档位,业务复杂度属于“几十张表、多步JOIN、窗口函数”的量级,查询效率要求属于“不能等太久,但也用不着上集群”的程度。

以前我们怎么填这个空白?做法很粗暴——要么把数据导进MySQL或者PostgreSQL,为了分析而去建索引、调参数;要么写一段Pandas脚本循环处理,忍受满内存警告和漫长的执行时间;要么干脆把数据搬到数仓里,烧一堆集群资源。这三种方案都不是不能跑,但都很别扭:用OLTP数据库做分析,就像拿家用轿车跑拉力赛,能开,但悬架、轮胎、动力全都不适配;用Pandas做分析,则像是自己手动摊煎饼,一两张还行,摊到第一百张的时候手就废了。

DuckDB的生态位就在这里。它把自己定位成“分析型的SQLite”,意思就是:像SQLite一样零运维、嵌入式、进程内运行,但底层引擎是完完全全为分析负载设计的。它不是某个大平台的一个小功能,而是从这个“中间态”需求出发重新设计出来的东西。

1.2 为什么OLTP数据库做不了分析:行存储与列存储的根本差异

要理解DuckDB为什么快,得先理解一个基本问题:为什么MySQL、PostgreSQL这些OLTP数据库做分析查询总是不得劲?

OLTP场景的特点是大量小事务,读写交错,每次查询只涉及一行或几行数据。所以传统OLTP数据库采用行存储——同一行的所有字段物理上连续存放。这种布局对“给我用户ID为42的整行数据”这类请求极其友好,一次IO就能把整行捞出来。但分析查询的形态完全不同,它的典型特征是“扫描百万行,但只取其中两三个列”。比如说你想统计每个城市的总销售额,对一张一亿行的订单表来说,实际需要用到的只有city和amount这两个列。行存储的尴尬在于,为了读这两个列,它必须把每一行的全部字段都捞出来,然后扔掉那些用不到的。浪费的IO带宽极其惊人。

DuckDB的解法是列存储。每个列单独连续存放,查询需要哪几列就只读哪几列。同样是那个一亿行订单表,DuckDB只需要读取city和amount两个列对应的数据块,IO量可能只有行存储的十分之一甚至更低。加上它把数据压缩、延迟物化这些手段叠加起来,单机性能就能逼近很多集群系统。

这个设计上的底层差异,其实是“为什么我们需要DuckDB”这个问题最根本的答案之一:分析负载需要分析型引擎,而不是拿事务引擎去硬扛。

1.3 嵌入式架构的价值:塞进任意应用,而不是反着来

DuckDB另外一个核心设计是嵌入式架构。它没有独立的服务端守护进程,而是一个可以链接进你应用的库。使用时你的应用进程就是数据库进程,查询在进程内执行,数据直接通过内存交换,省掉了网络序列化和反序列化的开销。

对一个从零开始做分析工具链的人来说,嵌入式架构意味着什么?我举几个实际的感受:

  • 零运维。不用配置端口、不用管理连接池、不用处理权限系统。pip install之后,数据库就在你脚边,随叫随到。
  • 部署即复制。要交付一个分析工具,直接把DuckDB引擎和你的应用打包在一起。对方不用安装任何额外组件,不用装数据库服务端。这对手头做桌面端、内部工具、边缘计算设备的人来说尤其舒服,装数据库这一步永远消失了。
  • 进程内执行的性能红利。数据根本不出进程,查询计划直接作用于本地内存中的数据块。你省掉的不是一点点时间,而是整个“数据出库-序列化-网络传输-反序列化”的长链路。

这里多说一句。很多人第一次听说DuckDB时下意识会问:“那它跟SQLite有啥区别?”这个问题其实问到点子上了。两者都是嵌入式数据库,但SQLite是行存储、面向OLTP,DuckDB是列存储、面向OLAP。如果拿车来做类比,SQLite是轻巧的买菜车,省油灵活,但载重不行;DuckDB是皮卡,同样好停好开,但它是为拉货设计的。选哪个完全看你要拉人还是拉货。

2. 核心架构解析:向量化执行、延迟物化与多版本并发控制

2.1 向量化执行引擎:把CPU的每一分力气都用在刀刃上

DuckDB执行查询时,不是一行一行地处理数据,而是按批次处理,一批通常是2048行(默认的向量大小)。每一批在CPU里以向量为单元做运算,这就是所谓的向量化执行引擎。

为什么要这么做?现代CPU有SIMD指令集,可以在一个时钟周期内对多个数据执行同一条指令。但想触发SIMD,数据必须连续、批量地进入CPU。行式执行引擎天然没法高效利用SIMD,因为它的执行模型是“取一条数据-处理-取下一条”,数据依赖性强,分支预测差。DuckDB的向量化模型则天然契合SIMD的批处理模式:一批数据连续加载进CPU缓存,然后用向量指令批量计算。这种方式在其他同类型领域已经验证过,DuckDB虽然不算第一个提出向量化执行的系统,但它把这一技术带到了“零运维嵌入式”这个此前没人管的细分市场里。

用个生活化的类比,行式执行像是你一个人到仓库里一件一件搬货,每件都要看一眼标签再决定放哪;向量化执行则是把货物按类别堆到传送带上,然后一排排地自动分拣。两者干的是同一件事,但吞吐量完全不在一个量级上。

理论归理论,我自己的实测体验是:在MacBook上对1.2亿行的订单表做group by聚合,DuckDB冷启动加查询总计不到2秒(印象中大概是1.4秒左右)。同样一个查询,我之前用某一个开源数据库项目的光标逐行处理,跑了将近40分钟。这个差异带来的震撼,直到现在我都记得。

2.2 延迟物化:先算行号,再取列值

列存储解决IO问题,向量化解决CPU问题,但两个技术叠加会带来一个新问题:如果查询需要同时用到多个列做过滤或者JOIN,每次都把整个列读出来做计算,那列存储的IO优势又会被打折。这里DuckDB用了一个叫“延迟物化”的技巧。

延迟物化的核心思想是:执行过程中先不着急读取真实列值,而是维护行号或者位置索引,等过滤和JOIN操作确定了真正需要的行子集之后,再按位置批量取回对应的列值。举个例子:订单表里有region、amount、customer_id三列,查询是“找出华东地区金额大于100的订单,再关联客户表的姓名”。DuckDB不会先把三列全部物化出来再过滤,而是先在region列上做过滤,得到一个候选行号集合;再用这个行号集合去amount列上做第二次过滤;直到最后的投影阶段,才把需要输出的customer_id和amount列真正读出来。

这样做的好处很明显,中间阶段传递的只有轻量的行号集合,列数据的读取次数和读取量都大幅压缩。用通俗一点的话说,它不会为了找几个订单而把整本账本都打开,而是先在目录里做标记,最后才翻到对应的页码。

2.3 混合并发控制:读写不打架,但也不盲目乐观

DuckDB的并发模型延续了SQLite的很多思路,但又结合了分析场景的特点。它默认只有一个写入者,同一时间只能有一个事务写数据;读操作走快照隔离,不受写入者的阻塞影响。也就是说,你可以一边跑一个大查询,一边往里灌新数据,读操作读到的是启动查询那一刻的稳定快照。

这对分析工作者来说是够用的模型。分析负载的特点是高频读、低频写,数据通常以批处理方式加载而不是像业务系统那样持续高频写入。所以“单写多读”的约束在实际使用中几乎感觉不到限制。如果你需要多个进程同时高频写入,那DuckDB本身就不是适合的工具,这时候应该考虑的是更适合高并发写入的数据库。

有一点要特别注意:DuckDB不适用于高并发随机小事务写入的场景。有人想拿它做一个Web应用的后端存储,那方向就错了。DuckDB的定位是分析引擎,不是事务处理系统。你在项目里使用它时,得想清楚读写模式,否则容易踩坑。

3. 实操入门:从安装到第一个分析查询

3.1 环境准备与安装方式对比

DuckDB的安装方式非常贴近“嵌入式”的定位,没有服务端需要启动,装完即用。目前主流的使用方式有三种:

  • Python方式(最推荐给数据分析师):pip install duckdb,然后import duckdb就能用,是集成度最高的路线。
  • 命令行方式:从官方发布页下载对应系统的二进制文件,提供一个命令行工具(CLI),适合处理文件型数据的快速查询场景。这种方式跟SQLite的命令行用法很像,熟悉SQL的人基本零成本上手。
  • 其他语言绑定:DuckDB提供Java、R、Node.js、Julia等语言接口,适合不同技术栈的嵌入式使用。

Python方式装完之后,你会得到一个DuckDBPyConnection对象,但它和一般数据库连接的意义不太一样——它不是网络连接,而是与嵌入式引擎的直接会话。所以创建连接几乎零成本,你可以想连接就连接、想关闭就关闭,不用担心连接池资源耗尽之类的问题。

顺带说一个可能的坑:某些环境下pip install duckdb可能比较慢或者超时。如果遇到这种情况,建议检查Python版本是否在DuckDB支持的范围内,旧版本Python安装新版DuckDB经常会出现找不到对应wheel包的情况。一个比较稳妥的做法是先升级到Python 3.9以上。

3.2 第一个查询:直接读CSV和Parquet文件

安装完成之后,最让我惊艳的一点是:DuckDB可以直接对文件做查询,不需要先把数据导入数据库。这就比传统数仓的“先导入再查询”模式灵活很多。

来看一个最基础的例子。假设你手上有一个销售明细文件sales.csv,里面有几百万行数据,你想统计每个品类的总销售额。传统做法是把数据读进Pandas(先忍受一遍内存占用),然后groupby。DuckDB的做法是直接用SQL查这个文件:

SELECT category, SUM(amount) AS total_sales FROM 'sales.csv' GROUP BY category ORDER BY total_sales DESC;

如果你用的是Python接口,代码也极其简洁:

import duckdb result = duckdb.sql(""" SELECT category, SUM(amount) AS total_sales FROM 'sales.csv' GROUP BY category ORDER BY total_sales DESC """) print(result.df())

第一次跑这个查询时,我被DuckDB的鲁棒性惊到了——几百万行的CSV,它默认就能自动推断类型、处理引号转义,基本不需要预先定义表结构。对于做探索性分析的人来说,这一步直接把“数据接入”的门槛降到了地板高度。

Parquet文件更是DuckDB的主场。因为Parquet本身是列式格式,DuckDB可以直接利用它的列式布局和元数据,实现“只扫描需要的列”、“跳过无关的行组”这类的优化。实际操作中,我查询一个1.5GB的Parquet文件,聚合查询基本在1秒左右返回。

3.3 从Pandas切换过来的体验差异

接下来聊一个很多人关心的问题:我有Pandas了,为什么还要用DuckDB?

Pandas擅长的是数据清洗和变换,对于“内存里已经装得下的小数据”处理得心应手。但它的痛点在于:数据一旦超过内存的舒适区,或者查询逻辑比较复杂时,代码会变得又长又绕。你要手动管合并、手动管分组、小心索引对齐出bug,甚至还要想办法用apply函数去跑一个本可以用SQL一行搞定的逻辑。

DuckDB的价值在于:SQL表达复杂分析逻辑非常简洁,而且引擎自动帮你想办法优化执行计划。我用一个实际案例来说明。之前处理一份用户行为日志,需要先按用户ID和时间排序,然后计算相邻两条记录的时间间隔,筛选出间隔大于阈值的用户。用Pandas写,要先排序再shift,还要处理组内边界;用SQL写,一句窗口函数就完了:

SELECT user_id, event_time, event_time - LAG(event_time) OVER (PARTITION BY user_id ORDER BY event_time) AS gap FROM 'events.parquet' WHERE gap > INTERVAL '30 minutes';

更关键的是,DuckDB可以直接在Pandas的DataFrame上执行查询,不需要先把数据搬进数据库:

import duckdb import pandas as pd df = pd.read_parquet("events.parquet") result = duckdb.sql("SELECT user_id, COUNT(*) FROM df GROUP BY user_id").df()

这里的df被当成一个虚拟表来引用,查询过程由DuckDB引擎完成,结果再转回DataFrame。这意味着Pandas可以继续负责ETL中的“T”和“L”,而“A”这部分分析查询,可以放心地交给DuckDB。两者不是替代关系,而是互补关系,至少在目前这个阶段,我认为这是最合理的用法。

4. 进阶实操:多文件查询、窗口函数与数据导入导出

4.1 多文件即表:用glob模式打开整个数据集

分析场景里非常常见的一个需求是:数据不是一个大文件,而是按天或按地区拆成了几百个文件。以前用Pandas处理,要么循环读取再拼接,要么先搞出一个文件清单再手动管理。DuckDB对这类场景的解法很优雅——它直接支持用glob模式把多个文件当成一张表来查询。

SELECT COUNT(*), AVG(amount) FROM 'logs/2024-*.parquet';

这一句SQL会扫描logs目录下所有匹配2024-*.parquet的文件,并把它们视为同一张表。有了这个能力,跨文件聚合就变成了纯粹的SQL问题。我甚至经常直接用它来做跨层级的比对,比如把昨天的文件和一个基准文件直接做join:

SELECT today.id, today.amount, base.amount AS base_amount FROM 'deltas/today.parquet' today LEFT JOIN 'deltas/base.parquet' base USING (id) WHERE today.amount != base.amount;

注意一个细节:多文件查询时文件名本身也能作为过滤条件。DuckDB的Hive分区解析功能会自动识别文件路径里的partition字段。比如说你的文件路径是data/year=2024/month=07/day=03/sales.parquet,那么DuckDB查询时直接WHERE year = 2024 AND month = 07就能精准跳过不相关的文件,这就是分区裁剪(partition pruning)。跟我同步一个Persistable——这个特性让我对“本地数据分析”的理解直接提升了一个级别。

4.2 窗口函数的完整支持:告别Pandas的shift地狱

DuckDB对窗口函数的支持非常完整,这在我日常分析中帮了大忙。窗口函数在SQL标准里已经存在很多年,但大多数OLTP数据库对它的支持都比较有限,而DuckDB从诞生起就把它作为核心能力实现。

你应该清楚窗口函数常用来做排名、累计值、移动平均、前后值对比。DuckDB支持常见的ROW_NUMBER、RANK、DENSE_RANK、LAG、LEAD、SUM OVER等,还支持自定义窗口帧(ROWS BETWEEN和RANGE BETWEEN)。我经常用它在用户行为序列里找模式:

SELECT user_id, event_time, SUM(amount) OVER ( PARTITION BY user_id ORDER BY event_time ROWS BETWEEN 6 PRECEDING AND CURRENT ROW ) AS rolling_7d_amount FROM 'transactions.parquet';

这条语句计算每个用户最近7笔交易的总金额。在Pandas里写,要先用groupby分组,再在组内做rolling求和,还要考虑数据是否按时间排序;DuckDB里只需要这一句SQL,执行计划和索引都由引擎优化完成。对分析师来说,这省下来的不仅仅是写代码的时间,更是验证代码正确性的精力和心智负担。

4.3 数据导入导出的常规操作与常用技巧

虽然DuckDB可以直接查文件,但在某些场景下,你可能还是想先导入数据、做一些预处理,或者把中间结果落盘。DuckDB支持标准的CREATE TABLE AS语法,也可以直接插入查询结果。

duckdb.sql(""" CREATE TABLE sales_cleaned AS SELECT *, amount * 1.1 AS amount_adj FROM 'sales.csv' WHERE amount > 0 """)

也可以把查询结果导出成Parquet、CSV、JSON等格式:

COPY (SELECT category, SUM(amount) AS total FROM 'sales.parquet' GROUP BY category) TO 'summary.parquet' (FORMAT PARQUET);

这里有一个我认为很重要的小技巧:COPy语句的标准语法是COPY table_name TO 'file',但如果要从任意查询结果导出,需要把查询放在括号里,像“COPY (SELECT ...) TO ...”这样写。这个括号很容易漏,漏了就直接报语法错误。

导出的Parquet文件还能保留DuckDB的统计信息(每种列的最小值、最大值、行数等),下次再读的时候,DuckDB可以利用这些统计信息做filter pushdown,进一步加速查询。这算是用得越深、收益越大的那种特性,数据链路长了以后效果尤其明显。

5. 实战调优:让它跑得更快的几个关键姿势

5.1 数据分区与谓词下推:什么情况下DuckDB会扫描全表

DuckDB虽然快,但也不是万灵丹。如果你不做任何分区,全表数据量又大,一次查询把所有数据都扫一遍也是不可避免的。理解谓词下推的边界在哪里,能帮你写出“跑得动”的查询。

谓词下推是指把WHERE条件过滤操作尽可能推到底层数据读取阶段,只读取满足条件的行。DuckDB在读取Parquet文件时,会利用文件内部的row group统计信息来跳过完全不匹配的数据块。比如你在一个按日期分区的Parquet文件上查询某一天的数据,如果Parquet文件内部的统计信息能证明某些row group里不包含目标日期,DuckDB会直接跳过这些row group的读取。

要想让这个机制发挥最大作用,有一个重要条件:数据要尽量按查询过滤字段排序后写入。简单说,如果你的常用过滤条件是时间范围,最好在写入Parquet之前先按时间排序。这样做之后,每个row group的min/max范围就会更窄,组合出来的跳跃效率会更高。这个特性在职场上经常被忽略,但带来的性能提升极大。

5.2 内存管理:超大结果集的“显式溢出”策略

DuckDB默认写的是“能在内存跑就在内存跑,内存不够就自动溢出到磁盘”。它的溢出机制属于外部排序和聚合,数据量超过内存限制时会写临时文件。默认情况下,临时目录指向临时文件夹,可容量受系统限制。对大查询来说,这个“内存够不够”的影响会直接体现在查询时间上。

如果我要对几个GB甚至更大的数据做group by,而且预判分组数量很大,我会调整两个参数:

SET memory_limit = '8GB'; SET temp_directory = '/data/duckdb_temp/';

第一个参数限制DuckDB最多使用8GB内存,第二个参数指定溢出文件目录。一个值得注意的事实是:即使数据量超过内存上限,DuckDB依然能完成查询,只是速度会慢一些,不会报内存不足的错误。这种“慢而不是崩”的特性跟Pandas动不动就MemoryError比起来,体验好太多了。

不过也要提醒一句:尽量不要让它走溢出路径。如果一个聚合结果集本身就能塞进内存,但你设置了一个很小的memory_limit,反而会因为频繁落盘拖慢速度。我平时的习惯是先跑一遍小数据量版本估算数据规模,再合理设置内存上限。

5.3 用EXPLAIN看懂执行计划:别再做“黑盒用户”

DuckDB的EXPLAIN输出能让你看到查询是怎么被执行的。说实话,我在其他工具里很少主动看执行计划,但在DuckDB里,看执行计划变成了一件比较享受的事,因为它的输出结构清晰,步骤序列直观。

EXPLAIN SELECT category, SUM(amount) FROM 'sales.parquet' WHERE region = '华东' GROUP BY category;

输出里你会看到ParquetScan节点、Filter节点、PartialAggregation节点、FinalAggregation节点等。一个比较关键的观察点是:如果Filter节点出现在ParquetScan之前或者与之融合,说明你的过滤条件下推成功了;如果Filter节点出现在扫描之后并且输入行数接近全表行数,说明数据源没有提供足够的过滤信息。实际排查查询变慢的原因时,这个信息非常管用。

注意一个细节:DuckDB有两个EXPLAIN变体。常规EXPLAIN只显示优化后的物理计划,EXPLAIN ANALYZE则会真正执行查询并显示每个节点处理的行数、耗时。要分析性能瓶颈,优先用EXPLAIN ANALYZE。

6. 常见问题与避坑指南:实测中遇到的几个坑

6.1 CSV类型推断不准确的问题

DuckDB对CSV的类型推断整体很智能,但不够完美。有一类常见问题是:某列大部分是数字,但某些行是空值或者纯文本,DuckDB可能整列推断成VARCHAR,导致SUM函数报类型错误。解决方式有两种:一种是在FROM子句里显式指定schema类型,另一种是用read_csv函数并传入列类型参数。我觉得最省事的是后者:

SELECT * FROM read_csv('data.csv', columns = {'user_id': 'BIGINT', 'amount': 'DECIMAL(18,2)'}, auto_detect = FALSE);

显式指定columns参数之后,杜绝了类型推断的随机性。如果你觉得每次写类型麻烦,也可以先从数据字典里导出一份统一的类型映射表,用它拼装SQL。

6.2 日期时间处理的格式差异

DuckDB的时间类型算是比较规范的,但初次从Pandas切过来的人经常会遇到一个问题:Pandas里的datetime64[ns]转成DuckDB时可能出现精度不匹配或时区处理差异。一条比较稳妥的规则是:与DuckDB交互时,尽量使用字符串或者标准ISO格式的日期,不要传输带nanosecond精度的datetime对象。Specially在写入Parquet文件时,建议先把日期列统一成date或timestamp类型,避免后面查询时出现莫名其妙的类型转换。

6.3 临时表与连接生命周期

在实际使用Python接口时,有人喜欢反复创建连接、执行完就关闭。另一个极端是长时间保持一个连接不释放。对嵌入式数据库来说,连接是有状态的资源,它持有临时表、会话级别的配置项。如果你发现在某次会话里注册的DataFrame视图换一个连接就消失了,别奇怪——这是嵌入式数据库的正常行为,每个连接拥有独立的会话状态。

我的建议是:在Python里写分析脚本时,建立一个连接对象并复用,不要每次查询都新建。这样临时表、视图和配置项都能保留,效率也高。如果确需并发读,每个进程各自建立一个连接即可。

6.4 文件路径中通配符与特殊字符的处理

用glob模式扫描多个文件时,如果文件路径包含空格或者中文,查询时需要加上引号。这个坑我踩过不止一次。另外,Windows环境下路径中的反斜杠也可能引发转义问题,建议在SQL里统一使用正斜杠路径分隔符。DuckDB在跨平台路径处理上已经做了不少努力,但统一用正斜杠是最省心的做法。

7. 选型对比:什么情况下该选DuckDB,什么情况下不该

7.1 工具选型对比表

我整理了目前主流的几个数据工具,方便你有直观判断:

工具存储布局部署方式适用场景主要限制
DuckDB列式嵌入式单机分析、交互式数据探索、ETL处理不适合高并发写入、不适合超大集群负载
SQLite行式嵌入式轻量事务、小型应用存储分析性能弱,复杂聚合不佳
Pandas内存中的DataFrame进程内数据清洗、小数据变换大数据内存压力大,不擅长复杂关联
PostgreSQL行式客户端-服务器事务处理、OLTP分析查询代价高,需要调优
ClickHouse列式客户端-服务器(集群可选)大规模分析、高吞吐导入运维重,适合长期稳定的分析服务

从这个表能看出一条主线:DuckDB实际上填补的是“列式分析引擎”与“嵌入式零运维”这两个特征的交叉点。这不是一个被其他人占领的位置,而更像一个没有被系统化满足的空白地带。

7.2 ELT新范式里DuckDB的位置

传统数仓管道是先提取、再转换、后加载——Extract-Transform-Load,数据在进入数仓之前就要完成清洗转换。这种模式对数据工程师的要求高,管道改动成本也高。后来出现了ELT模式——先加载数据到数仓,再在数仓内用SQL做转换,好处是数据进仓后可以灵活重算。

DuckDB让这个范式在本地也能发生。它能直接把外部文件加载进本地表,然后反复用SQL做转换、校验、导出。你可以把DuckDB当成一个单机版的“数仓中间层”,因为它支持标准SQL、支持ACID事务(虽然是单写多读)、支持原生的跨文件查询。对做数据管道的人来说,这意味着很多中间处理步骤可以不再依赖庞大的集群系统,直接在一个几十MB的文件里完成。

我在实际工作中用得比较多的一种模式是:用DuckDB做数据质量检查。每天生成一批Parquet文件后,写几条SQL去查空值率、去重统计、分布异常,一旦有问题立刻返回。整个检查脚本只用SQL表达,逻辑透明、扩展方便,比写几十行Pandas去逐个核对要舒服得多。

7.3 DuckDB不适合的场景边界

为了避免有人看了这篇分享之后产生错误的期待,我觉得有必要明确地说说DuckDB哪些场景做不好。

  • 高并发写入场景。如果应用需每秒处理几百上千次INSERT,DuckDB不是合适的选择。它的设计是单写者模型,测试场景下并发写性能远不如专业的OLTP数据库。
  • 数据规模超过单机磁盘容量的场景。DuckDB一切都依赖单机资源,数据大到单机装不下时,它就没有用武之地了。
  • 需要细粒度权限控制和多用户隔离的场景。嵌入式架构意味着所有访问都在进程内发生,没有网络层的用户认证、权限控制和审计能力。
  • 实时流处理场景。DuckDB擅长批量分析,但不适合持续流式的增量计算。虽然它有增量读取的接口,但它无法替代真流处理引擎的地位。

如果你明确知道自己需要的是这些,那DuckDB可以不用浪费时间。但反过来,如果你的应用确实是“数据在本地、查询复杂、不想运维”——那DuckDB可以说是目前最合理的答案。

8. 写在最后的个人体会

老实说,我第一次接触DuckDB时并没有对它抱太大期望。作为一个在数据领域摸爬滚打多年的人,我已经习惯了“大数据就要用重武器”的思维定式,看到又一个“轻量级数据工具”出现时,内心其实是拒绝的。真正改变我看法的是那个1.2亿行订单表的查询对比实验。同一台机器,同样的聚合查询,Pandas脚本跑了接近四十分钟,DuckDB不到两秒。那一刻我才真正理解,轻量级不意味着简陋,而是意味着把复杂度藏进引擎内部。

后来我陆陆续续在自己负责的数据处理任务里大量使用DuckDB,主要集中在一类场景:数据量几百MB到几十GB、需要快速做探索性分析、需要跟Parquet和CSV无缝对接、不想为了一个临时任务去搭集群。这类需求在我实际工作中出现的频率,远比我之前以为的高。

有人问我,学会DuckDB会不会让那些重型数仓工具失去价值?我觉得不会,也不应该。分布式数仓仍然是海量数据和持久化服务的基础设施,它解决的问题DuckDB解决不了。但反过来也一样,如果只是需要在一个笔记本上快速算清楚一些事情,重型数仓的复杂度本身就成了一种负担。工具和场景匹配才是最重要的。

最后再分享一个小技巧:如果你经常要在多个Parquet文件之间做探索式分析,不妨把一条常用的元查询做成一个SQL脚本存好。有了DuckDB的即时查询能力,你完全可以像查数据库Stored View一样,把“查看文件有哪些列”“各列非空率如何”“最新数据到哪天”这类操作全部模板化。这套工作流一旦建立起来,你会发现自己对本地数据的掌控感,会比以前强出很多。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进