mongodb索引到底是B+树还是B树?

MongoDb到底使用的是B树,还是B+树,这个问题困扰了很久,终于解开疑惑了。

1、为什么 MongoDb 数据组织形式使用 B 树,而 MySQL 使用 B+ 树?

答,这个问法其实不对,WiredTiger 使用的是 B+ 树作为其存储结构。

从对名词 B-tree 的误解开始

本文前面提到,提到 B 树时,可以指侠义上的 B 树,也可以指广义上的 B 树,有人不注意区分这两者,于是就造成了误解。例如,Mongodb 官网说“MongoDB indexes use a B-tree data structure.”,即“Mongodb 索引使用 B 树数据结构。”,如下图所示,该图截自 Mongodb 官方文档,链接为 MongoDB Manual-Indexes

翻译过了就是:“WiredTiger 在内存中使用名为 B 树(具体为 B+ 树)的数据结构维护表的数据,将 B 树中的节点称为页。内部页面只存储 key,叶子页面存储 key 和 value ”。

所以说,MongoDB 官方文档中提到的 B 树说的是 B 树的变体 B+ 树,也怪 MongoDB 官方惜字如金,对底层数据结构讨论的太少了,导致很多人将其理解成了狭义上的 B 树。

B+ 是在 B 树上改进,它的数据都在叶子节点,同时叶子节点之间还加了指针形成链表。范围选择只需要找到首尾,通过链表就能把所有数据取出来了。

2、B 树的单条记录查询性能真的好于 B+ 树吗?

前面提到了几篇错误八股文都说 B 树的单条记录查询性能好于 B+ 树。理由是 B 树中每个点的中 key 和卫星数据保存在一起,最好的情况是,待查询的记录位于根节点中,只需要加载一个根节点页面,所以时间复杂度只有 O(1);最坏的情况下,查询的记录位于叶子节点层,需要加载从根节点到叶节点路径上的所有页面,所有时间复杂度和在 B+ 树中查询单条记录一样为 O(h),h 为树的高度。B 树的最怀情况才和 B+ 树一样,所以 B 树的单条记录查询性能更好。

这种说法根本经不起推敲。首先,虽然查询位于 B 树上层节点的效率比查询位于下层的效率高,但 B 树中大多数节点都位于下层,其中叶节点数最多。最好的情况是待查询记录位于根节点,时间复杂度 O(1),但根节点中的记录比较是少数,这个最好情况的 O(1) 能有多大影响呢?

其次,由于 B+ 树中内节点不需要保存卫星数据,所以 B+ 树内节点能存储的 key 和指针数比 B 树节点能存储的记录数更多,也就是内节点的子节点更多。所以,对于相同的数据量,B+ 树的高度可能更低一点。所以在 B+ 树查询单条记录的效率应该好于在 B 树中查询位于叶节点的单条记录。另外,B+ 树内节点的子节点数 B 树内节点的子节点多,也就意味着:所以对于相同的数据量,B+ 树的内节点数更少,更有可能被缓存在内存中。而且 B+ 树中的所有卫星数据都在叶节点,不管查询哪条记录都需要经过相同的层级,所以内节点有良好的缓存命中率。

总结一下,对于查询单条记录,B 树有一个优势,B+ 树有两个优势。B 树的优势为:

  1. 查询位于上层的记录,需要加载的节点页面数很少。

但大多数记录都位于下层,所以这个优势其实很小。

B+ 树的优势为:

  1. 对于同样的数据量,B+ 树的高度可能小于 B 树的高度。
  2. 对于同样的数据量,B+ 树的内节点数更少,更可能被缓存。而且缓存命中率良好。

所以,B 树和 B+ 树的各有优势,不能简单的说哪个的单条记录查询性能更好。下面通过一个示例,更具体的分析 B 树、B+ 树的单条数据查询性能。

下面的示例是理论分析,说服力自然比不上对实际产品的基准测试。但我目前找不到哪个数据库使用狭义上的 B 树作为存储结构,基准测试也无从做起。

3、假设要存一千万条记录

现在通过一个例子具体分析,假设有一千万条记录需要存储,每条记录的 key 占 4 个字节,卫星数据占 248 个字节,一条记录总共 252 个字节。另外假设 B 树和 B+ 树中每个节点页面大小为 16 KB,每个指针占用 4 字节。

构建 B 树

如果将这一千万条记录保存到 B 树中,为了计算方便,假设每个节点页中的指针和记录数一样多(标准定义中的指针数比记录数量多 1),每个指针占 4 个字节,则每个 16 KB 的节点页面能保存的记录条数为 16×1024÷(252+4)=64 条。所以构建出的 B 树只有 4 层.

  • 其中第 1 层(根节点)只有一个节点页面,有 64 条记录和 64 个指针;
  • 第 2 层有 64 个节点,每个节点页面存储 64 条记录和 64 个指针,所以第 2 层的记录数为 642=4096 条,
  • 同理可推出第 3 层的节点页面数为 642=4096 ,存储的的记录数为 643=262144 条,
  • 第 4 层最多能存储的记录数为 644=16777216 条,但我们只有一千万条记录,所以第 4 层存储的记录数为 10000000−64−642−643=9733696 ,但由于第四层是叶子节点层,不需要存储指针,每个页面能存储的记录数为 16∗1024÷252≈66 ,节点数为 9733696÷66=147482 。

构建 B+ 树

如果将这一千万条记录保存到 B+ 树中,由于卫星数据都在叶子节点层,我们可以从叶子节点层的节点数量反推得到 B+ 树的高度.

  • 叶子节点中要保存 10000000 条数据,而每个页面能保存的记录数为 (16×1024−4)÷252≈65 条(因为每个叶子节点有需要一个指向下一个页面的指针,所以需要先减 4),所以叶子节点层需要的节点数为 10000000÷65≈153847 。
  • 内节点不需要保存卫星数据,只需要保存 key 和指针,所以每个内节点页面能保存的数据量为 16×1024÷(4+4)=2048 条,也就是每个节点最多能保存 2048 个key 并引出 2048 个指针,所以叶子节点层的的上一层只需要 153847÷2048≈76 个节点,
  • 上上层只需要一个节点就可以引出 76 个指针,所以上上层就是根节点。

即构建出的 B+ 树只有 3 层,第 1 层只有 1 个节点,节点中有 76 个 key 和 指针;第 2 层只有 76 个节点,每个节点有 2048 个 key 和指针;第 3 层有 153847 个节点,每个节点最多 64 条记录,共一千万条记录。

现实中各种数据库实现的存储结构的每个节点页面都会预留固定大小的空间用于保存元数据,这里不用考虑,影响不大。

进一步假设缓存大小

外部存储系统一般都会搭配缓存使用,假设为前面构建的 B 树和 B+ 树分别设置 64 M 缓存。64M 可以缓存 4096 个 16 KB 的节点页面。

我们在实际使用 MySQL 和 MongoDB 时会设置非常大的缓存,可能达到一百多 G,但一个 MySQL 实例或 MongoDB 实例中有多个库,每个库里有多张表,每个表对应多个索引结构。所以分每个索引结构能用的缓存空间其实不大,一般缓存不了一整张表。

这里的缓存具体多大不重要,只要能缓存 B 树或 B+ 树中的部分页面就行。

我们前面构建的 B+ 第 1 层和第 2 层一共 1+76=77 个页面,所以第 1 层和第 2 层完全可以被缓存。叶节点层的 4019 个页面可以被缓存。

前面构建的 B 树,第 1 层、第 2 层以及第 3 层共 1+64+642=4161 个页面,和 4096 相差不大,就假设其前 3 层都被完全缓存了吧。

对访问耗时建模

在 B 树查找记录的主要步骤为:

  1. 将根节点设为当前节点,在当前节点中查找对应的记录。
  2. 如果记录不在当前节点中则会找到一个指向下一层某个节点的指针,根据指针接着去下一个节点中查找。
  3. 如果节点页面不在缓存中,则需要先从磁盘中将页面加载到内存中。

在 B+ 树中查找记录的主要步骤为:

  1. 将根节点设为当前节点,在当前节点中查找一个对应 key 值和其对应的指针。
  2. 将指针指向的节点设置当前节点,继续查找,直到当前节点为叶子节点,在叶子节点中根据 key 查找对应记录。
  3. 如果节点页面不在缓存中,则需要先从磁盘中将页面加载到内存中。

可以看出在 B 树和 B+ 树查找单条记录三个步骤中有两种关键操作:

  1. 在一个节点页面内读取并对比 key 值查找对应的记录或指针。因为节点内的 key 都是升序的,所以可以使用时间复杂度为 log⁡(�) 的二分查找算法,即平均读取和对比的次数为 log⁡(�) ,n 为节点内的 key 数。
  2. 将节点页面从磁盘加载到内存。

从内存读取数据和比磁盘加载数据的的效率高很多,一般有几个数量级的差距。这里我们假设在内存中读取并对比 key 的耗时为 1 个时间单位,从磁盘读取 16 KB 的页面到内存中耗时 10000 个时间单位。

这里内存操作和磁盘读取操作的耗时 相差 4 个数量级,但内存和磁盘都在进步,它们的实际相差几个数量级我没测试过,但你把这个数量级差距改成 3 个或 5 个都不影响后面得出的结论。

计算在 B 树中查询单条记录的耗时

先分析从 B 树中查询单条记录需要花费的时间。查询存储在第 1 层的记录时,由于节点已经缓存在内存中,所以只需要在根节点页面的 64 条记录中进行二分查找,耗时为 log⁡(64)=6 。但第 1 层的记录数只有 64 条,在总记录数占比只有 0.00064% 。

查询存储在第 2 层的记录时,需要先在根节点页面找到指针,再去指针指向的第二层节点页面查找记录,所以花费的时间为 log⁡(64)+log⁡(64)=12 。但第 2 层的记录数为 4096,在总记录数中占比只有 0.04086% 。

查询在第 3 层节点中的记录时,需要在根节点页面找到指向二层节点的指针,再去对应二层节点页面找到指向三层节点的指针,最后在对应的三层节点页面中查找记录,而前 3 层节点页面在磁盘中,需要花费时间加载到内存,所以花费的时间为 log⁡(64)×3=18 。但第 3 层的记录数为 262144,在总记录数中占比只有 2.62144% 。

查询在第 4 层节点中的记录时,和前面类似,只不过有 4 层,而且第 4 层的节点不在缓存中,需从磁盘读取,所以耗时为 log⁡(64)×3+log⁡(66)+10000≈10030 。第四层的记录数为 9733696,占比 97.33696% 。

平均访问耗时为: 6×0.00064%+12×0.04096%+18×2.62144+10030×97.33696≈9763.37 。

计算在 B+ 树中查询单条记录的耗时

再分析从 B+ 树种查询单条记录需要花费的时间。由于所有的卫星数据都在叶子节点层(第 3 层)中,所以查询每条数据都需要先在根节点查找一个指向二层节点的指针,再去对应的 2 层节点页面查找指向叶子节点的指针,最后在对应叶子节点页面查找记录。

第 1、2 层节点都在缓存中,如果记录位于叶节点层中那被缓存的 4019 个页面,则耗时为 log⁡(76)+log⁡(2048)+log⁡(65)≈23 。但 4019 个页面共 4096∗64=262144 记录,在所有记录中占比只有 2.62144% 。

如果记录位于第 3 层中没被缓存的的节点页面,则需要从磁盘加载页面,所以需要花费的时间为 log⁡(76)+log⁡(2048)+log⁡(65)+10000≈10023 。没被缓存的记录数为 10000000−4096×64=9737856 ,占比 97.37856% 。

平均耗时为 23×2.62144%+10023×97.37856%=9760.86 。

对比分析

通过前面的计算可以看出,在我们构建的 B 树和 B+ 树中,极少数情况下(不到 3% )B 树的单条数据访问效率明显好于 B+ 树。但大多数情况下都是 B+ 树的单条数据性能好于 B 树。单条数据的平均查询耗时也是 B+ 树好于 B 树。综合看,都是 B+ 优于 B 树。

情况\耗时B 树B+ 树
最好情况(占比<3%)6、12、1823
一般情况1003010023
平均情况9763.379760.86

如果继续增加记录的数量,或将每条记录占用的空间设置的更大一点,形成的 B 树和 B+ 树的高度差可能会变大,访问单条记录的效率差距会继续增大。

如果干脆不使用缓存,两者的单条数据访问效率都会变大很多,上面的 B+ 树会领先 B 树一大截,因为在上面的 B+ 树访问一条记录只需要加载 3 个页面,而在上面的 B 树中查询一条数据,一般需要加载 4 个页面。

所以网上那些“B 树的单条记录访问效率好于 B+ 树”的说法在一般情况下都不成立。

参考:

【驳斥八股文系列】别瞎分析了,MongoDB 使用的是 B+ 树,不是你们以为的 B 树 - 知乎

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值