【问题标题】:BTrees and Disk Persistance树和磁盘持久性
【发布时间】:2016-12-03 06:18:57
【问题描述】:

一段时间以来,我一直致力于为非常大的数据集(大约 1.9 亿)创建索引。我有一个可以插入数据集(通常是对象)/搜索键的 BTree,当我搜索如何将数据保存到磁盘中的文件时,我遇到了这篇令人惊叹的文章 (http://www.javaworld.com/article/2076333/java-web-development/use-a-randomaccessfile-to-build-a-low-level-database.html#resources)。这几乎给了我一个起点。

在这里,他们将字符串键索引到二进制对象(blob)。它们具有文件格式,将其分为 3 个区域,标题(存储索引的起点),索引(存储索引及其对应位置)和数据区域(存储数据)。他们正在使用 RandomAccessFile 来获取数据。

我如何为 btree 定义类似的文件格式。我所知道的是每次读取磁盘,我必须得到一个节点(通常是一个块 512 字节)。关于如何坚持有很多类似的问题,但很难理解为什么我们决定像这个问题(Persisting B-Tree nodes to RandomAccessFile -[SOLVED])那样实现我们实现的东西。请分享您的想法。

【问题讨论】:

    标签: java file indexing b-tree randomaccessfile


    【解决方案1】:

    根据同时已知的问题细节,这是对该问题的另一种看法。本文基于以下假设:

    • 记录数约为 1.9 亿,已修复
    • 密钥是 64 字节的哈希值,例如 SHA-256
    • 值是文件名:可变长度,但合理(平均长度
    • 页面大小 4 KiByte

    数据库中文件名的有效表示是一个不同的主题,此处无法解决。如果文件名很尴尬 - 平均较长和/或 Unicode - 那么哈希解决方案将通过增加磁盘读取计数(更多溢出,更多链接)或减少平均占用率(更多浪费空间)来惩罚您。不过,B-tree 解决方案的反应稍微温和一些,因为在任何情况下都可以构建出最优树。

    在这种情况下,最有效的解决方案 - 并且在很大程度上实现最简单 - 是散列,因为您的密钥已经是完美的散列。取散列的前 23 位作为页码,页面布局如下:

    page header
        uint32_t next_page
        uint16_t key_count
    key/offset vector
        uint16_t value_offset;
        byte key[64];
    
    ... unallocated space ...
    
    last arrived filename
    ...
    2nd arrived filename
    1st arrived filename
    

    值(文件名)从页面末尾向下存储,前缀为 16 位长度,键/偏移向量向上增长。这样,低/高键计数和短/长值都不会导致不必要的空间浪费,就像固定大小的结构一样。您也不必在关键字搜索期间解析可变长度结构。除此之外,我的目标是尽可能简单 - 没有过早的优化。堆的底部可以存储在页眉中,KO.[PH.key_count].value_offset(我的偏好),或者计算为KO.Take(PH.key_count).Select(r => r.value_offset).Min(),无论你最喜欢什么。

    键/偏移向量需要在键上保持排序,以便您可以使用二进制搜索,但值可以在它们到达时写入,​​它们不需要按任何特定顺序。如果页面溢出,则在文件的当前末尾分配一个新的,就像它一样(将文件增加一页)并将其页码存储在适当的标题槽中。这意味着您可以在一个页面内进行二分搜索,但所有链接的页面都需要一个一个地读取和搜索。此外,您不需要任何类型的文件头,因为文件大小是可用的,这是唯一需要维护的全局管理信息。

    将文件创建为稀疏文件,其页数由您选择的散列密钥位数指示(例如 8388608 页 23 位)。稀疏文件中的空页面不占用任何磁盘空间并读取为全 0,这与我们的页面布局/语义完美配合。每当您需要分配溢出页时,将文件扩展一页。注意:这里的“稀疏文件”并不是很重要,因为当您完成构建文件时,几乎所有页面都已写入。

    为了最大限度地提高效率,您需要对数据进行一些分析。在我的模拟中 - 使用随机数作为散列的替代品,并假设平均文件名大小为 62 字节或更小 - 最佳结果是制作 2^23 = 8388608 个桶/页。这意味着您将散列的前 23 位作为要加载的页码。以下是详细信息:

    # bucket statistics for K = 23 and N = 190000000 ... 7336,5 ms
    
    average occupancy 22,6 records
    0 empty buckets (min: 3 records)
    310101/8388608 buckets with 32+ records (3,7%)
    

    这样可以将链接保持在最低限度,平均而言,每次搜索您只需阅读 1.04 页。将哈希键大小增加一位到 24 会将预期的溢出页面数减少到 3,但文件大小会增加一倍,并将平均占用率降低到每页/存储桶 11.3 条记录。将密钥减少到 22 位意味着几乎所有页面 (98.4%) 都会溢出 - 这意味着文件的大小实际上与 23 位相同,但每次搜索必须执行两倍的磁盘读取。

    因此,您会看到对数据进行详细分析以确定用于哈希寻址的正确位数是多么重要。您应该运行使用实际文件名大小并跟踪每页开销的分析,以查看 22 位到 24 位的实际图片是什么样的。这需要一段时间才能运行,但这仍然比盲目地构建数 GB 文件然后发现您浪费了 70% 的空间或搜索平均需要显着超过 1.05 次页面读取要快得多。

    任何基于 B-tree 的解决方案都将涉及更多(阅读:复杂),但由于显而易见的原因,并且即使仅假设有足够数量的内部节点,也无法将每次搜索的页面读取计数减少到 1.000 以下可以缓存在内存中。如果您的系统拥有如此庞大的 RAM,可以在很大程度上缓存数据页面,那么哈希解决方案将与基于某种 B 树的解决方案一样受益。

    尽管我想找个借口来构建一个非常快速的混合基数/B+树,但散列解决方案只需一小部分工作量即可提供基本相同的性能。在这里,B-treeish 解决方案可以超越散列的唯一一点是空间效率,因为为现有 预排序 数据构建最佳树是微不足道的。

    【讨论】:

    • 感谢您的精彩解释。伟大的指标真的很有帮助。我还有一些不清楚的地方。堆的底部是什么意思?是否是未使用的空间(帮助插入下一个键)。在分析部分,如果我的理解是正确的,一个bucket或page可以存储22条记录(key,value)(因为bucket id是页码),只有7%有超过30条记录。所以平均 (8388608-310101) 桶有 1 页来存储他们的记录,而 310101 需要两页来存储他们的记录,因此 1.04 页/搜索。
    • 这些数字很棒。我不能感谢你。我被几件学术上的事情耽误了。多亏了你,现在它变得非常感兴趣。我将开始研究它。另外,我知道我问的更多,但是如果你能指出我正确的方向,你是如何计算出指标的,使用任何字节阅读器工具会很好。这样我可以做一些分析。
    • 还有800万页,我们如何定位准确的页呢?和稀疏文件有什么关系吗?
    • 我是否必须为 RAM 中的键构建哈希表(听起来很合理)。但是有没有其他选择或最好的?
    • 谢谢。我想在完成编码后将其标记为正确。但我想不出更好的办法。
    【解决方案2】:

    有大量的开源键/值存储和完整的数据库引擎 - 请休息一周并开始使用 Google 搜索。即使您最终没有使用它们,您仍然需要研究具有代表性的横截面(架构、设计历史、关键实现细节)以获得对主题的足够概述,以便您可以做出明智的决定并提出明智的问题.如需简要概述,请尝试在 Google 上详细了解索引文件格式,包括 IDX 或 NTX 等历史格式以及各种数据库引擎中当前使用的格式。

    如果您想自己动手,那么您可以考虑加入现有格式的潮流,例如 dBASE 变体 Clipper 和 Visual FoxPro(我最喜欢的)。这使您能够使用现有工具处理数据,包括 Total Commander 插件等。您不需要支持完整格式,只需支持您为项目选择的格式的单个二进制实例。非常适合调试、重新索引、即席查询等。即使您不使用任何现有的库,格式本身也非常简单且易于生成。索引文件格式不是那么简单,但仍然易于管理。

    如果您想从头开始自己开发,那么您还有很长的路要走,因为节点内(页面内)设计和实践的基础知识在 Internet 和文献中很少见。例如,一些旧的 DDJ 问题包含有关与前缀截断(又名“前缀压缩”)相关的有效密钥匹配的文章等,但我目前在“网络”上没有发现任何可比的文章,除了深埋在一些研究论文中或源代码存储库。

    这里最重要的一项是有效搜索前缀截断键的算法。一旦你有了这个,其余的或多或少都会到位。我在网上只找到了一个资源,就是这篇 DDJ(Dr Dobb's Journal)文章:

    很多技巧也可以从像这样的论文中收集到

    对于更多细节和几乎所有其他内容,您可以做的比阅读以下两本书更糟糕(它们都是!):

    后者的替代品可能是

    它涵盖了类似的范围,并且似乎更加亲力亲为,但似乎没有完全相同的深度。不过,我不能肯定地说(我已经订购了一份,但还没有拿到)。

    这些书为您提供了所有相关内容的完整概览,而且它们几乎不含脂肪 - 即您需要了解其中的几乎所有内容。他们会回答你不知道的问题,或者你应该问自己的问题。它们涵盖了整个领域——从 B-tree(和 B+tree)基础知识到详细的实现问题,如并发、锁定、页面替换策略等。它们使您能够利用散布在网络上的信息,例如文章、论文、实施说明和源代码。

    话虽如此,我还是建议将节点大小与架构的 RAM 页面大小(4 KB 或 8 KB)相匹配,因为这样您就可以利用操作系统的分页基础架构,而不是与它发生冲突。而且您最好将索引和 blob 数据保存在单独的文件中。否则,您无法将它们放在不同的卷上,并且数据会妨碍索引页面在不属于您的程序(硬件、操作系统等)的子系统中的缓存。

    我肯定会使用 B+tree 结构,而不是像普通 B-tree 那样用数据稀释索引页。我还建议使用与长度前缀键相关的间接向量(Graefe 有一些有趣的细节)。将密钥视为原始字节,并将所有排序规则/规范化/上下废话排除在您的核心引擎之外。用户可以根据需要为您提供 UTF8 - 您不必关心这些,相信我。

    在内部节点中仅使用后缀截断(即区分“John Smith”和“Lucky Luke”,“K”或“L”与给定键一样有效)并且仅叶子中的前缀截断(即,您存储 'John Smith' 和 7+'ythe' 而不是 'John Smith' 和 'John Smythe')。

    它简化了实现,并为您提供了大部分可能获得的效果。 IE。共享前缀往往在叶级别(按索引顺序在相邻记录之间)非常常见,但在内部节点中并不常见,即在更高的索引级别。相反,叶子无论如何都需要存储完整的密钥,因此没有任何东西可以截断和丢弃,但内部节点只需要路由流量,并且您可以在页面中放置比非截断键更多的截断键。

    与充满前缀截断键的页面进行键匹配非常有效 - 平均而言,每个键的比较少于一个字符 - 但它仍然是线性扫描,即使所有基于跳过计数的向前跳跃。这在一定程度上限制了有效页面大小,因为二进制搜索在面对截断键时更加复杂。格雷夫对此有很多细节。启用更大节点大小(数千个而不是数百个)的一种解决方法是将节点布局为具有两层或三层的迷你 B 树。它可以使事情变得快如闪电(特别是如果您遵守诸如 64 字节缓存行大小之类的魔法阈值),但它也会使代码变得非常复杂。

    我会选择简单的精益设计(范围类似于 IDA's key/value 商店),或者使用现有的产品/库,除非您正在寻找新的爱好...

    【讨论】:

    • 非常感谢您进行如此详细的讨论。现在我明白为什么谷歌无法帮助我了。是的,B+ 树要好得多,我也不存储 blob,因为它使一切变得复杂(所以只是键值对)。现在感谢您,我将研究 dbase 文件格式(将极大地帮助我进行调试,我在 .dat 文件中苦苦挣扎)并且截断键似乎是个好主意。
    • @Anandan:我重新找到了 DDJ 文章并添加了一个链接,还有一个关于 DB2 索引的有趣论文的链接。搜索有关紧凑型 Visual FoxPro 索引 (CDX) 的信息以开始使用,也许可以在 Ebay 上获得一份 Visual FoxPro 8 或更高版本的副本。玩得开心!
    • @Anandan:如果你真的有兴趣,那么我可以在下周末做点什么(反正我早就打算这么做了)。只是一些基本的间接向量,堆和前缀截断,作为试验节点布局等的基线。至少我会发现二进制搜索在截断键重组方面的实际表现如何......我猜想它可能有利于由大节点大小(例如 4 KB)启用的大密钥计数,但猜测和知道是两件不同的事情。;-)
    • 真的很想得到任何这样的指点,非常感谢您的兴趣。我的方法很简单。我正在使用 B+Tree 和 RandomFileAccess(最初可能是批量加载)。一种方法是在这个站点 (people.cs.clemson.edu/~wayne/cpsc241/spring01/assignments/…)。有很多方法,但您的链接(关于使用 Zip - DDJ 链接)非常有帮助。我还没有开始编码(当然除了一些带有文件的基本 B+Tree)。因为这个话题很像兔子洞。我一直在挖掘。
    • 我的密钥大小是 64 字节,经过一些计算,如果我使用我给出的链接中给出的逻辑,我可以容纳大约 58 个密钥(对于 4K 页面)。
    猜你喜欢
    • 1970-01-01
    • 2016-09-02
    • 2011-02-15
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 2018-03-28
    相关资源
    最近更新 更多