DuckDB 的内存管理
DuckDB 的内存管理
内存是处理海量数据的重要资源。作为高速缓存层,内存可以显著提升查询处理速度。然而,内存资源有限且成本高昂,在处理大型数据集时,通常没有足够的内存来缓存所有必要的数据结构。因此,高效的内存管理对于高性能查询引擎至关重要。一方面,我们必须充分利用内存来提升性能;另一方面,我们也要避免过度使用内存,防止出现内存不足错误,甚至导致可怕的 OOM killer[2] 将进程彻底终结。
DuckDB 致力于在有效利用可用内存的同时,避免出现内存不足的情况:
- 流式执行引擎允许对数据进行分块读取和处理,无需一次性将整个数据集加载到内存中。
- 中间结果的数据可以临时存储到磁盘上,从而释放内存空间,使得 DuckDB 能够处理那些原本会超出可用内存的复杂查询。
- 缓冲区管理器在不超过预设内存限制的情况下,尽可能多地缓存来自已连接数据库的页面数据。
本篇文章将深入探讨 DuckDB 中内存管理的这些方面,并通过示例展示其应用。
流式执行
DuckDB 采用流式执行引擎来处理查询。这意味着数据源(例如数据表、CSV 文件或 Parquet 文件)不会被一次性全部加载到内存中。相反,DuckDB 会分块读取和处理数据。例如,以下查询:
SELECT UserAgent,
count(*)
FROM 'hits.csv'
GROUP BY UserAgent;
在执行时,DuckDB 不会一次性读取整个 CSV 文件,而是分块读取数据,并使用读取到的数据逐步计算聚合结果。这个过程会持续进行,直到读取完整个 CSV 文件,最终计算出完整的聚合结果。
上述示例只展示了一个数据流。实际上,DuckDB 使用多个数据流来实现多线程执行,每个线程处理自己的数据流。不同线程的聚合结果最终会被合并,以计算出最终结果。
流式执行的概念虽然简单,但功能强大,足以在许多简单场景下提供对大于内存的数据集的支持。例如,流式执行可以处理以下情况:
- 计算组数较少的聚合操作
- 将数据从一个文件读取并写入另一个文件(例如,从 CSV 文件读取数据并写入 Parquet 文件)
- 计算数据的 Top-N 排名(其中 N 较小)
值得注意的是,DuckDB 默认采用流式执行方式处理查询,无需进行任何额外设置。
中间结果溢出
尽管流式执行可以处理简单查询中大于内存的数据集,但在许多情况下,仅凭流式执行是不够的。
以上面的例子为例,流式执行之所以能够处理大于内存的数据集,是因为计算的聚合结果非常小,因为与网络请求总数相比,唯一用户代理的数量非常少。因此,聚合哈希表始终保持较小规模,不会超过可用内存量。
然而,如果处理查询所需的中间结果超过了内存容量,流式执行就无法阻止内存不足问题的发生。例如,如果我们按照源 IP 对上一个示例进行分组:
SELECT IPNetworkID,
count(*)
FROM 'hits.csv'
GROUP BY IPNetworkID;
由于唯一源 IP 的数量更多,因此需要维护的哈希表也会更大。如果聚合哈希表的大小超过了内存限制,流式执行引擎就无法有效地防止内存不足问题。
在许多情况下,特别是执行复杂查询时,都可能出现中间结果超出内存的情况。例如:
- 计算包含大量唯一组的聚合操作
- 计算具有大量不同值的列的精确去重计数
- 连接两个都大于内存的表
- 对大于内存的数据集进行排序
- 在大于内存的表上进行复杂的窗口计算
为了解决这个问题,DuckDB 引入了磁盘溢出机制。当中间结果超出内存限制时,DuckDB 会将部分或全部中间数据写入临时目录的磁盘中。尽管磁盘溢出能够处理大于内存的数据集,但它会降低性能,因为需要执行额外的 I/O 操作。因此,DuckDB 会尽量减少磁盘溢出的使用。只有当中间结果的大小超过内存限制时,才会自适应地启用磁盘溢出机制。即使在这种情况下,DuckDB 也会尽可能多地将数据保留在内存中,以最大程度地提升性能。具体的实现方式取决于操作符,其他博客文章中对此有更详细的介绍(聚合,排序[3])。
memory_limit
设置用于控制 DuckDB 允许在内存中保留的数据量。默认情况下,该值设置为系统物理内存的 80%(例如,如果系统拥有 16 GB 内存,则默认值为 12.8 GB)。你可以使用以下命令修改内存限制:
SET memory_limit = '4GB';
可以使用 temp_directory
设置指定临时目录的位置。默认情况下,临时目录位于连接的数据库目录下,并以 .tmp
作为后缀(例如 database.db.tmp
);如果连接的是内存数据库,则后缀仅为 .tmp
。可以使用 max_temp_directory_size
设置限制临时目录的最大大小,默认值为存储临时文件的驱动器上剩余磁盘空间的 90%。你可以根据需要调整这些设置:
SET temp_directory = '/tmp/duckdb_swap';
SET max_temp_directory_size = '100GB';
如果内存使用超过限制且无法使用磁盘溢出(例如,明确禁用了磁盘溢出、临时目录大小超过限制,或者系统限制导致无法对特定查询使用磁盘溢出),DuckDB 将会报告内存不足错误并取消查询。
缓冲区管理器
DuckDB 中内存管理的另一个核心组件是缓冲区管理器。它负责缓存 DuckDB 自身持久化存储中的页面数据。缓冲区管理器的工作原理与中间结果溢出类似,都会尽可能多地将数据保留在内存中,并在需要空间存储其他数据结构时将其移出内存。缓冲区管理器遵守与其他中间数据结构相同的内存限制。当需要为中间数据结构腾出空间时,可以释放缓冲区管理器中的页面数据,反之亦然。
缓冲区管理器与中间结果数据结构有两个主要区别:
- 缓冲区管理器缓存的是已经存在于磁盘上(DuckDB 持久化存储中)的页面数据,因此在将其移出内存时,无需将其写入临时目录。当再次需要这些页面数据时,可以直接从已连接的存储文件中重新读取。
- 查询中间结果具有天然的生命周期,即当查询处理完毕后,就不再需要这些中间结果。而从持久化存储中缓冲管理的页面数据则可以跨查询使用。因此,缓冲区管理器会一直缓存这些页面数据,直到关闭持久化数据库或需要为其他操作释放空间。
缓冲区管理器带来的性能提升取决于底层存储介质的速度。如果数据存储在速度非常快的磁盘上,读取数据的速度就很快,性能提升可能并不明显。但如果数据存储在网络驱动器上或需要通过 HTTP/S3 读取,读取数据就需要进行网络请求,此时缓冲区管理器可以带来显著的性能提升。
分析内存使用情况
DuckDB 提供了一些工具,可以帮助你分析内存使用情况。
duckdb_memory()
函数可以用来查看哪些系统组件正在使用内存。缓冲区管理器使用的内存被标记为 BASE_TABLE
,而查询中间结果则会被划分到不同的组中。
FROM duckdb_memory();
tag | memory_usage_bytes | temporary_storage_bytes |
---|---|---|
varchar | int64 | int64 |
BASE_TABLE | 168558592 | 0 |
HASH_TABLE | 0 | 0 |
PARQUET_READER | 0 | 0 |
CSV_READER | 0 | 0 |
ORDER_BY | 0 | 0 |
ART_INDEX | 0 | 0 |
COLUMN_DATA | 0 | 0 |
METADATA | 0 | 0 |
OVERFLOW_STRINGS | 0 | 0 |
IN_MEMORY_TABLE | 0 | 0 |
ALLOCATOR | 0 | 0 |
EXTENSION | 0 | 0 |
duckdb_temporary_files
函数可以用来查看临时目录的当前内容。
FROM duckdb_temporary_files();
path | size |
---|---|
varchar | int64 |
.tmp/duckdb_temp_storage-0.tmp | 967049216 |
结论
内存管理对于高性能分析引擎至关重要。DuckDB 致力于充分利用可用内存来加速查询处理,同时通过中间结果溢出机制优雅地处理大于内存的数据集。内存管理一直是 DuckDB 积极开发的领域,并且在各个版本中都得到了持续改进 (改进历史)。我们目前正在努力改进涉及多个具有大于内存中间结果的运算符的复杂查询的内存管理。
引用链接
[1] Memory Management in DuckDB: https://duckdb.org/2024/07/09/memory-management.html
[2] OOM killer: https://en.wikipedia.org/wiki/Out-of-memory#Recovery