发布于 2026-01-06 0 阅读
0

如果用 Rust 编写 SQLite 会是什么样子?——第三部分 数据结构是优秀程序的关键。那么,让我们开始吧!首先,我们来谈谈二叉搜索树。什么是 B 树?为什么需要它?总结

如果用 Rust 编写 SQLite 会是什么样子?——第三部分

数据结构是优秀程序的关键。

那么,我们开始吧!

我们先来谈谈二叉搜索树。

什么是B树?以及为什么需要B树?

概括

用 Rust 从零开始编写一个SQLite克隆版本

← 第 0 部分 — 概述

← 第一部分 — 了解 SQLite 并设置 CLI 应用程序和 REPL

← 第二部分 — SQL 语句和元命令解析器 + 错误处理

在 GitHub 上查看(非常欢迎提交 pull request)

好了,我们正一步步接近目标。我们的目标是创建一个基于 SQLite 的简单关系型数据库,这意味着它需要嵌入到一个单独的文件中。这次,除了之前文章中提到的研究之外,我还花了很多时间研究所有与 SQLite 相关的内容,例如:文档代码库相关讨论

自本系列最后一章以来,我还花了很多时间去了解关系数据库中使用的数据结构的基本原理,它们在 SQLite 中的使用方式,以及在 SQLRite 项目中采用它们的最佳方法

数据结构是优秀程序的关键。

替代文字

以下是林纳斯·托瓦兹在2006年的一段引言

我非常提倡围绕数据设计代码,而不是反过来,我认为这正是 Git 能够取得相当成功的原因之一……事实上,我认为优秀程序员和糟糕程序员的区别在于,前者更重视代码本身,还是更重视数据结构。 糟糕的程序员只关注代码,而优秀的程序员则关注数据结构及其相互关系。

如果你还需要更多,这里还有罗布·派克在1989年写的一篇文章

数据才是关键。如果你选择了正确的数据结构并妥善组织了数据,算法几乎总是显而易见的。 数据结构,而非算法,才是编程的核心。

我认为他们在软件方面确实很有见地,你同意吗?总之,我的理解是,好的数据结构能让代码的设计和维护变得轻松,而世界上最好的代码也无法弥补糟糕的数据结构。我认为这和我第一部分提到的“只要花足够的时间规划,编码就很容易”也相呼应。

因此,在本系列的这一章中,我们将深入探讨数据库设计中使用的主要数据结构,希望我们能够更好地理解为什么使用它们。

那么,我们开始吧!

我首先想探讨的是一种名为B 树的数据结构,它是现代数据库设计的关键组成部分。

我们先来谈谈二叉搜索树。

传统上,排序地图一直是二叉搜索树(BST)的专属领域。关于二叉搜索树的文献、实现和在教育领域的推广可谓汗牛充栋。它们不仅引人深思,而且在理论和实践上都具有极高的应用价值,更拥有数以百万计的变体,足以满足各种需求。

二叉搜索树的基本思想是,树中的每个元素都对应一个单独的节点。每个节点都有两个指向子节点的指针,即左子节点和右子节点。左侧子树中的节点必须包含小于其父节点的元素,而右侧子树中的节点必须包含大于其父节点的元素。这使得搜索过程相当简单:从树的“根节点”开始,将该节点中的元素与搜索键进行比较。然后递归地搜索相应的左子树或右子树(如果找到完全匹配的元素则停止搜索)。

替代文字

二叉搜索树有几种“类型”。例如,如果你使用红黑树,那么每个基本操作(搜索、插入、删除)的最坏情况时间复杂度都是对数级的,这非常棒!

但就计算机的实际运行方式而言,BST 存在一些严重的实际问题。许多应用程序,例如数据库和文件系统,其性能瓶颈并非在于 CPU 执行指令的速度,而是在于它访问磁盘数据的速度。归根结底,如今的 CPU 运行速度如此之快,以至于它们处理的数据必须速度极快且距离 CPU 非常近才能真正发挥作用。光速终究有限!此外,还需要考虑 CPU 可用的缓存,其缓存层级从“非常小但速度非常快”到“非常大但速度(相对而言)非常慢”不等

缓存是如何工作的?简单来说……

缓存通常基于时空局部性假设工作。理想情况下,你需要的数据应该尽可能地彼此靠近。例如,如果你现在正在处理位于位置 A 的数据,理想情况下,你希望接下来的数据也位于位置 A 附近。一个很好的例子就是遍历数组:你想要的下一个数据实际上就在上一个数据旁边。由此可见,选择合适的数据结构至关重要。

这种假设通常大致是这样实现的:当请求内存中的 A 位置时,CPU 会检查它是否在最快的缓存中。如果在,那就太好了(缓存命中)!如果不在,那就糟了(缓存未命中),需要检查下一级(速度较慢、容量更大的)缓存。最坏的情况下,缓存会直接进入计算机的 RAM(或者更糟,直接进入磁盘!)。找到数据后它会被添加到之前所有(容量较小、速度更快的)缓存级别,以及它周围的一些数据。然后,这个操作会驱逐一些被认为不太可能需要的数据。具体原理不在本文讨论范围内,但重点是:缓存命中速度很快,所以我们需要以时空局部的方式访问数据。为了更好地理解其规模,可以参考以下数据

我们一开始聊的是二叉搜索树(BST),结果聊到了CPU和缓存命中/未命中。但是二叉搜索树是如何访问数据的呢?基本上是随机访问。树中的每个节点都与其他节点独立分配内存。因此,与数组不同,每个节点都位于内存中一个独立的区域,搜索操作实际上是一系列随机访问。粗略估计,每次从一个指针指向另一个指针时,都可能发生缓存未命中。真是糟糕!(就像荷兰人会说的那样!)

二叉搜索树实际上内存效率非常低。每个节点都有两个指针指向树中的每个元素。因此,在 64 位计算机上,这意味着每个元素都需要 16 字节的额外开销。更糟糕的是,其中一半的指针是空指针。而这还是在最佳情况下。如果考虑到填充以及节点需要存储的任何额外元数据(例如红黑树),二叉搜索树的内存效率就非常糟糕了。

更糟糕的是,对于二叉搜索树来说,每次插入都会触发内存分配。内存分配通常被认为是一种效率低下的操作,所以如果可以避免,我们当然会尽量避免!

B-Trees来帮忙啦!

什么是B树?以及为什么需要B树?

替代文字

B是一种自平衡树状数据结构,它能保持数据有序,并允许在对数时间内进行搜索、顺序访问、插入和删除操作。等等!这不就是二叉搜索树(BST)的功能吗?那我为什么还需要B树呢?

B 树借鉴了二叉搜索树 (BST) 的思想,简单来说,就是“我们不妨在里面加入一些数组,数组简单易用,计算机也喜欢数组,何乐而不为呢?”。因此,B 树的每个节点不再是包含一个元素和两个子节点,而是一个元素数组,每个元素又是一个子节点数组。所有元素都有序排列!

基本上,对于某个固定的常数B,每个节点包含B-12B-1 个元素,并按顺序排列(根节点可以只有一个元素)。一个包含k 个元素的内部节点(即有子节点的节点)k+1个子节点。这样,每个元素仍然有一个“左”子节点和一个“右”子节点,但例如,第二个子节点包含的元素严格介于第一个元素和第二个元素之间。

根据Knuth 的定义,M 阶 B 树是满足以下性质的树:

  1. 每个节点最多有m个子节点。
  2. 除了根节点之外,每个非叶子节点至少有⌈M/2⌉个子节点。
  3. 如果根节点不是叶节点,则它至少有两个子节点。
  4. 具有k个子节点的非叶子节点包含k − 1 个键。
  5. 所有叶子都出现在同一层级,不携带任何信息。

每个内部节点的键值用作分隔值,用于划分其子树。例如,如果一个内部节点有 3 个子节点(或子树),则它必须有 2 个键值:_a1 和 _a2。最左侧子树中的所有值都小于 _a1,中间子树中的所有值都介于 _a1 和 _a2 之间,最右侧子树中的所有值都大于 _a2。

1970 年发明以来,B 树作为一种存储在磁盘上的数据结构,历史上一直非常流行。这是因为访问磁盘可能非常缓慢,而 B 树可以一次性返回大量数据,这与二叉搜索树 (BST) 截然不同。搜索数据所需的 CPU 时间远少于从磁盘读取数据到内存所需的时间。例如,如果B = 1000,那么可以一次性在树中存储一千个条目,并在 RAM 中相对快速地处理它们。此外,与二叉搜索树相比,B 树的深度会非常浅,这意味着每次搜索可能只需要通过指针访问磁盘几次。听起来是不是很熟悉?这就是我们刚才讨论的缓存命中问题。

为了让您有个概念,以下是一些真实数据:

一个包含 10 万行数据的 SQLite 数据库使用深度为 3 的 B 树。因此,要获取一个节点,我们只需要读取 3 页或在节点之间跳转 3 次。如果使用二叉搜索树 (BST),则需要执行 log(100000) / log(2) = 16 次查找!这比 B 树的查找次数多了五倍多。我们当然不希望这样!

这些都很好,也很有趣,不过关于 B 树的理论就到此为止吧,让我们来看看 SQLite 是如何使用它的。如果您想更深入地了解 B 树及其在不同编程语言中的实现,我强烈推荐您阅读Open Data Structures。另一个提供大量 B 树背景知识的优秀资料来源是 Knuth 的《计算机程序设计艺术》第三卷“排序与搜索”,第 471-479 页这是一本不错的轻松读物……

SQLite 使用 B 树来表示表和索引。但实际上,SQLite 使用了两种 B 树变体。传统的 B 树用于存储索引,称为“索引 B 树”。而 SQLite 则使用一种称为 B+ 树的变体来存储表,称为“表 B 树”

主要区别在于,“表 B 树”或 B+ 树使用 64 位有符号整数键来引用内部节点中的数据,所有数据实际上都存储在叶子节点中。这个 64 位有符号整数被称为“行 ID (ROWID )”。此外,叶子节点可以包含指向下一个叶子节点的指针,以加快顺序访问速度。而“索引 B 树”或简称 B 树使用任意键,并且不存储任何数据。

以下是一些基本区别:

+-------------------------------+----------------+-----------------+  
|               #               |      B-tree    |       B+ tree   |  
+-------------------------------+----------------+-----------------+  
| Pronounced                    | “Bee Tree”     | “Bee Plus Tree” |      
| Used to store                 | Indexes        | Tables          |      
| Internal nodes store keys     | Yes            | Yes             |      
| Internal nodes store values   | Yes            | No              |      
| Number of children per node   | Less           | More            |      
| Internal nodes vs. leaf nodes | Same struct    | Different struct|   
+-------------------------------+----------------+-----------------+
Enter fullscreen mode Exit fullscreen mode

另一个重要的信息是,SQLite 会在数据库中为数据库模式中的每个表(包括系统表,例如sqlite_schema )维护一个“表 B 树”。SQLite 还有一种名为WITHOUT_ROWID的表类型,但这里暂不赘述。此外,SQLite 还会为模式中的每个索引(包括由 UNIQUE 约束创建的隐式索引)维护一个“索引 B 树”。

让我们试着把它形象化!

假设数据库中存储了以下表格:

+-------+-------+-------+-----+  
| ROWID | Name  | Marks | Age |  
+-------+-------+-------+-----+  
|     6 |  Jone |     5 |  28 |  
|    15 | Alex  |    32 |  45 |  
|    12 | Tom   |    37 |  23 |  
|    53 | Ron   |    87 |  13 |  
|    24 | Mark  |    20 |  48 |  
|    25 | Bob   |    89 |  32 |  
+-------+-------+-------+-----+
Enter fullscreen mode Exit fullscreen mode

首先,数据库为每条记录创建一个唯一的自增索引(ROWID 或使用主键),并将相关行转换为字节流。然后,它将键和记录的字节流分别存储在 B+ 树中。这里,ROWID 用作索引键。键和记录的字节流统称为有效载荷。生成的 B+ 树可以表示如下。

SQLite 数据库上的 B+ 树
SQLite 数据库上的 B+ 树

如您所见,所有记录都存储在 B+ 树(或称“表 B 树”)的叶节点上,索引或 ROWID 用作创建 B+ 树的键。内部节点上不存储任何记录。每个叶节点都指向树中的下一条记录。这样,数据库既可以使用索引执行二分查找,也可以通过遍历每个元素进行顺序查找,而无需遍历叶节点。

如果未使用索引,数据库将读取所有记录以查找指定记录。启用索引后,数据库会为表中的每个索引列创建一个 B 树或“索引 B 树”,如下所示。在本例中,我们对“名称”列创建了索引,并将该列值用作 B 树的键。索引是指向“表 B 树”中实际数据记录的引用。

SQLite 数据库上的索引 B 树
SQLite 数据库上的索引 B 树

当索引可用时,数据库引擎首先会在相应的索引 B 树中查找给定的键,并在对数时间内获取索引。然后,它会利用已找到的索引在表 B 树或 B+ 树中再次查找,同样在对数时间内完成,并返回记录。

由于这次我们并没有真正进行任何编码,所以这里只是简单展示一下 SQLite 代码库中数据库的表示方式,顺便说一句,SQLite 代码库的注释和文档都非常完善

sqliteint.h
sqliteint.h

这就是我们挚爱的 SQLite Btree,它代表着我们数据库的起点。如果我们深入研究这些属性,就会发现很多我们在文章中讨论过的内容。这里我就不赘述了,但如果你感兴趣,我强烈建议你去看看SQLite 的代码

概括

我非常推崇第一性原理方法。运用第一性原理时,首先要确定要解决的问题,并定义一些约束条件和假设。一开始就定义约束条件的好处在于,它实际上定义了哪些事情不能做。而一旦明确了哪些事情不能做,哪些事情可以做就变得清晰明了。完成第一步后,将问题分解成更小的部分或其基本原理。最后,也是至关重要的一步,从零开始创建一个新的解决方案。

这一次,我们朝着理解问题迈出了非常重要的一步,通过定义一些数据结构的局限性来定义一些约束,甚至将问题分解成更小的部分,例如如何处理表和索引。

现在我们准备决定如何从头开始构建问题的解决方案,也就是说,我们究竟如何在 Rust 中构建数据结构。

← 第 0 部分 — 概述

← 第一部分 — 了解 SQLite 并设置 CLI 应用程序和 REPL

← 第二部分 — SQL 语句和元命令解析器 + 错误处理

在 GitHub 上查看(非常欢迎提交 pull request)

如果你想关注我的动态,别忘了在Medium上关注我,也请给我点个赞!

其他来源:

文章来源:https://dev.to/thepolyglotprogrammer/what-would-sqlite-look-like-if-writing-in-rust-part-3-ool