数据结构内部结构非官方指南

2020-07-24 23:35:58

免责声明:我没有为Cognitect工作,不幸的是,我还没有看到Datomic的任何源代码。我刚刚通过大量的公开演讲、文档和谷歌小组对数据的回答才做到了这一点。这篇帖子是对这些内容的汇编。其目的是通过了解他人在做什么来帮助他们更有效地使用数据。

数据体将所有数据建模为称为数据的4元组(实体、属性、值、时间)。在“约翰喜欢披萨”中,“约翰”是一个实体,“喜欢”是一个属性,“披萨”是一个值。“数据学”中的每一件事都表现为这样简单的事实。这种简单性使Datomic能够做比任何关系型数据库或KV存储所能做的更多的事情:高效地存储和访问稀疏、不规则、分层、图形数据和多值属性。

DATMIC本身并不管理持久性,相反,它将存储问题外包给其他人实现的数据库。您可以自费将数据保存在DynamoDB、Riak、InfinisPan、Couchbase或SQL数据库中。

存储用于将段存储为二进制BLOB,类似于DB在文件系统上存储块的方式。它基本上是段键(使用UUID)来对正文映射进行分段,因此存储只需要支持两件事:键-值接口和用于更新索引根记录的CAS。段是不可变的,一旦写入,就不能更改。重建索引仅写入新段。这使得一致的读取和不变性成为可能。

没有专用的数据存储;取而代之的是使用覆盖索引。这意味着它们包含实际数据,而不是对可以获得数据的地方的引用。数据读取直接从索引进行,除了索引之外没有其他位置存储数据。其中有一些冗余,因为如果а数据最终在3个索引中,它将被存储3次。但已经部分缓解了。

索引是类似B树的结构:排序的、不可变的、持久的、1,000+分支因子、自定义排序顺序、快速查找和范围扫描,能够有效地合并(细节未知)。索引树很浅,深度不超过三层:根节点、目录和作为叶子的段。

索引树的元素不是单个数据,而是段。段是一个数据数组,用Fressian序列化,然后用zip压缩。数据段大小最大可达~50 KB,通常包括1,000到20,000个数据。压缩可实现更快的访问时间以及更高效的缓存和存储使用。

共有五个索引,它们的覆盖范围不同,并按照使用的排序顺序命名:

EAVT用于高效访问实体的属性,类似于传统数据库中的主键查找。

AEVT用于列式访问,按给定属性检索实体列表。EAVT和AEVT都存储每个数据。

VAET用于向后导航关系,并存储具有引用属性的所有数据。给定VAET,您不仅可以找出John跟踪谁(“John”:跟随?x),而且还可以高效地查找跟随John的人(?x:跟随“John”)。

AVET提供了高效的按值查找,并将属性标记为唯一或索引的数据存储在模式中。这类属性适用于外部ID。AVET是实践中最有问题的索引,如果您能设法将单调值放入其中,或者尽量少用它,那就更好了。

分区只是较大的预先分配的实体ID范围(242)。这样,当您向一个分区添加新实体时,它们不会扰乱与其他分区相关的EAVT索引段。它们还提供语义分组:例如,当查询城市时,您不会仅仅通过获取下一个db宽的顺序id来获得随机发生的交错的内容。

同样的逻辑也适用于属性:最好不要只有一个:Name属性,而是对不同的实体类(:City/Name、:Person/Name、:Transaction/Name等)使用命名空间版本。这样,AEVT和AVET索引更新将获得更好的局部性。

索引无法覆盖的查询(例如,按TX过滤数据库;或按非索引属性的值查找)会导致全面数据库扫描,并向您抛出异常或花费大量时间“思考”。

每个索引在概念上都是一个单元,但是(除Log之外)在技术上被分成三个部分,具有不同的位置和使用模式:

历史部分包含到目前为止已更改或删除的数据,因此不再有效(包括初始断言和后续收回)。历史是经久不衰的,保存在仓库里。

当前部分仅包含最新的断言,即在建立索引时相关的事实。如果“Mary”被重命名为“John”,那么将只包括“John”,作为最新的真实数据。当前部件经久耐用,也保存在仓库中。

内存中的部分包含断言和收回,是短暂的,保存在对等者和交易者的内存中。

内存中部分充当缓冲区,在索引重建之间积累新颖性。新写入的数据将写入日志(命中存储以在此处写入)和事务处理机的内存中索引。然后,事务处理机将该新颖性传播给所有对等点,以确保它们具有相同的内存索引内容。

新的对等点或断开连接的对等点都以空的内存索引开始。它们从构建最新当前索引的位置开始读取Log,并从该信息填充内存中的索引。

对等和交易者通信是基于推送的。每当事务处理机完成事务时,它都会立即通知所有对等点,这样对等点就会尽可能快地知道新数据。对等数据库调用不与事务处理机通信,它返回对等数据库已听说的最新DB值。

现在,我们有了索引,并希望对它们执行查询,对吗?现在正是高效合并持久树的能力派上用场的时候。查询从不从单个索引回答,它们总是咨询两个索引部分,有时是三个索引部分:

为了回答常规查询,当前部分和内存中的部分被合并,给您一种查询最新版本的DB的错觉。

为了回答时刻T的截止查询,合并当前、内存中和历史部分,然后忽略时刻T之后带有时间戳的所有数据。请注意,由于-of查询不需要较旧版本的CURRENT索引,它们使用最新的CURRENT索引并按时间对其进行筛选,从而推断出数据库的先前视图。

通过历史API调用,您可以获得内存中索引部分、当前索引部分和历史索引部分的合并结果。它将包含在数据库生存期内发生的所有断言和收回。你决定如何处理这些信息。

使用最新版本的数据,您还可以访问日志索引(Log index,LOG),这对于查询来说效率不是很高,但可以高效地询问两个时间戳之间的一系列事务。

当内存中索引变得过大(内存索引阈值,例如32Mb)时,事务处理机开始重建当前索引。它是通过合并最新的当前索引和内存中索引来完成的。断言从内存复制到当前索引,过期的断言和收回从内存复制到历史。标记为noHistory的属性中的更改将以静默方式删除。当当前/历史索引重建完成后,对等点和事务处理者将了解它们的新版本,并删除新索引现在涵盖的所有内存中的新奇内容。

从datomic.api/db调用获得的所有数据库始终引用最新的当前索引。一旦重建了当前索引,就无法获得对其旧版本的引用。当前索引的旧版本引用的数据段,如果它们被“更改”,因此不会被最新版本重用,则现在受GC约束。

垃圾段可以清理,但如果某个对等点保留了对其很久以前获得的数据库的引用,则该数据库可能仍在引用旧索引。在大多数情况下,您得到数据库,做一些处理,然后就忘了它。但分析处理或其他长期运行的作业可能会违反这一模式。因此,GC是手动操作,建议很少运行(例如,每周运行一次)。对此有一个API调用,即GC-STORAGE。如果DB引用的旧索引是垃圾收集的,则可以通过获取最新的DB并对其调用As-of来获得等效的DB版本。

GC和重新索引都不是阻塞操作。它们都在后台发生,不会以任何方式中断正常的数据库操作。

运行查询的DB值包含对根索引节点的引用。然后,Peer推断它需要执行查询的段,并到达缺失段的存储。查询可以跨不适合内存的数据集运行(通过在数据库扫描期间加载和卸载缓存段),但是查询结果应该适合内存。查询结果不是懒惰的。

有一个API可用于直接遍历索引(数据和查找数据)。它很懒惰,可以在需要遍历大数据集但不能在查询中表达意图时使用。

事务处理机不参与查询。没有事务处理机连接的对等机仍然可以从存储进行读取。如果已经缓存了所需的段,则将执行查询,而不会进行任何网络读取。

因为段是不可变的,所以缓存它们是不费吹灰之力的,您不需要使其无效( - )。DATMIC支持Memcache用于快速外部段缓存,对等库也支持内存缓存。内存中的对等段维护两种类型的高速缓存:压缩段高速缓存(堆外)和未压缩段高速缓存(在堆上,受object-cache-max限制,例如128M)。

因为缓存发生在段级别,所以当您获取一个属性时,您可能还会获得它周围的1,000-20,000个数据。这意味着,如果您逐个读取实体属性,就不会出现N+1选择问题。这类事情在传统的客户机-服务器数据库中是一个很大的禁忌。

总体而言,底层结构是多么简单和优雅,给我留下了非常深刻的印象。构建块很小,但它们的组合在功能集中令人印象深刻。几乎没有花招和妥协,一切都是直接从核心模型派生出来的。事务处理机和对等机使用相同的基本原语集做非常不同的事情。感觉他们已经从元组、持久排序集和通信队列构建了一个完整的数据库。

我还创建了开源的东西:Fira Code、AnyBar、DataScript和Rum。如果您喜欢我所做的,并希望早日访问我的文章(以及其他好处),您应该在Patreon上支持我。