跳到内容

ArcticDB 磁盘存储格式

ArcticDB 使用了一种自定义存储格式,它不同于 Parquet、HDF5 或其他类似的已知列式存储格式。本页提供了该格式的高层描述。

ArcticDB 存储引擎设计用于与任何键值存储后端协作,并且目前完全支持以下已*优化*的后端

  1. S3
  2. LMDB

存储数据的结构已针对该存储进行了优化——例如,使用 S3 时,所有相关路径(或*键*)共享一个共同前缀,以优化 ListObjects 调用。

尽管有这种针对特定存储的优化,但无论后端存储如何,其层级结构和段格式都保持一致。这些组件将在下面描述。

结构概览

Storage Format Overview

上图提供了该格式的概览,说明了 ArcticDB 如何管理其存储数据的符号元数据、版本历史和索引布局。图中未显示用于存储 ArcticDB 段的二进制格式。

注意

请注意,上图左侧所示的键格式特定于 S3。格式可能因底层存储而异,因此使用其他存储引擎(如 LMDB)时,键路径可能不匹配。

ArcticDB 存储格式由 4 个层组成:引用层版本层索引层,最后是数据层。有关键的完整定义,请参见上图。

引用层

引用层维护一个指向版本层链表头部的活动指针,这使得能够快速检索符号的最新版本。该指针存储在数据段中,如上图所示。因此,引用层是 ArcticDB 存储格式中唯一可变的部分,每个引用层键的值都可以被覆盖(因此使用*引用键*而不是*原子键*)

引用层主要存储类型为*版本引用*的键,如下文所述

版本层

版本层包含一个由不可变原子键/值组成的链表。链表的每个元素在数据段中包含两个指针;一个指向链表中的下一个条目,另一个指向索引键,提供了通往存储结构索引层的路径。因此,遍历符号的版本层链表可以让你回溯时间,检索先前版本/时间点的数据。版本层还包含关于哪些版本已被删除以及哪些版本仍然“存活”的信息。

这意味着对于拥有很多版本的符号,该链表会变得相当长,因此读取旧版本(使用 as_of 参数)会变慢,因为需要进行大量微小的对象读取才能找到相关的索引键。很快将在 Library API 中添加一个方法,允许用户将该链表“压缩”成几个更大的对象。

版本层主要存储类型为*版本*的键,如下文所述

索引层

索引层是一个不可变层,它提供了一个覆盖数据层的 B-树索引。与引用层和版本层非常相似,它利用包含数据指针的数据段。每个指针只是一个包含数据段的键。

有关此层中存储的数据的更多信息,请参见结构概览图

索引层主要存储类型为*表索引*的键,如下文所述

数据层

数据层是一个不可变层,它包含压缩的数据段。用户提供的数据帧按列和行进行切片,以便在读取操作期间快速进行日期范围和列搜索。有关 rows_per_segmentcolumns_per_segment 库配置选项的更多详细信息,请参见文档

数据层主要存储类型为*表数据*的键,如下文所述

ArcticDB 键类型

什么是 ArcticDB 键?

ArcticDB 将数据存储在 LMDB 或对象存储(S3)中。然而,无论后端如何,ArcticDB 都以明确定义的键类型存储数据,其中键类型定义了相关值的目的以及键的名称/路径结构。下面显示了 S3 的路径示例。

请注意,本文档中提及*键类型*键(例如*版本引用*键)时,它指的是 S3/LMDB 键及其相关的 S3/LMDB 值。

键类型

键类型 原子/引用 S3 前缀 用途
版本引用 引用 vref 维护指向符号最新版本的顶层指针
版本 原子 ver 维护指向一个或多个符号版本的索引键的指针
表索引 原子 tindex 维护数据上的索引结构
表数据 原子 tdata 维护表的数据
符号列表 原子 sl 缓存符号的添加/删除

请注意,原子键是不可变的。引用键不是不可变的,因此关联的值可以更新。

版本引用

在本文档中,Version Reference key 有时缩写为 Version Ref key

示例路径

以下路径是基于 S3 的路径示例。LMDB ArcticDB 库的路径可能略有不同。

键类型 示例路径
版本引用 {桶}/{库前缀}/{库存储ID}/vref/sUtMYSYMBOL
符号列表 {桶}/{库前缀}/{库存储ID}/*sSt*__add__*6*1632469542244313925*8986477046003934300*ORJDSG8GU4_6*ORJDSG8GU4_6

附加信息

数据层碎片化

正如结构概览图中的示例所示,如果旧版本数据层中的原子键数据仍被需要,则新版本的索引层会重用(即指向)这些原子键。对于 append 操作来说,这总是显而易见的,而且概念上更简单,因此我们这里使用的示例基于 append。值得注意的是,所有相同的论点也适用于 update

通过重用上一版本的数据层键,append 调用尽可能高效,因为它们无需读取上一版本的任何数据层键。然而,这可能导致数据层变得*碎片化*,即数据层中的数据分散在存储中的许多小对象上,这可能对 read 操作产生负面性能影响。

考虑一个用例:一个包含 10 列、全部为 8 字节数值类型的符号,每分钟追加一次。这意味着每天会写入 1,440 个数据层段,每个段只包含 80 字节的信息。因此,调用 read 读取一天的数据需要读取这 1,440 个微小的数据层对象,这将远不如读取一个 115,200 字节的对象高效。

我们很快将添加一个 API 来执行此操作,重新切片数据,以便后续读取尽可能高效,同时不损害现有 appendupdate 操作的效率。

符号列表缓存

加快列出符号的速度

以下文档详细介绍了符号列表缓存的架构。

它解释说,要加快 list_symbols 的速度,只需经常完整运行 list_symbols。缓存会在第一次运行时构建,之后会进行*压缩*。这将加快库的**所有访问者**的 list_symbols 速度,而**不仅仅是运行 list_symbols 的用户**。

list_symbols 是在 ArcticDB 库上执行的常见操作。由于它返回“存活”符号的列表(即至少有一个版本未被删除的符号),使用上述数据结构,这涉及到

  • 加载库中版本引用键的列表。
  • 对于每个版本引用键
    • 遍历版本键链表,直到找到一个存活的版本,或者确定所有版本都已被删除。

对于许多库来说,这些操作足够快。但是,如果符号数量达到百万级,或者对于某些符号来说,找到的第一个存活版本在版本键链表中非常靠后,那么这种方法可能会非常昂贵。

因此,ArcticDB 维护一个存活符号的缓存,以便 list_symbols 调用总是能快速返回。其工作原理是在存储中写入特殊的原子键,这些键跟踪符号创建或删除的时间以及操作发生的时间。这些符号列表原子键的格式如下:

  • <库前缀>/sl/*sSt*__add__*0*<时间戳 1>*<内容哈希>*<符号名称>*<符号名称> - 表示 <符号名称><时间戳 1> 创建
  • <库前缀>/sl/*sSt*__delete__*0*<时间戳 2>*<内容哈希>*<符号名称>*<符号名称> - 表示 <符号名称><时间戳 2> 删除

列出符号的操作随后涉及

  • 读取库的所有符号列表原子键(对于 S3 存储,这相当于一个 ListObjects 调用)。
  • 根据键所引用的符号是创建还是删除来分离
  • 对于每个符号
    • 检查最近的操作是创建还是删除。

最近操作为创建的符号随后返回给 list_symbols 的调用者。

如果没有进行清理,这个过程可能导致库中符号列表原子键的数量无限增长。更糟糕的是,其中许多键包含冗余信息,因为我们只关心每个符号的最新操作。因此,每当具有库写入权限的客户端调用 list_symbols 时,如果符号列表原子键过多(默认为 500 个,有关如何配置的详细信息请参见运行时配置页面),所有这些键的信息将被压缩到一个单个符号列表原子键中,并删除旧键。例如,如果有 4 个符号列表原子键:

  • <库前缀>/sl/*sSt*__add__*0*t0*<内容哈希>*symbol1*symbol1 在 t0 创建 "symbol1"
  • <库前缀>/sl/*sSt*__delete__*0*t1*<内容哈希>*symbol1*symbol1 在 t1 删除 "symbol1"
  • <库前缀>/sl/*sSt*__add__*0*t2*<内容哈希>*symbol12*symbol2 在 t2 创建 "symbol2"
  • <库前缀>/sl/*sSt*__add__*0*t3*<内容哈希>*symbol1*symbol1 在 t3 创建 "symbol1"

它们将被压缩成一个单一对象,说明 "symbol1" 和 "symbol2" 在 t3 时都处于存活状态。此对象的键形式为 <库前缀>/sl/*sSt*__symbols__*0*t3*<内容哈希>*0*0

敏锐的观察者会正确地指出,在一个纯客户端数据库中,如果不同客户端的时钟之间没有强制同步,则基于时间戳进行逻辑决策存在问题。因此,如果两个不同的客户端在大致相同的时间创建和删除同一个符号,此缓存可能会与实际情况不符,这很可能是出现奇怪行为的最常见原因,例如 lib.read(symbol) 可以工作,但 symbol in lib.list_symbols() 返回 False。如果发生这种情况,应运行 lib.reload_symbol_list() 来解决问题。