DuckDB 列存储引擎
执行引擎算得飞快,但如果每个 chunk 都要从磁盘现读,快也没用。本篇讲存储引擎:DuckDB 怎么把 600 万行交易流水装进 280 MB 文件、怎么让 5,000 万个连续整数只占 768 KB、以及内存只有数据三分之一时会发生什么。这一层的关键词只有一个:表示法。
先讲本文的核心案例,它比任何架构图都更能说明存储设计的水平。作者在本机构建版上执行了三条命令:
CREATETABLE t(i INT);
INSERTINTO t SELECT * FROMrange(50000000);
CHECKPOINT;
然后看了一眼文件大小:768 KB。5,000 万个 32 位整数,按未压缩算是 200 MB,按普通列存压缩(BitPacking 到 26 bit)也该有 160 MB 量级——而它只有 768 KB,平均每行 15.4 个二进制位,而且 count、sum、min、max 全部查询结果正确。
这不是压缩率的胜利,是表示法的胜利。要理解它,得从 DuckDB 的五级存储层级走起。
五级层级:文件、块、行组、段、组
数据库文件是单文件,头部 4 KB 的 DatabaseHeader 记录着全部家当:检查点迭代数、元数据块指针、空闲块链表指针、块数量、块分配尺寸、向量尺寸、存储兼容版本。没有 WAL 时它自包含;有 WAL 时配套一个 .wal 文件(第 07 篇详述)。
块(Block)是分配单位,默认 256 KB(DEFAULT_BLOCK_ALLOC_SIZE,必须是 2 的幂),块头 8 字节;启用加密时头部膨胀到 40 字节(AES-256,1.4 引入)。删除产生的空洞进空闲链表,由后续写入回收。
行组(RowGroup)是执行层的抓手:默认 122,880 行——正好 60 个 2048 的向量,这个数字同样不是拍的,它让"行组"和"向量"在编译期就对齐(源码里有静态断言:行组尺寸必须是向量尺寸的整数倍)。zone map 剪枝、列裁剪、扫描调度都以行组为粒度。实测 TPC-H SF1 的 lineitem(600 万行)切成 49 个行组,最后一个不满。
段(Segment)是列存的落点:行组内每一列两段——数据段和有效性段(记录哪些位置是 NULL)。每段头部携带自己的统计:最小最大值(zone map 的原料)、字符串总长度、是否有 Unicode 等。实测 lineitem 行组 0 有 38 个段:16 列 × 2,其中 comment 列因为超出段尺寸上限被自动拆成 7 个数据段。
组(Group)是最小压缩单位:段内按 2048 个值分组,每组一段 4 字节元数据——8 位模式加 24 位偏移。同一个段里,不同组可以采用不同压缩模式。这就是"自适应"的粒度。
压缩家族:每个 2048 值组自己选模式
写入时 DuckDB 分析每个 2048 值组的特征,从压缩家族里挑一个,把模式写进组的元数据;扫描时按模式解码。家族成员按数据类型分四路:
整数路的主角是 BitPacking 家族,但它的内涵已经超出"位打包"。源码 bitpacking.hpp 里的枚举是四种模式:CONSTANT(组内全同值)、CONSTANT_DELTA(组内是等差数列)、DELTA_FOR(先求差值再对差值做帧参照位压缩)、FOR(经典帧参照位压缩)。RLE 伺候连续重复值,Roaring 压缩稀疏位图。前面 768 KB 的谜底就在这里:连续整数在每个 2048 组里都是公差 1 的等差数列,整组落入 CONSTANT_DELTA——只存起点和步长,O(1) 描述 2048 个值;扫描时也不落全量,而是以 SEQUENCE 向量形态惰性物化(第 05 篇的六种向量之一)。
浮点路是三篇论文的工程化:Chimp(XOR 编码)、Patas(打补丁的帧参照)、以及 CWI 自家的 ALP/ALPRD(自适应小数压缩,利用多数浮点只需少数有效小数位的特点)。浮点列在 TPC-H 的 price、discount、tax 上常见 ALP 与 BitPacking 混编。
字符串路是 Dictionary(低基数转小整数)、DictFSST(字典叠加 FSST 符号表,TPC-H 实测默认选择)、FSST(长文本高基数)。字符串的压缩不只是省空间:扫描产出的字典向量让分组和比较直接在小整数上进行——第 02 篇 EXPLAIN 里那些 __internal_compress_string_* 内部函数就是这条通路。
通用兜底是 ZSTD(拿 CPU 换空间)、Uncompressed(小数据不值得压)、Constant(无 NULL 列的有效性段)、Empty(全空列)。
压缩是默认行为,没有开关要拧;真要拧,pragma_storage_info 把每一段的模式、统计、块位置全部摊开——本文所有"实测"数字都来自这张表。
Zone map:剪枝的原料
每段的 min/max 统计在查询时发挥作用:扫描一个行组前,先用过滤条件对照 zone map,整个行组的某列范围与条件无交集就整组跳过。这就是 RowGroupPruner 优化步骤(第 04 篇流水线的一员)的数据基础。作者在 SF1 上实测:宽范围过滤(如 shipdate 跨度六年)剪不掉任何行组(49/49 全扫),而点查类条件可以剪掉绝大多数。zone map 是粗粒度剪枝,别指望它替代索引——这也是第 10 篇要讨论的 DuckDB 边界之一。
内存外:当数据比内存大
存储引擎与执行引擎在 BufferManager 会师:所有块进出都经过它,内存占用受 memory_limit 约束(默认系统可用内存的 80%,src/main/config.cpp 的 SetDefaultMaxMemory)。超出预算时不是报错,而是溢出:
作者的实测(SF1 库 280 MB,SET memory_limit='50MB',lineitem JOIN orders 后按 orderkey 分组计数):查询正常完成,profiling JSON 记录 total_bytes_spilled 1.5 MB、peak_temp_dir_size 31.8 MB、peak_buffer_memory 49.8 MB——哈希表和中间结果把一部分分区写进临时目录,执行继续。把限制放到 100 MB,同一查询峰值 67 MB,完全不溢出。"支持超出内存的工作负载"不是宣传语,是一条有实测背书的工程路径——当然,溢出换来的正确性有时延代价,生产上应该先把 memory_limit 设合理。
崩溃恢复:WAL 与检查点的合谋
存储层 durability 的故事分两半。运行中,所有修改先写 WAL(预写日志),commit 标记落盘即持久;后台或退出时,检查点把 WAL 里的变更固化进主文件、然后清空 WAL。作者的实测抓到了完整证据链:
干净退出(CLI 正常结束)自动触发检查点,目录下不残留 .wal文件;用 kill -9模拟崩溃:插入 5,000 万行的进程被杀(无清理机会),重开数据库后数据完整——重放 WAL,然后检查点,.wal消失;wal_autocheckpoint默认 16 MB:WAL 涨到这个规模就自动触发检查点,控制崩溃后的重放长度。
这套设计的代价是存储放大(写放大:WAL 一次、检查点一次),收益是崩溃窗口以毫秒计。对嵌入式场景,这个交换非常划算。
与 Parquet 的对照
DuckDB 读写 Parquet 就像读写自家格式一样原生,两者在行组尺寸上心意相通——写入 Parquet 时默认行组就是 122,880 行。作者对同一份 600 万行 lineitem 做了对照:CSV 773 MB、Parquet(ZSTD)149 MB、Parquet(不压缩)391 MB、DuckDB 库文件 280 MB。几个值得注意的事实:DuckDB 原生格式比 Parquet 压缩后更大——它的优化目标是扫描速度与混合负载,不是极致压缩;而 Parquet 不压缩反而比 DuckDB 库文件大得多,说明 DuckDB 的压缩家族在整型列上收益显著。查询 Parquet 与查询库表走同一条执行路径,区别只在存储插件——这正是第 08 篇扩展机制要讲的。
冷静的部分
存储层有三个必须知道的边界。其一,压缩省空间不省时间:解码有 CPU 成本,随机访问压缩段的代价远高于顺序扫描——DuckDB 的答案是顺序扫描为主、点查靠索引(第 10 篇讨论 ART 与 CREATE INDEX 的现状)。其二,存储格式不跨大版本兼容:2.0 起新格式,旧版读不了新版文件,升级前必须检查数据迁移路径。其三,单文件是特性也是上限:一个库一个文件,好处是随应用分发、备份就是拷文件;上限是单机单文件的治理模型——多写者、分布式写、对象存储上的多实例并发,都要靠 DuckLake 这类外层格式解决(第 08 篇)。
到这里,"算"与"存"都讲完了。下一篇看时间维度:并发读写时,谁看到什么版本,单写者模型如何自洽。