ArcticDB 磁盘存储格式¶
ArcticDB 使用了一种自定义存储格式,它不同于 Parquet、HDF5 或其他类似的已知列式存储格式。本页提供了该格式的高层描述。
ArcticDB 存储引擎设计用于与任何键值存储后端协作,并且目前完全支持以下已*优化*的后端
- S3
- LMDB
存储数据的结构已针对该存储进行了优化——例如,使用 S3 时,所有相关路径(或*键*)共享一个共同前缀,以优化 ListObjects
调用。
尽管有这种针对特定存储的优化,但无论后端存储如何,其层级结构和段格式都保持一致。这些组件将在下面描述。
结构概览¶
上图提供了该格式的概览,说明了 ArcticDB 如何管理其存储数据的符号元数据、版本历史和索引布局。图中未显示用于存储 ArcticDB 段的二进制格式。
注意
请注意,上图左侧所示的键格式特定于 S3。格式可能因底层存储而异,因此使用其他存储引擎(如 LMDB)时,键路径可能不匹配。
ArcticDB 存储格式由 4 个层组成:引用层、版本层、索引层,最后是数据层。有关键的完整定义,请参见上图。
引用层¶
引用层维护一个指向版本层链表头部的活动指针,这使得能够快速检索符号的最新版本。该指针存储在数据段中,如上图所示。因此,引用层是 ArcticDB 存储格式中唯一可变的部分,每个引用层键的值都可以被覆盖(因此使用*引用键*而不是*原子键*)
引用层主要存储类型为*版本引用*的键,如下文所述。
版本层¶
版本层包含一个由不可变原子键/值组成的链表。链表的每个元素在数据段中包含两个指针;一个指向链表中的下一个条目,另一个指向索引键,提供了通往存储结构索引层的路径。因此,遍历符号的版本层链表可以让你回溯时间,检索先前版本/时间点的数据。版本层还包含关于哪些版本已被删除以及哪些版本仍然“存活”的信息。
这意味着对于拥有很多版本的符号,该链表会变得相当长,因此读取旧版本(使用 as_of
参数)会变慢,因为需要进行大量微小的对象读取才能找到相关的索引键。很快将在 Library
API 中添加一个方法,允许用户将该链表“压缩”成几个更大的对象。
版本层主要存储类型为*版本*的键,如下文所述。
索引层¶
索引层是一个不可变层,它提供了一个覆盖数据层的 B-树索引。与引用层和版本层非常相似,它利用包含数据指针的数据段。每个指针只是一个包含数据段的键。
有关此层中存储的数据的更多信息,请参见结构概览图。
索引层主要存储类型为*表索引*的键,如下文所述。
数据层¶
数据层是一个不可变层,它包含压缩的数据段。用户提供的数据帧按列和行进行切片,以便在读取操作期间快速进行日期范围和列搜索。有关 rows_per_segment
和 columns_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 来执行此操作,重新切片数据,以便后续读取尽可能高效,同时不损害现有 append
或 update
操作的效率。
符号列表缓存¶
加快列出符号的速度
以下文档详细介绍了符号列表缓存的架构。
它解释说,要加快 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>
删除
列出符号的操作随后涉及
最近操作为创建的符号随后返回给 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()
来解决问题。