DuckDB:嵌入式OLAP数据库,列式存储与向量化查询实战
2026/9/9 2:22:43 网站建设 项目流程

1. 为什么一个"嵌入式数据库"敢碰OLAP的活儿

做数据处理的人这几年应该都有同样的困惑:单机上放着几十GB的Parquet、CSV,临时想跑个分组统计、算个占比,用Pandas一读就接近内存上限,启动个Spark集群又显得大炮打蚊子,SQLite倒是轻量,但它的执行引擎本质是OLTP那一套,跑大查询就是全表扫描。DuckDB这两年在数据圈子里频繁被提起,核心原因就是它把"单机数据仓库"这件事做成了进程内嵌的体验,查Parquet就跟查一个普通表一样自然。

DuckDB是一个开源的嵌入式OLAP数据库,不需要独立服务端,直接以库的形式链接进Python、R、Node.js、Julia等程序里。它采用列式存储作为底层布局方式,执行引擎是向量化的,并且能直接扫描Parquet文件而不需要先导入数据。这三件事单独拿出来都不算新鲜,但组合在一起,"查一下某个目录下的几千个Parquet分片"就从过去需要搭数仓、写Spark的活儿,变成了一条SQL就能解决的事。

这篇文章适合谁?三类人。第一类是数据分析师,手里攒了一堆CSV和Parquet,想快速做探索性分析;第二类是数据工程师,想验证管道输出文件的正确性,或者做轻量ETL;第三类是做AI应用开发的,正在搞RAG相关的数据向量化流程,需要先把杂乱的Excel、JSON、CSV整理成干净的文本块。我下面按自己实际用下来的理解和踩过的坑来写,尽量不堆概念,拆开讲清楚原理,再给可以直接上手的方式。

1.1 把一个数据库"塞进"进程里,解决了什么

传统数仓给人什么印象?要有独立的服务进程,有专门的端口,有连接池,要管理用户名和权限。DuckDB不这么玩,它是Embedded数据库,类似SQLite的定位,但面向的分析场景完全不同。你的程序调用DuckDB的API,数据文件就摆在磁盘上,查询直接在进程内执行,不需要经过网络协议层。

这种设计带来的直接好处有三个。第一个是部署成本几乎为零,不用装服务、不用配集群,pip install duckdb就算装好了。第二个是数据不需要"导入"到自己的存储引擎里,它直接读文件系统上的数据,这就消除了传统数仓里最重的那个环节,ETL中的T和L可以压缩到最小。第三个是进程内执行意味着数据和计算在同一个地址空间,少了序列化和网络传输的开销。

换个角度理解:DuckDB像是把"数仓的查询引擎"抽出来,做成了一个可嵌入的库,同时保留了SQL的表述能力和列式存储的性能特征。你可以在Jupyter Notebook里直接创建一个10GB的临时分析环境,跑完关掉,不留任何常驻进程。这种轻量特性对现代数据团队的日常探索来说太重要了。

1.2 与SQLite、Pandas、Spark的边界在哪里

很多初学者会把DuckDB和SQLite放在一起比较,因为都是嵌入式数据库。但两者其实是两个维度的东西。SQLite是典型的行式存储,面向事务处理,特点是高频的小读写、锁粒度细、支持ACID。DuckDB是列式存储,面向分析型负载,擅长的是大范围扫描、批量聚合、复杂的多表连接。你让SQLite跑一个在5000万行上做分组统计的查询,它会把整列数据都扫一遍,执行计划也还是那个思路;换成DuckDB,列式布局会把不必要的IO省掉,加上向量化执行,速度差出一个量级。

Pandas解决的问题是内存里的DataFrame操作,灵活,但数据量一大就会被内存限制住,而且它本身不是查询引擎,没法做SQL优化器层面的谓词下推。DuckDB可以理解为"比Pandas更能扛数据量、比Spark更轻的中间选项"。Spark适合分布式场景,当你单机内存装不下、需要多节点横向扩展时才值得引入;如果数据是几十GB到几百GB,单节点磁盘够用,DuckDB往往是更务实的方案。

边界感我自己总结成一句话:单机内存放得下的数据用Pandas没问题,单机磁盘放得下的分析场景优先考虑DuckDB,数据量大到单机磁盘都吃力时再考虑Spark或真正的分布式数仓。很多人一上来就上Spark,其实大部分分析任务根本没有到需要分布式的规模,反而被集群调度的复杂度拖住了效率。

2. 列式存储带来的碾压式查询体验

DuckDB在存储和交换数据的过程中,始终贯穿着列式布局的思路。理解这个原理,你才能真正明白"直接查Parquet"为什么快,以及为什么它对某些查询模式的加速如此明显。

2.1 从CSV到列式布局:烦恼的根源是行

CSV和传统关系型数据库默认的行式存储,数据在物理上是按行连续摆放的。一行记录的所有字段紧挨着写在一起,读一行就能拿到这一行的全部属性。这对点查询和频繁更新非常友好,因为修改一行只需要定位到那一行所在的位置,然后覆盖写。

但分析型查询几乎都是"对某一列或多列做批量计算"。比如要算整个电商订单表的销售额总和,行式存储的扫描方式得把每一行都读出来,取出amount字段做累加,然后丢掉其他字段。问题是,大量无关字段也一并读入了内存和CPU缓存,IO放大非常严重。数据量从GB级往TB级走的时候,瓶颈往往不是计算,而是把数据从磁盘搬到内存的带宽。

DuckDB的列式存储把这一套整个翻过来了。每一列的数据连续存储在独立的区域内,查询只需要读取涉及的列。同样是算销售额总和,只需要把amount这一列从头到尾读一遍,其他列的字节完全不用碰。磁盘IO从"读整张表"降为"读一列",省下来的带宽和等待时间,就是列式存储的第一重红利。

2.2 列式布局带来的压缩和IO收益

列式存储的另一大优势是压缩率。同一列的数据类型完全一致,数值往往落在某个有限区间内,字符串也可能存在大量重复。这种高局部性让压缩算法非常容易找到规律,常见的压缩手段包括字典编码、游程编码、位打包等。

拿一个实际场景举例:一张订单表里的status字段只有pending、paid、shipped、cancelled四种取值。行式存储在每一行里都存一遍完整字符串,假设平均8个字节;列式存储可以用字典编码,字典里存四个值各一次,然后在数据区用2个bit表示枚举值,2000万行下来,存储量从160MB降到几十MB级别。读取阶段,压缩数据先被解压成内存中的列向量,再喂给计算层,尽管多了解压这一步,但因为数据量大幅缩小,磁盘读时间反而更短。

这也是为什么DuckDB官方建议"能用Parquet就不要用CSV"的原因。CSV本质上没有任何列式能力,DuckDB读CSV时必须先解析整个文件,没有压缩、没有统计信息、没有谓词下推的元数据可用。而Parquet配合列式压缩和行组统计,扫描效率不在一个量级。

2.3 延迟物化:先在列上算,最后才组行

还有一个容易被忽略的点:列式存储改变了查询执行过程中的数据组装方式。传统行式引擎在扫描阶段就会把一条条完整的行组装出来,然后传给下游算子。DuckDB这类列式引擎则倾向于"延迟物化",意思是在算子链的上游尽量保持列式数据,先完成大部分过滤、聚合、投影计算,等最后需要输出结果或执行连接的时候,才把列拼装成行。

这个习惯带来的性能收益很直观。如果一条查询只需要三列参与过滤和聚合,那么在整个执行过程中,引擎始终只在这三列上做批处理,大幅减少了CPU缓存的压力和数据搬运量。等到最终SELECT要输出五列时,才临时补上剩余两列进行组装。这种设计配合向量化执行,效果是叠加的。

理解延迟物化对我自己的实际收益在于:写SQL时我开始刻意避免SELECT *,因为多取的列在列式存储里不是"加上几个字段那么点开销",而是真的会触发额外的列读取和解压。你让DuckDB去读一列和读十列,即使最终都只SELECT一个字段,底层IO是完全不同的量级。

3. 向量化执行:在CPU流水线上做数据批量加工

列式存储解决的是"如何少读数据",向量化执行解决的是"读进来的数据如何算得快"。这两者配合,才构成DuckDB高性能的核心逻辑。

3.1 从"逐行解释"到"一次处理一批"

传统关系型数据库的执行引擎普遍采用火山模型,也叫Iterator Model。每个算子都实现一个next()方法,调用一次返回一行数据。上一层的算子在循环里不停调用下一层的next(),每拿到一行就处理一行。这套模型的优点是抽象清晰,缺点是每处理一行都要经历一次函数调用、一次虚函数分发,CPU的分支预测在这种模式下频繁失效,执行效率大打折扣。

DuckDB采用的是向量化模型,它的数据交换单位不是一行,而是一个Vector,也就是一批固定大小的列数据,默认情况下一个Vector包含1024行。算子拿到一个Vector后,不再为每行做一次解释和分发,而是在内部用一层紧凑循环批量处理这个Vector里的所有数据。函数调用次数从"每行一次"降为"每1024行一次",解释开销被摊薄到几乎可以忽略。

打个比方,火山模型像是流水线上的工人一个一个地处理零件,每个零件都要停下来检查图纸;向量化模型像是把零件分成一批一批地传过来,机器内部用统一的模具批量冲压。单件加工时间差不多,但大批量处理时,机器空闲时间少了很多。

3.2 1024行为一桶:向量化引擎的执行节奏

为什么是1024行而不是1行,也不是100万行?这个数字是工程权衡的结果。Vector太小,函数调用的摊薄效果不明显;Vector太大,内部循环体占用的寄存器、缓存资源会溢出,反而降低单次循环的效率。1024行在实践中的表现是:CPU的L1/L2缓存能轻松容纳,内部循环可以做得很紧凑,同时内存带宽利用充分。

向量化处理方式对底层算子的实现影响很大。以哈希聚合为例,逐行模式下每来一行就要探查哈希表、更新累积状态,随机访问频繁,缓存命中率低。向量化模式下,可以先批量计算每行的哈希值,对一批数据进行分组,然后分组更新聚合状态。类似地,过滤操作在向量化引擎里可以批量生成掩码位图,一次跳过整个不满足条件的区域。

我自己在跑聚合类查询时最大的感受是,DuckDB对小数据量返回结果几乎"秒出",这不是连接数或索引带来的,纯粹是向量化批量计算的功劳。尤其当数据已经落在Parquet里、被列式存储压缩过之后,扫描阶段就已经节省了大量时间,剩下的计算阶段再由向量化引擎提速,两相叠加,体验和数据仓库别无二致。

3.3 CPU为何喜欢这种批处理

现代CPU的性能瓶颈早就不是主频,而是内存访问延迟和指令级并行能力。向量化执行之所以能快,是因为它天然适配CPU的工作方式:

  • 提高缓存命中率:一批1024行的同一列数据,内存地址是连续的,CPU按顺序加载进缓存后,一次加载可以被反复利用多次。
  • 增强分支预测成功率:批处理内部循环的结构相对固定,跳转规律容易预测,不像逐行解释那样每个循环都面临不确定的分支。
  • 可以利用SIMD指令:列数据连续排列、类型一致,是SIMD指令理想的数据形态。现代CPU的AVX指令可以一次处理多条数据,在聚合、过滤、比较等场景中明显提速。

理解这一点有助于调整使用方式。DuckDB在扫描Parquet时,"把需要的列连续读取"是它在磁盘布局上就能做到的事。SQL层面优化的一个实用技巧是:尽量筛选小列做过滤,再回表取大列。因为过滤阶段如果只读一列,IO和计算量都小,生成一批行号后再去读其他列,整个过程和列存原理完全一致。

4. 直接查Parquet:省掉导入环节的架构红利

DuckDB最吸引我的一点,就是它把Parquet从"某一种文件格式"提升成了"对外可直接查询的数据源"。过去要分析Parquet数据,要么先导入到数仓或数据库里,要么用Spark的DataFrame API慢慢读。DuckDB直接做到了两个正交的能力:把Parquet当作表来查,以及把查询结果写回Parquet。

4.1 Parquet不是一种"文件格式"那么简单

Parquet是Apache社区下的开源列式存储格式,最初由Twitter和Cloudera推动标准化。它的文件内部结构分三层:文件级别的Footer元数据区、Row Group、以及每个Row Group内部的Column Chunk。每个Column Chunk里存了一列的一段连续数据,并且带有一套统计信息(min/max值、空值数量、字典页等)。

这套结构为DuckDB提供了极大的操作空间。DuckDB读取Parquet时,不是傻乎乎地把整个文件全部解压读入,而是先读取文件尾部的元数据,看了每个Column Chunk的统计信息之后,再来决定哪些部分真正需要读取。一套基于文件结构的"查询裁剪"流程就建立起来了。

我在第一次理解到这一点时,重新看待了Parquet在管道里的位置。Parquet不只是存储格式,它在逻辑上已经具备了一个"小型索引"的能力。文件内部的元数据可以让查询引擎在不用扫描全部数据的前提下跳过大量无关注。这与传统数据库里的分区剪枝颇有相似之处。

4.2 只读需要的部分:谓词下推与元数据过滤

谓词下推在DuckDB里应用得非常彻底。当SQL中的WHERE条件出现对某一列的比较时,DuckDB会把过滤条件下推到扫描算子阶段,直接在读取Column Chunk时检查它的min/max统计。如果某个Row Group的该列最大值都小于条件中的阈值,那整个Row Group直接跳过,连解压都不用做。

举个例子,一个Parquet文件里存了全年订单,按日期字段分区成12个文件。查询条件WHERE order_date >= '2024-06-01',理论上只需要读后半年的文件。但如果你的Parquet没有做物理分区,数据混在同一个文件里,只要文件内的Row Group按日期有序,min/max统计同样可以跳过前面六个月对应的某个范围内Row Group,效果不亚于分区。

除了min/max,Parquet还支持Bloom Filter和字典过滤。字典编码的列在做等值条件查询时,可以先查字典页,如果值不在字典里,整块直接跳过。这套"多级过滤"机制是Parquet性能的重要来源,也是DuckDB能够直查大数据量的底气之一。

4.3 实际跑一跑:从查询到导出

直接查Parquet的语法非常简单,核心就是把文件名或通配符当作表名传入:

-- 查单个文件 SELECT * FROM 'data/orders.parquet'; -- 查一个目录下的所有Parquet分片 SELECT region, count(*) AS order_cnt, sum(amount) AS total_amount FROM 'data/sales/*.parquet' WHERE date >= '2024-01-01' GROUP BY region ORDER BY total_amount DESC;

DuckDB支持Glob通配符,*会匹配任意文件名,**可以递归匹配子目录。多文件查询时,DuckDB会自动做文件级别的并行扫描,把不同文件分给不同线程。不需要提前把文件合并,这对我来说非常省事。

同样重要的是把查询结果写回Parquet:

COPY ( SELECT region, count(*) AS order_cnt, avg(amount) AS avg_amount FROM 'data/sales/*.parquet' GROUP BY region ) TO 'output/summary.parquet' (FORMAT PARQUET);

COPY语句支持查询子句,意味着可以一边做ETL一边输出结果。配合DuckDB的跨格式能力,可以在同一个流程里完成CSV -> 清洗 -> Parquet -> 聚合 -> Parquet的整条链路,不需要启动任何外部服务。

用Python调用也差不多:

import duckdb conn = duckdb.connect() result = conn.execute(""" SELECT region, avg(amount) FROM 'data/sales/*.parquet' GROUP BY region """).df() print(result)

.df()方法把结果直接转成Pandas DataFrame,这就打通了DuckDB和Pandas生态的边界:分析阶段用SQL跑,画图和后续灵活处理用Pandas,两边各取所长。

5. 把DuckDB塞进数据向量化管道

最近几个月我一直在处理RAG相关的工作,热搜词里频繁出现的"RAG向量化流程""Excel向量化""文档向量化"背后,其实都有一个共同的痛点:embedding之前的数据清洗和分块,远比想象中繁琐。我试过很多工具,最后发现DuckDB在这个环节里能扮演一个极其高效的角色。

5.1 数据向量化管道里,最耗时的其实是清洗和筛选

很多人以为向量化管道耗时在embedding环节,真跑起来才知道,embedding是纯计算,GPU或云服务会处理;真正耗时的是把一份Excel里乱七八糟的字段挑出来、去重、过滤空行、格式化日期、拼接文本块。这一步用Pandas写起来逻辑很灵活,但数据量一大,内存就吃紧。而且在一个大表格里按行做字符串拼接,效率并不高。

DuckDB的列式引擎处理这类任务刚刚好。数据量通常在几十万到几百万行,单机完全能扛,SQL的声明式写法让清洗逻辑一目了然,还能用正则表达式和字符串函数直接操作文本列。下面这个场景是我的日常:一个Excel报表文件,包含几千条产品记录,我想把它整理成适合embedding的文本块,每一条是一个完整的结构化描述。

5.2 直接从Excel到可embedding的文本块

DuckDB有读取Excel文件的方式,需要先安装扩展:

import duckdb conn = duckdb.connect() conn.execute("INSTALL excel; LOAD excel;") clean_df = conn.execute(""" SELECT product_id, regexp_replace(name, '\\s+', ' ', 'g') AS cleaned_name, lower(trim(category)) AS category, round(price, 2) AS price, rating, concat( '产品名称:', cleaned_name, '; 品类:', category, '; 价格:', price, '; 评分:', rating ) AS chunk_text FROM read_xlsx('products.xlsx', header=true) WHERE price IS NOT NULL AND category IS NOT NULL AND price > 0 QUALIFY row_number() OVER (PARTITION BY product_id ORDER BY updated_at DESC) = 1 """).df() with open('chunks.txt', 'w', encoding='utf-8') as f: f.write('\n'.join(clean_df['chunk_text'].tolist()))

这段代码里做了几件事:用read_xlsx直接读入Excel文件;用正则替换清理字符串中的多余空白;统一转小写和trim;过滤掉缺失和异常值;用开窗函数的QUALIFY语法按product_id去重,保留最新一条记录。最后生成一个纯文本的chunk列表,可以直接送进embedding接口。

这里的关键点是用SQL的批量字符串处理代替Python里的逐行循环。几十万行数据的清洗和拼接,在DuckDB里是向量化执行的,速度比Pandas的apply快得多,内存占用也低。

5.3 避免N+1次文件读写的架构调整

向量化管道的常见错误是每个环节都生成一份中间文件,读一次、写一次,文件多了之后IO开销反而拖慢整体流程。用DuckDB可以把多个环节合并到一条SQL里完成,或者在一个Python进程内连续查询,数据始终保持在进程内或内存缓冲区,减少不必要的磁盘落盘。

同时,DuckDB可以直接把向量化流程的输入源标准化为Parquet。比如上游团队传过来的是CSV或JSON,先用DuckDB转成Parquet存档,之后所有下游任务都查Parquet而不是原始文件。这个改动不仅让单次查询更快,还让后续每次访问都享受列存和统计信息带来的裁剪红利。

这个架构模式总结下来就是:原始数据落成Parquet,清洗、去重、过滤、拼接文本块由DuckDB的SQL完成,输出依然是Parquet或文本列表,最后交给embedding环节做向量化。整个过程中没有引入重量级框架,也没有复杂的依赖,却解决了我过去用Pandas反复试错才能跑通的事情。

6. 实测中遇到的那些坑

任何工具用久了都会暴露一些细节,DuckDB也不例外。下面这些坑不是从文档里看来的,大多是我实际跑数据时踩过之后才明白的,列出来供你参考。

6.1 内存不像你想的那么"无限制"

DuckDB默认会用所在机器的80%物理内存作为缓冲和计算上限,这在大查询时会显得很吃内存。虽然它是磁盘友好的,但某些聚合和连接操作仍然需要在内存里维护状态。我在一台16GB内存的机器上跑一个多表连接时,DuckDB一度占用接近10GB内存,如果没有限制,反而容易触发OOM。

解决方案是主动设置内存限制,让DuckDB把数据spill到磁盘:

SET memory_limit='4GB'; SET temp_directory='/data/tmp';

这个设置之后,超出内存的部分会落到临时目录,而不是直接杀死进程。代价是查询变慢,但至少能跑完。我的建议是:默认配置适合单机"随便查",生产化使用或跑大查询之前,一定先根据机器内存规划好这两个变量。

6.2 用错了函数,一样全表扫描

列式存储和向量化执行解决的是"数据读得少、算得快",但如果你写出来的SQL在过滤条件里用到了隐式类型转换,DuckDB可能没法把谓词下推到Parquet的元数据阶段。一个典型例子是把日期列和字符串常量比较时类型不一致,优化器无法推断出确切的比较语义,只能读取所有数据再转换过滤。

另一个常见场景是在WHERE里对列包一层表达式,比如WHERE upper(category) = 'BOOKS'。这类写法让优化器无法直接使用category列的字典和min/max信息,只能对整列做函数计算后再比较。性能差距可能非常大。经验法则是:尽量保持过滤条件中的列是"裸列",不要套函数;类型不匹配时先显式CAST,而不是让DuckDB猜。

6.3 多文件处理时的三个细节

用通配符读几十个Parquet文件时,我遇到过几个容易忽略的问题:

  • 假设所有文件结构一致,实际上只要有一个文件缺列或字段顺序不同,查询就会报错。这时候可以用union_by_name参数:read_parquet('data/*.parquet', union_by_name=true),DuckDB会自动按列名合并而不是按位置合并。
  • 文件过多时,每个文件都有启动扫描的开销,小文件数量越多,效率越低。如果一张表被切成几百个只有几MB的小Parquet,建议先用DuckDB把它们合并成更少的文件,再跑分析,性能提升明显。
  • 跨目录扫描时,**通配符递归匹配子目录,但并不会自动识别Hive风格的分区目录。如果文件按/date=2024-01-01/这样组织,DuckDB可以通过hive_partitioning=true参数识别分区列,让查询直接利用目录结构做裁剪,效果更好。

6.4 一些值得养成的习惯

用了一段时间之后,我总结了几条自己的工作习惯。

数据输入层,能转Parquet就转Parquet,不要长期用CSV做分析源;数据细节不确定时,先跑DESCRIBE SELECT * FROM 'xxx.parquet'确认结构和类型,避免后续踩坑;多步骤分析尽量在同一个DuckDB连接里完成,减少冷启动和元数据重复读取。

还有一个关于扩展的提醒:DuckDB的HTTPFS扩展可以让你直接查询S3或HTTP协议下的Parquet文件,INSTALL httpfs; LOAD httpfs;之后,SELECT * FROM 's3://bucket/path/*.parquet'就能用。这个能力在数据管道里非常实用,配合分区裁剪效果很好。不过网络IO毕竟受带宽限制,异地跨云访问时性能波动大,建议还是先同步到本地或内网存储再分析。

从我几个月的使用体验看,DuckDB并不是要替代Pandas或Spark,它更像是填补了它们之间的空白:既有SQL的清晰表达,又有列存和向量化的性能,还不需要运维独立的服务。工具本身不复杂,复杂的是理解它底层的存储和执行逻辑,然后把习惯调整过来。希望这篇文章能让你少踩几个我踩过的坑,尽早把它用顺。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询