DuckDB 中的轻量级压缩

Author Avatar
Mark Raasveldt
2022-10-28 · 15 分钟

TL;DR: DuckDB 支持高效的轻量级压缩,该压缩会自动使用以减小数据大小,而不会产生高昂的压缩和解压缩成本。

Matroshka Ducks (ducks going from big to small)

处理大量数据时,压缩对于减少存储大小和出站成本至关重要。压缩算法通常能将数据集大小减少 75-95%,具体取决于数据的可压缩性。压缩不仅能减少数据集的存储占用空间,通常还能 提高性能,因为需要从磁盘或通过网络连接读取的数据更少。

列式存储格式,如 DuckDB 的原生文件格式或 Parquet,尤其受益于压缩。这是因为单个列内的数据通常非常相似,可以被压缩算法有效地利用。以行式格式存储数据会导致不同列的数据交错,从而降低压缩率。

DuckDB 在去年年底添加了对压缩的支持。如下表所示,DuckDB 的压缩比自那时以来持续改进,并且仍在积极改进中。在这篇博客文章中,我们将讨论 DuckDB 中的压缩工作原理,以及我们在为 DuckDB 存储格式实现压缩时所做的设计选择和各种权衡。

版本 出租车 准时 行项目 备注 日期
DuckDB v0.2.8 15.3 GB 1.73 GB 0.85 GB 未压缩 2021 年 7 月
DuckDB v0.2.9 11.2 GB 1.25 GB 0.79 GB RLE + 常量 2021 年 9 月
DuckDB v0.3.2 10.8 GB 0.98 GB 0.56 GB 位打包 2022 年 2 月
DuckDB v0.3.3 6.9 GB 0.23 GB 0.32 GB 字典 2022 年 4 月
DuckDB v0.5.0 6.6 GB 0.21 GB 0.29 GB FOR 2022 年 9 月
DuckDB 开发版 4.8 GB 0.21 GB 0.17 GB FSST + Chimp 现在
CSV 17.0 GB 1.11 GB 0.72 GB    
Parquet (未压缩) 4.5 GB 0.12 GB 0.31 GB    
Parquet (Snappy) 3.2 GB 0.11 GB 0.18 GB    
Parquet (ZSTD) 2.6 GB 0.08 GB 0.15 GB    

压缩简介

从核心来看,压缩算法试图在数据集中找到模式,以便更巧妙地存储它。因此,数据集的 可压缩性 取决于是否可以找到这些模式,以及它们是否首先存在。遵循固定模式的数据可以显著压缩。没有模式的数据,例如随机噪声,则无法压缩。正式地,数据集的可压缩性被称为其

作为这个概念的例子,让我们考虑以下两个数据集。

Example data set with predictable and noisy data

常量数据集可以通过简单地存储模式的值和模式重复的次数(例如,1x8)来压缩。另一方面,随机噪声没有模式,因此不可压缩。

通用压缩算法

大多数人熟悉的压缩算法是通用压缩算法,例如 zipgzipzstd。通用压缩算法通过查找比特中的模式来工作。因此,它们与数据类型无关,可以用于任何比特流。它们可以用于压缩文件,也可以应用于通过套接字连接发送的任意数据。

通用压缩灵活且易于设置。有许多高质量的库可用(如 zstd、snappy 或 lz4),它们提供压缩功能,并且可以应用于以任何方式存储的任何数据集。

通用压缩的缺点是(解)压缩通常成本很高。虽然我们在从硬盘或通过慢速互联网连接进行读写时这并不重要,但当数据存储在 RAM 中时,(解)压缩的速度可能成为瓶颈。

另一个缺点是这些库作为黑盒操作。它们在比特流上操作,不向用户透露其内部状态的信息。虽然如果您只是想减小数据大小,这不是问题,但它阻止了系统在执行过程中利用压缩算法发现的模式。

最后,通用压缩算法在压缩大数据块时效果更好。如下表所示,当压缩少量数据时,压缩率会显著降低。为了获得良好的压缩率,必须使用至少 256 kB 的块。

压缩 1 kB 4 kB 16 kB 64 kB 256 kB 1 MB
zstd 1.72 2.1 2.21 2.41 2.54 2.73
lz4 1.29 1.5 1.52 1.58 1.62 1.64
gzip 1.7 2.13 2.28 2.49 2.62 2.67

这很重要,因为块大小是从磁盘读取单行时必须解压缩的最小数据量。更糟的是,由于 DuckDB 是按列压缩数据,块大小将是每列必须解压缩的最小数据量。块大小为 256 kB 时,获取单行可能需要解压缩数兆字节的数据。这可能导致获取少量行的查询(例如 SELECT * FROM tbl LIMIT 5SELECT * FROM tbl WHERE id = 42)产生显著成本,尽管表面上看起来非常廉价。

轻量级压缩算法

实现压缩的另一个选择是使用专门的轻量级压缩算法。这些算法也通过查找数据中的模式来工作。然而,与通用压缩不同,它们不试图在比特流中找到通用模式。相反,它们通过在数据集中查找 特定模式 来操作。

通过检测特定模式,专门的压缩算法可以显著更轻量级,提供更快的压缩和解压缩速度。此外,它们可以在更小的数据量上有效工作。这允许我们一次解压缩几行,而不是需要一次解压缩大量数据块。这些专门的压缩算法还可以提供对随机查找的有效支持,从而显著加快通过索引进行的数据访问。

轻量级压缩算法还为我们提供了对压缩过程更精细的控制。这尤其与我们相关,因为 DuckDB 的文件格式使用固定大小的块,以避免涉及删除和更新的工作负载的碎片化。精细控制允许我们更有效地填充这些块,并避免猜测多少压缩数据将适合缓冲区。

另一方面,如果数据中没有出现它们设计的特定模式,这些算法就会失效。因此,单独来看,这些轻量级压缩算法不能替代通用算法。相反,必须结合多个专用算法,以捕获数据集中许多不同的常见模式。

压缩框架

由于上述优点,DuckDB 只使用专门的轻量级压缩算法。由于这些算法在数据的不同模式下都能最佳地工作,DuckDB 的压缩框架必须首先决定使用哪种算法来存储每列的数据。

DuckDB 的存储将表分割成行组(Row Groups)。这些是包含 120K 行的组,以称为列段(Column Segments)的列式块存储。这种存储布局类似于 Parquet – 但有一个重要的区别:列被分割成固定大小的块。做出此设计决策是因为 DuckDB 的存储格式支持对存储格式进行原地 ACID 修改,包括删除和更新行,以及添加和删除列。通过将数据划分为固定大小的块,这些块在不再需要时可以轻松重复使用,从而避免碎片化。

Visualization of the storage format of DuckDB

压缩框架在单个列段的上下文中操作。它分为两个阶段。首先,对列段中的数据进行分析。在此阶段,我们扫描段中的数据并找出该特定段的最佳压缩算法。之后,执行压缩,并将压缩后的数据写入磁盘上的块中。

虽然这种方法需要对段内数据进行两次遍历,但这并不会产生显著的成本,因为一个段中存储的数据量通常足够小,可以放入 CPU 缓存中。分析步骤也可以考虑采样方法,但总的来说,我们更看重选择最佳压缩算法和减少文件大小,而不是压缩速度的微小提升。

压缩算法

DuckDB 实现了几种轻量级压缩算法,我们正在向系统中添加更多算法。我们将在以下部分介绍其中的一些压缩算法及其工作原理。

常量编码

常量编码是 DuckDB 中最直接的压缩算法。当列段中的每个值都相同时,使用常量编码。在这种情况下,我们只存储那个单一值。此编码如下所示。

Data set stored both uncompressed and with constant compression

在适用情况下,这种编码技术能带来巨大的空间节省。虽然这种技术可能看起来很少适用——但实际上它发生的频率相对较高。列可能充满 NULL 值,或者具有很少更改的值(例如,传感器数据流中的 year 列)。由于这种压缩算法,此类列在 DuckDB 中几乎不占用空间。

游程编码 (RLE)

游程编码 (RLE) 是一种利用数据集中重复值进行压缩的算法。它不是存储单个值,而是将数据集分解成一对(值,计数)元组,其中计数表示该值重复的次数。此编码如下所示。

Data set stored both uncompressed and with RLE compression

当数据中存在大量重复值时,RLE 非常强大。当数据按特定属性排序或分区时,可能会出现这种情况。它也适用于包含许多缺失(NULL)值的列。

位打包

位打包是一种压缩技术,它利用了整数值很少跨越其数据类型完整范围的事实。例如,四字节整数值可以存储从负二十亿到正二十亿的值。通常不会使用此数据类型的完整范围,而是只存储小数字。位打包通过在存储值时删除所有不必要的开头零来利用这一点。下面提供了一个示例(十进制)。

Data set stored both uncompressed and with bitpacking compression

对于位打包压缩,我们跟踪每 1024 个值的最大值。最大值决定了位打包宽度,即存储该值所需的位数。例如,当存储一组最大值为 32 的值时,位打包宽度为 5 位,低于存储未压缩的四字节整数所需的每值 32 位。

位打包在实践中非常强大。它也方便用户——因为由于这项技术,使用各种整数类型之间没有存储大小差异。一个 BIGINT 列将与一个 INTEGER 列占用完全相同的空间。这减轻了用户担心选择哪种整数类型的烦恼。

参考帧

参考帧编码是位打包的扩展,其中我们还包含一个帧。帧是值集中找到的最小值。值以相对于该帧的偏移量存储。下面给出了一个示例。

Data set stored both uncompressed and with FOR compression

虽然这乍一看可能不那么有用,但在存储日期和时间戳时却非常强大。这是因为日期和时间戳在 DuckDB 中存储为 Unix 时间戳,即自 1970-01-01 以来的偏移量,可以是天数(日期)或微秒数(时间戳)。当我们有一组日期或时间戳值时,绝对数字可能非常高,但这些数字彼此之间都非常接近。通过在位打包之前应用一个帧,我们通常可以极大地提高压缩比。

字典编码

字典编码通过将常见值提取到单独的字典中,然后用对该字典的引用替换原始值来工作。下面提供了一个示例。

Data set stored both uncompressed and with Dictionary compression

字典编码在存储包含许多重复条目的文本列时特别高效。大得多的文本值可以被小的数字代替,这些小数字又可以有效地进行位打包。

FSST

快速静态符号表压缩是字典压缩的扩展,它不仅提取整个字符串的重复,还提取字符串内部的重复。当存储本身是唯一的但字符串内部有大量重复(例如 URL 或电子邮件地址)的字符串时,这种方法很有效。下面是说明其工作原理的图像。

Data set stored both uncompressed and with FSST compression

有兴趣了解更多信息的人,请在此处观看 Peter Boncz 的演讲

Chimp & Patas

Chimp 是一种非常新的压缩算法,旨在压缩浮点值。它基于 Gorilla 压缩。Gorilla 和 Chimp 的核心思想是,浮点值进行异或运算后,似乎会产生带有许多尾随和前导零的小值。然后这些算法致力于寻找一种有效存储尾随和前导零的方法。

在实现了 Chimp 之后,我们受到了启发并着手实现 Patas,它采用了许多相同的思想,但进一步优化以获得更高的解压缩速度。敬请期待未来的博客文章,很快会更详细地解释这些内容!

检查压缩

可以使用 PRAGMA storage_info 来检查表和列的存储布局。这可以用来检查 DuckDB 为压缩表的特定列选择了哪种压缩算法。

SELECT * EXCLUDE (column_path, segment_id, start, stats, persistent, block_id, block_offset, has_updates)
FROM pragma_storage_info('taxi')
USING SAMPLE 10 ROWS
ORDER BY row_group_id;
行组 ID 列名 (column_name) 列 ID 段类型 计数 压缩
4 额外信息 (extra) 13 FLOAT 65536 Chimp
20 小费金额 15 FLOAT 65536 Chimp
26 上车纬度 6 有效性 65536 常量
46 过路费金额 16 FLOAT 65536 RLE
73 存储转发标记 8 有效性 65536 未压缩
96 总金额 17 有效性 65536 常量
111 总金额 17 有效性 65536 常量
141 上车时间 1 时间戳 52224 位打包
201 上车经度 5 有效性 65536 常量
209 乘客数量 3 TINYINT 65536 位打包

结论与未来目标

压缩在 DuckDB 中取得了巨大的成功,我们在降低系统存储需求方面取得了长足进步。我们仍在积极努力扩展 DuckDB 内的压缩功能,并希望通过改进现有技术和实现其他几种技术,进一步提高系统的压缩比。我们的目标是实现在使用 Parquet 和 Snappy 时同等水平的压缩,同时仅使用操作速度非常快的轻量级专用压缩技术。