写点什么

深入理解 MongoDB 文档模型

作者:彭振翼
  • 2025-02-05
    广东
  • 本文字数:26362 字

    阅读完需:约 86 分钟

深入理解 MongoDB 文档模型

导语

截止目前,MongoDB 距离 2007 年发布第一个版本(github 数据)已经将近 20 年,从最开始的”小众数据库“发展为了可以承载海量业务数据的核心数据库系统。在此期间,支持了插件式引擎,引入了 WiredTiger 存储引擎(3.2 版本),支持了因果一致性,changeStream(3.6 版本),支持了事务(4.0 版本) 和分布式事务(4.2 版本),支持了时序并持续优化其性能(5.0 版本), 持续丰富索引和聚合算子等基础能力。随着功能和性能的不断增强,目前 MongoDB 已经成为了全球流行度前 5 的数据库,流行度第 1 的文档数据库。参考 DB-Engines Ranking:


当我们在看一款数据库时,首先会去了解它有哪些特性,适应哪些场景,并自然地同当前最流行的数据库进行对比(比如 MySQL, Redis 等)。


MongoDB 最重要的特性有:

  • 天生分布式。MongoDB 天生就支持非常优秀的复制集、HA、分片集群等架构,非常方便横向扩展,非常适合成长型业务和海量存储的业务。从其命名也能看出来:humongous database

  • 灵活可扩展。得益于 MongoDB 的文档模型,存储 BSON 格式(JSON - like)的数据。用户可以不需要预先定义表结构,而且可以在运行过程中任意增加或者删减字段,任意变换字段类型,任意进行嵌套。非常适合存储半结构化数据。

除此之外,丰富的索引和聚合操作、事务、时序等特性的支持也使得 MongoDB 不同于一般的 NoSQL 数据库,能够适应更多的业务场景。


如果选择一个特性来代表 MongoDB,那一定是文档模型,这也是为什么 MongoDB 会被称为文档数据库的原因。

因此,本文接下来探讨一个核心问题:为什么 MongoDB 是文档数据库?

具体来说,分以下几个章节进行阐述:

  1. BSON 文档。对 BSON 的组成进行比特级拆解和分析,说明 BSON 相比于 JSON 的改进,以及为什么 MongoDB 选择 BSON 而不是 JSON。

  2. KeyString 和索引原理。对 BSON compare 操作进行分析,说明存在的不足。详细说明 KeyString 的诞生和原理,以及在 MongoDB 索引中的应用。

  3. 文档模型和存储引擎的关系。在前面介绍完文档模型的 2 个核心组件之后,从系统架构出发,说明文档模型和存储引擎的关系,以及 WiredTiger 引擎如何在 MongoDB 中发挥作用。

  4. 总结和思考。探讨文档数据库的核心组件都有哪些,思考自研一个文档数据库该如何去做?


1. BSON 文档

1.1  BSON 是什么

说起文档数据库,第一个想到的可能是 JSON. 比如 ElasticSearch, PostgreSQL, MySQL 等数据库中都能看到它的身影。

MongoDB 作为目前最流行的文档数据库,却没有直接使用 JSON,而是采用 BSON 格式来支持文档模型。

BSON 全称是 Binary JSON, 和 JSON 很像,但是采用二进制格式进行存储。

一条 BSON 文档的可视化表达如下:

{	"_id" : ObjectId("64af4b1a89c38e320882ed60"),	"name" : "zhang san",	"tele" : NumberLong("13212345678"),	"addr" : "beijing",	"score" : [		80,		90,		95	],	"info" : {		"male" : true,		"introduction" : "I am a student"	}}
复制代码

BSON 相比 JSON 有以下优势:

  • 访问速度更快。 BSON 会存储 Value 的类型和内存中的原始值,因此不需要进行字符串类型到其他类型的转换操作。以整型 12345678 为例,JSON 需要将字符串转成整型,而 BSON 中存储了整型类型标志,并用 4 个字节直接存储了整型值。对于 String 类型,会额外存储 String 长度,这样解析操作也会快很多;

  • 存储空间更低。 还是以整型 12345678 为例,JSON 采用明文存储的方式需要 8 个字节,但是 BSON 对于 Int32 的值统一采用 4 字节存储,Long 和 Double 采用 8 字节存储。 当然是否能节省存储空间也要看具体场景,比如对于小整型,BSON 消耗的空间反而更高;

  • 数据类型更丰富。 BSON 相比 JSON,增加了 BinData,TimeStamp,ObjectID,Decimal128 等类型,全部 BSON 类型参考官方文档


MongoDB 官方文档 对此有比较权威直观的描述,总结如下:

下面对 BSON 的存储格式进行深入分析,并从代码级别分析 BSON 的存储和解析过程,使大家对 BSON 有更深入的了解。


1.2  BSON 存储格式

一条最简单的 BSON 文档,从前向后可以拆解成以下几个部分:

  1. 首先是文档的总长度, 占 4 个字节;

  2. 然后是多个 BSONElement 按照顺序排列。每个 BSONElement 包含的内容有:

1) Value 类型,参考代码定义,占 1 个字节;

2)  Key 的 C-String 表示形式,只存储 C-String 内容,不存储长度,以 '\0' 结尾,占 len(Key)+1 个字节;

2) Value 的二进制存储,比如 Int32 占 4 字节,Long 和 Double 占 8 个字节等,本文后续会对常用类型逐一举例分析;

  1. 文档以 '\0' 结尾,也就是在遍历 BSON 到末尾时,常见的 EOO(End Of Object),占 1 个字节;


下面列举常用的 Int32, Double, String, Object 内嵌文档,Array 类型,并分析它们的 16 进制表现形式。

Int 类型

Double 类型

Double 类型占用 8 个字节空间(小端模式),使用 IEEE 754 标准转换成二进制存储。

String 类型

String 类型头部包含额外的 4 字节长度空间,并且以 '\0' 结尾。

需要注意的是,BSON 有 2 种 String 类型:

  1. C-String: 一般用于 Key ,以 '\0' 结尾,不存储长度信息

  2. String: 一般用于 Value,以'\0' 结尾,头部会存储 4 字节的长度信息

推测这样设计的好处是:

  1. Key 一般长度较短,可以很快解析。不需要额外 4 字节的存储和解析开销;

  2. Value 一般长度较长,通过存储 4 字节的长度信息,可以明显加快解析速度;

嵌套文档

嵌套文档和普通文档一样,头部也包含了额外的 4 字节长度空间。比如下面的例子 {"b" : NumberInt(1)} 的存储长度为 12 字节。

数组类型

数组类型头部有 4 个字节存储长度,每个元素都对应有下标,从 '0' 开始递增。

比如下面的例子中,"a.0" 表示第 1 个元素,值为 Double(1), "a.3" 表示第 4 个元素,值为 "4".

1.3  BSON 的序列化和反序列化

1.3.1 反序列化(解析)流程

解析 BSON 文档 时,先用小端模式读取头部的 4 个字节,转换成 Int32 类型的长度信息,得到 BSON 文档的结束位置。

然后根据上一节介绍的 BSON 格式信息,不断获取 Value 类型, Key,以及 Value。通过迭代器重复上述上述流程得到 BSON 文档中的所有 KV 对。

上述流程可以参考 MongoDB 代码中对 BSONObjBSONObjIterator 的定义。

部分关键代码摘抄如下:

// 根据传入的二进制 BSON 数据构造迭代器explicit BSONObjIterator(const BSONObj& jso) {    int sz = jso.objsize();  // 小端模式读取头 4 个字节,得到 int32 类型的长度    if (MONGO_unlikely(sz == 0)) {        _pos = _theend = 0;        return;    }    _pos = jso.objdata() + 4;  // 真正的起始位置    _theend = jso.objdata() + sz - 1;  // 末尾}
// 判断迭代器当前是否到了末尾bool more() { return _pos < _theend;}
// 获取迭代器当前指向的 BSONElement(可以理解为一个 KV 数据),然后迭代器 ++BSONElement next() { verify(_pos <= _theend); BSONElement e(_pos); // 从当前位置,封装 KV 数据 _pos += e.size(); // 迭代器 ++ return e;}
复制代码

整个解析过程通过迭代器从前向后遍历,时间复杂度为 O(N). 但是由于 存储了 Value 长度的元数据信息,所以效率还是会比较高。

1.3.2 序列化流程

BSON 文档的封装流程可以看做是解析的逆过程。首先在头部保留 4 个字节,然后不断将 Value 类型,Key, Value 的二进制形式进行追加,然后在文档末尾加上 '\0' EOO 标志,最后将计算的长度(包括存储长度的 4 个字节本身)存储在头部预留的 4 个字节中。

上述流程可以参考 MongoDB 代码中对 BSONObjBuilder 的定义。

部分关键代码摘抄如下:

// 构造一个 BSONObjBuilderBSONObjBuilder(int initsize = 512)    : _b(_buf), _buf(initsize), _offset(0), _s(this), _tracker(0), _doneCalled(false) {    // Skip over space for the object length. The length is filled in by _done.    _b.skip(sizeof(int));  // 头部预留 4 字节,在调用 _done() 时填充长度
// Reserve space for the EOO byte. This means _done() can't fail. _b.reserveBytes(1); // 尾部预留 1 个字节的 EOO}
// 往 BSONObjBuilder 中插入一条 Value 类型为 int32 的 KV 对BSONObjBuilder& append(StringData fieldName, int n) { _b.appendNum((char)NumberInt); // 先追加 Value 类型, 占 1 个字节 _b.appendStr(fieldName); // 追加字符串类型的 Key,以 '\0' 结尾 _b.appendNum(n); // 追加 4 字节的二进制整型 Value return *this;}
// KV 数据追加完毕之后,调用 done 方法处理收尾工作char* _done() { if (_doneCalled) return _b.buf() + _offset;
_doneCalled = true;

_s.endField();
_b.claimReservedBytes(1); // Prevents adding EOO from failing. _b.appendNum((char)EOO);
// 确定最终数据的起始位置和大小 // 这里的 _offset 用于嵌套 builder 共享一个 buffer 空间的场景,否则 _offset 为 0 char* data = _b.buf() + _offset; int size = _b.len() - _offset; // 将 size 使用小端模式写入头部 4 个字节中 DataView(data).write(tagLittleEndian(size)); if (_tracker) _tracker->got(size); return data; // 返回最终的数据}
复制代码

如果初次接触 BSON,可能会认为如果只修改 BSON 中的某一个字段,底层只会原地更新这一小块数据,不会有很大开销。但是事实并非如此,从前面的描述可以看到,每个 KV 是顺序紧凑排列的,如果增加、删除或者修改了某个字段,要生成新 BSON 文档。


除了通过 BSONObjBuilder 流式生成 BSON 文档外,MongoDB 代码中也提供了 DOM 接口用于修改或者增删某个字段,但是修改完成后还是会生成新的 BSON。除非是不改变 BSON 二进制结构的更新才支持 UpdateInPlace, 具体规则可以参考Element::setValue 流程 以及 DamageEvent 定义

1.4 性能测试

参考 https://github.com/pengzhenyi2015/bsonjson , 使用 Golang driver 对比了同样数据场景下,BSON 和 JSON 的长度和编解码性能。

使用的代码库:

JSON: Golang 内置的 JSON 库

BSON: MongoDB 官方提供的 go-driver


测试的数据类型:

double: 一个结构体包含 10 个 double 成员,分 “短” double(数值 0-10) 和 “长” double(数值 math.maxFloat64) 2 种场景。

int64: 一个结构体包含 10 个 long 成员,分 “短” int64(数值 0-10) 和 “长” int64(数值 math.maxInt64) 2 种场景。

string:一个结构体包含 10 个 string 成员,每个 string 成员的长度是 117 字节。


测试方法:

将每种类型的数据对象,分别使用 JSON 和 BSON 方式 marshal/unmarshal 各 100,000 次。 对比 长度、编码耗时、解码耗时。


测试结果如下(数字越小表现越好,加粗的部分表示 JSON 更优,其余的 BSON 更优):

在存储空间(长度)方面:

对于 “长” double/int64 类型,BSON 在空间上,有一定优势。因为 BSON 使用固定 8 字节进行编码。

对于 “短” double/int64 类型,BSON 在空间上,存在劣势。还是因为 BSON 使用固定 8 字节编码的原因。

对于测试使用的 string 类型,BSON 并没有优势。

在序列化(marshal)效率方面:

对于测试使用的 “长” double 类型和 string 类型, BSON 均有 10%-30% 的性能提升。

在 “短” double 类型上,BSON 和 JSON 性能相当。

对于 int64 类型,不管数据的长短,BSON 均有明显的性能下降。

在反序列化(unmarshal)效率方面:

BSON 优势非常明显。对于测试使用的 “长” double/int64 类型有 100% 的性能提升,对于 string 类型有 200% 以上的性能提升。

对于 “短” double/int64 类型,也有 20% 以上的性能提升。


不同的业务场景和不同的数据类型在测试时会存在性能差异,因此上述测试结果仅供参考!


1.5 小结

BSON 作为 JSON 的一种扩展存储格式,在速度,存储空间和数据类型方面都有非常大的提升,并在 MongoDB 的文档模型中扮演了关键角色。本文从原理上对比了 BSON 和 JSON 的区别和优缺点,通过一些典型的例子深入分析了 BSON 的数据组织结构,并从代码入手介绍了 BSON 的读写流程和一些注意事项。


2. KeyString 和索引原理

2.1  BSON compare

2.1.1 BSON 中不同类型的比较问题

文档数据库的魅力就在于,你可以随时增加和删除字段,并且可以随时变换数据类型。但是这也带一个问题:不同数据类型之间如何比较大小。

比如在一个表中插入如下数据:

{ "_id" : "1" } // "a" 不存在{ "_id" : "2", "a" : ISODate("2023-06-13T09:23:17.005Z") } // "a" 是 Date 类型{ "_id" : "3", "a" : ObjectId("6488358fbb697a2e3b9eee95") } // "a" 是 ObjectId 类型{ "_id" : "4", "a" : 1 } // "a" 是 Int 类型{ "_id" : "5", "a" : NumberLong(2) } // "a" 是 NumberLong 类型{ "_id" : "6", "a" : 3 } // "a" 是 Double 类型{ "_id" : "7", "a" : [ "x", "y", 1 ] } // "a" 是 Array 类型
复制代码

如果执行如下按 "a" 字段排序的命令,MongoDB 会如何对不同类型的值进行比较呢?

db.coll.find().sort({a:1})
复制代码

在讨论 MongoDB 如何比较不同类型的值之前,我们先思考一下不同类型的比较是否有意义:

  • 有些类型之间的比较是没有意义的。比如 ISODate("2023-06-13T09:23:17.005Z") 和 [ "x", "y", 1 ] 相比,没有足够的理由来判定谁大谁小;

  • 有些类型之间的比较是有必要的,典型的就是数值类型。比如 long(4) > double(3.5) > int(3) 是能被接受的,但是反过来就不行;


但是不论不同类型的比较是否有意义,MongoDB 需要保证每次排序都要返回稳定的结果。因此,MongoDB 规定了严格的不同类型之间的大小关系。参考官方文档, 从小到大的顺序为:

1. MinKey (internal type)2. Null3. Numbers (ints, longs, doubles, decimals)4. Symbol, String5. Object6. Array7. BinData8. ObjectId9. Boolean10. Date11. Timestamp12. Regular Expression13. MaxKey (internal type)
复制代码

以上图的 Array 和 Date 为例,不管 Array 多长,Date 的时间多小,Array 都是比 Date 要小的。

另外 Int, Long, Double, Decimal 被统一归类为 Numbers 类型,Symbol 和 String 也归类为同一种类型。

2.1.2 BSON compare 比较流程

BSON 的比较流程可以参考 BSONObj::woCompare 的实现。

基本流程为:

  1. 遍历 2 个 BSONObj 中的 BSONElement,使用 BSONElement::woCompare 对比 2 个 BSONElement 的大小;

  2. 对每个 BSONElement,先比较类型,如果类型一样才使用 BSONElement::compareElements 比较值;

  3. 根据类型不同,值比较的逻辑也不相同。比如 int 和 double 类型比较时,要都转成 double 进行比较;Object 这种嵌套类型会采用“递归”的方式进行比较;

以 {"a" : NumberInt(1), "b" : {"c" : "1", "d": true}} 和 {"a" : NumberLong(1), "b" : {"c" : "1", "d": false}} 的比较为例,整体流程如下图所示:

  1. 第 1 个字段。首先比较 fieldName(可选),相等;其次比较 ValueType, 归一化都都是 Numberic 类型, Value 值都是数值 1,也相等;

  2. 比较第 2 个字段,fieldName 相同(可选),都是 "b", ValueType 都是 Object 嵌套类型,接着通过递归方式对比 Object 内部的值;

  3. Object 内部第 1 个字段都是 "c", 包含的 Value 都是 "1";

  4. Object 内部第 2 个字段都是 "d", 第 1 个 BSON 包含的值是 true, 第 2 个 BSON 包含的值是 false. 因此到这一步才决出 BSON1 > BSON2.

2.1.3 BSON compare 的不足

从前面的分析可以看出,BSON 的比较是非常复杂而且消耗资源的。每次比较都伴随着 2 个 BSON 解析,并包含类型转换和比较过程,比如 number 类型之间的 int/long/double 转换等。

正如 MongoDB 官方文档中所描述的,MongoDB 运行过程中要处理大量的 BSON 比较。比如表中有一个 {x:1, y:1} 联合索引,现在按照 {x:42.0, y:"hello"} 查询条件找目标文档,在数据量比较大的场景下,可能需要 10 多次比较。如果使用 BSON 原生的比较方式,会暴漏非常严重的性能问题。因此:

  1. 有没有一种方式,能够将 BSON 比较转换成 memcmp 二进制比较,从而提升性能?

  2. MongoDB 底层采用 KV 引擎存储索引。如果 KV 引擎还需要理解文档模型,支持 BSON 比较,必然会破坏 MongoDB 的架构设计,增加存储引擎的复杂度。

2.2  KeyString

BSON 的比较过程比较繁琐,涉及到反序列化,类型转换等。BSON 虽然使用二进制进行存储,却不能直接使用二进制方式进行比较。

BSON 不能直接使用 memcmp 进行二进制比较的原因至少包含以下几点:

  1. 存在干扰信息。BSON 会将自身长度使用小端模式放在头部 4 字节,通过上面的例子可以看到,BSON 的大小和自身的长度并没有直接关系。如果直接使用二进制比较,可能会得到一个错误结果;

  2. 类型问题。Int/long/double 等数字类型在 BSON 中都有不同的 type,不能用来比较。必须归一化成同一种 type, 放在同一个维度作比较;

  3. 顺序问题。BSON 中多个字段的比较顺序可能有区别。比如创建了一个 {a : 1, b : 1, c : -1} 索引,a 和 b 升序,c 降序,BSON 很难处理这种二进制比较场景;

  4. 大小端问题。BSON 中采用小端模式存储数字,这种低地址存低字节的方式并不适合按地址进行二进制比较;



  1. 变长 Value 的比较问题。String/BinData/Object/Array 这种长度不固定的类型,可能会对比较产生干扰。比如 { a : , b : } 与 {a : , b : } 进行比较,如果 String1 是 String2 的前缀,String2 长度更长,则进行二进制比较时可能出现 Int1 和 String2 末尾部分进行比较的情况。如果出现这种情况,很有可能产生错误的比较结果。


为了解决上述 BSON compare 的不足,KeyString 应运而生。

KeyString 可以看作一个可以直接通过 memcmp 进行比较的 string. 它提供了一种方法 t, 将 BSON 的比较等价到 KeyString 的比较。

对于 BSON x 和 BSON y, 存在规则 t, 使得:

x < y ⇔ memcmp(t(x),t(y)) < 0x > y ⇔ memcmp(t(x),t(y)) > 0x = y ⇔ memcmp(t(x),t(y)) = 0
复制代码

2.2.1 KeyString 组织方式

KeyString 的组成方式为:字段 1 类型 + 字段 1 二进制 + 字段 2 类型 + 字段 2 二进制 + ... + <discriminator> + 结尾标识符(0x04) + <recordId>。


相比 BSON,KeyString 在组织方式上有以下改动:

  1. 排除了干扰信息

不再包含 BSON 整体长度,以及 String/Array/Object 等类型的长度信息。另外在 KeyString 用于索引场景时,还会去除 fieldName 信息来节省空间。因为索引包含哪些字段,以及对应的顺序都是固定好的。下文介绍 KeyString 编码时,都按不包含 fieldName 描述;

  1. 增加了 discriminator 和 recordId 字段

这 2 个是可选字段,一般用于索引场景。后文在介绍 KeyString 在索引中的使用时,会详细展开介绍

  1. 使用归一化类型

Int/Long/Double 等统一使用 Numberic 类型,String/Symbol 统一使用 StringLike 类型。由于 symbol 类型目前已废弃,因此后面主要分析 Numberic 的类型转换流程。另外,为了方便将 KeyString 中的数据转回到 BSON,KeyString 还维护了 TypeBits 按位存储原始类型信息,TypeBits 作为 KeyString 的元数据存储,不参与 KeyString 的二进制比较。需要注意的是并不是每一种数据类型都要再 TypeBits 中记录,只有 Int/Long/Double/String/Symbol 等存在归一化类型转换的类型才需要记录原始类型。对于 Array, BinData 等类型则不需要记录;

  1. 使用按位取反的方式解决字段的升降序问题

MongoDB 中使用 Ordering 来表示每个字段的顺序,Ordering 本质上是一个 32 位的 unsigned int, 其中每个 bit 表示一个字段的顺序。比如 {a : 1, b : -1} 索引,Ordering 中第 1 个 bit 是 0, 表示 "a" 字段升序;第 2 个 bit 是 1,表示 "b" 字段降序,在 KeyString 编码时,会将 "b" 字段的类型和二进制内容全部按位取反。另外,由于 Ordering 只能表示 32 个字段的信息,因此,MongoDB 在创建索引时,最多只能支持 32 个字段的联合索引;

  1. 使用大端模式表示数值

与 BSON 不同,KeyString 会对 Int/Long/Double 等数值类型进行处理之后转为大端模式存储,包括数值类型派生出的 Timestamp, Date 等类型也是如此;

  1. 使用分界符处理变长类型的比较问题

对于 String/Array/Object 等变长类型,如果采用和其他类型相同的编码方式,可能出现跨字段比较的问题。比如 {"a": "abcd", "b": 1} 和 {"a": "abc", "b": 2} 比较,由于第 1 个 KeyString 的 "a" 字段多一个字符 'd', 在按字节比较时,可能出现第一个 KeyString 中 “a” 字段的 'd' 和第二个 KeyString 中 "b" 字段类型比较的情形,这样结果是不准确的 。因此,KeyString 会在这类字段的编码结尾处添加 0x00 分界符 来避免跨字段错位比较导致结果不准确的问题。示意图如下:

而如果 String 中已经包含了 0x00,则使用 0x00FF 分界符来替代。通过这种处理方式,能保证 String 的前缀值一定是小于 String 本身的。

BinData 类型比较特别,在 KeyString 编码时,会将长度信息放在二进制内容前面,因为在 BSON 的比较规则中,BinData 长度越长,则值越大。KeyString 的比较规则和 BSON 保持一致。

2.2.2 KeyString 对数值类型的编码

在所有 BSON 类型中,数值类型(Numberic)的编码是最关键最复杂的部分。

很大一部分原因是 Double 采用 IEEE 754 标准的 8 字节方式表示, Double 相互直接能通过二进制比较,但是不能和 Long 直接进行二进制比较。举例如下:

除此之外,Int 占 4 字节,Long 占 8 字节,也不能直接使用原始的大端模式进行比较。

对于这些问题,KeyString 的解决方案是:

  1. 对于不同长度的整型 Int 和 Long, 按照真实有效字节数重新划分子类型。这里说的真实有效字节是指排除了前导 0 后剩余的字节,通过只编码真实有效字节来实现不同长度整型的比较。比如 Int(0x00001234) 和 Long(0x0000000000001234) 同属于 "2 字节整型" 的子类型, KeyString 只会再对有效部分 0x1234 进行编码,这样保证上述 2 个整型的类型和值都相等。

  2. 对于 Double 类型和 Int/Long 整型之间的比较,需要分情况讨论。由于 Double 能表示的范围是整型的超集,因此超出整型数值范围的 Double 可以直接通过子类型进行区分和比较;Double 和 Long 数值重合的部分,子类型应该相同,整数部分的表示也应该相同,小数部分编码在整数部分的后面。

KeyString 对于数值划分子类型的逻辑参考附录 A,基本的思想就是 2 字节整数小于 3 字节整数,3 字节整数小于 4 字节整数,以此类推。


对于 Double 和 Long 不存在交集的部分,比如 (-1, 0), (0, 1), (-∞, min), (max, +∞),由于可以直接通过类型区分 Long 和 Double 的大小,所以值比较只会存在 Double 类型内部。因此,KeyString 对这些子类型的 Double 的编码相对容易很多,只需要按照 Double 原有格式稍作处理,然后使用大端模式编码即可。具体可以参考代码中 KeyString::_appendSmallDoubleKeyString::_appendLargeDouble 的处理流程。


对于 Double 和 Long 存在交集的部分,KeyString 对 Double 的编码需要在数据类型和整数格式上与 Long 对齐,然后再加上小数部分。由于小数部分导致 Double 的编码可能更长,因此会出现前文提到的变长类型比较错位的情况。比如 {"a" : NumberLong(129), "b" : "1"} 与 {"a": Double(129.125), "b": "2"} 转成 KeyString 进行比较,可能出现前一个值中的 "b" 字段与后一个值中 "a" 字段的小数部分进行比较的情况,这样会导致结果不准确。

KeyString 对整数部分的编码进行了巧妙的设计,来避免跨字段错位比较的问题。还是上面的例子,我们既然已经知道 Double(129.125) 存在有效小数位,可以直接通过整数部分的编码使得 Double(129.125) 的整数部分大于 Long(129)。具体的实现方式为:

  1. 对于整型(Int/Long),编码时左移一位: value << 1, 比如 129 编码成 258;

  2. 对于 Double,整数部分编码时左移一位,如果存在有效小数位,则再加 1,比如 129.125 的整数部分编码为: (129<<1) + 1 = 259;如果 Double 不存在有效小数位,则编码方式和 Long 相同,只移位不加1.

如上图所示, 129.125 的 16 进制 KeyString 为 2C 01 03 20 00 00 00 00 00 04, 其中:

  • 0x2C 表示整数部分为 2 字节的正整数。

  • 0x0103 = 259 = (129<<1) +1, 表示编码后的正整数为 259.

  • 0x20 00 00 00 00 00 表示小数部分为 0 * 2(-1 次方) + 0*2(-2 次方) + 1 *2(-3 次方) = 0.125

  • 0x04 为结束标识。


上述流程位于 KeyString::_appendDoubleWithoutTypeBits 中,上述例子的每一部执行过程参考附录 B.

2.3  KeyString 性能

再回到我们最开始的问题,BSON 的比较太复杂导致性能不佳,所以诞生了 KeyString,那么:

1.  KeyString 的实际性能如何?

2.  相比 BSON Compare,KeyString 能有多大改善?

为了说明上述问题,我们对 KeyString 和 BSON 的比较性能进行性能测试。

2.3.1 测试方法

  1. 测试 2 条 BSON 文档,BSON compare 的平均耗时;

  2. 将 BSON 文档序列化成 KeyString(不包含 fieldName), 测试 2 个 KeyString compare 的平均耗时;

用于测试的 2 条 BSON 文档为:

第 1 条 BSON 文档:{"id":"zhenyitest", "description": "test performance of BSON compare", "int": NumberInt(1234), "long": NumberLong(123456789), "double1": 1.23456789, "double2":12345678.9}第 2 条 BSON 文档:{"id":"zhenyitest", "description": "test performance of BSON compare", "int": NumberInt(1234), "long": NumberLong(123456789), "double1": 1.23456789, "double2":12345678.8}
复制代码

值的注意的是,这 2 条 BSON 唯一的区别是最后一个 Double 类型的小数位不同。这样 BSON/KeyString 在比较时,会依次比较 String/Int/Long/Double 多种类型,并在最后一个 Double 类型上决一胜负。

2.3.2 测试工具

测试工具为 MongoDB 内核代码内置的 Benchmark 框架, 源于 Google Benchmark.

详细 Benchmark 代码参考附录 C.

2.3.3 结果分析

测试结果如下:

Run on (8 X 2992.97 MHz CPU s)----------------------------------------------------------------------------Benchmark                                     Time           CPU Iterations----------------------------------------------------------------------------BM_BSONCompare/threads:1                    173 ns        173 ns    4152481BM_BSON2KeyStringCompare/threads:1          689 ns        688 ns     840071BM_KeyStringCompare/threads:1                 6 ns          6 ns  122880289BM_BSON2KeyString/threads:1                 316 ns        315 ns    2218813BM_BSON2Strip/threads:1                     106 ns        105 ns    7145237BM_StripBSON2KeyString/threads:1            217 ns        215 ns    3229084
复制代码

BSON 直接比较平均耗时: 173 ns

2 个 BSON 都转成 KeyString 再比较:689 ns

  • 其中,KeyString 的比较只需要 6 ns , 但是每个 BSON 转 KeyString 需要 300+ ns

  • 其中转换的第一步:strip BSON 的 fieldName 耗时 100+ ns

  • 转换的第二步:BSON 转 KeyString 耗时 200+ns


总结:

  1. KeyString 的比较性能相比 BSON 提升了 1-2 个数量级;

  2. 但是 BSON 生成 KeyString 的代价比较大。因此适合 1 次生成多次比较的场景,索引就是最典型的场景;

  3. 对于非一次生成多次使用的场景,直接使用 BSON 进行比较反而更合适。比如内存排序(SORT_STAGE)比较算法就是直接使用的 BSON compare.


2.4  KeyString 在索引中的使用

2.4.1 MongoDB 索引的组织形式

MongoDB 中的索引,在 WiredTiger 存储引擎中使用 Key-Value 对的方式进行组织和存储。我们知道数据库中索引的作用,就是根据索引字段的值快速找到文档/数据行的存储位置,即建立索引字段到存储位置的映射。其中索引字段的值采用 KeyString 来表示,而存储位置则是由 RecordId 来表示。


KeyString 在本文已经有了详细描述,这里有必要说明一下 RecordId 的概念。我们知道 MongoDB 是 schema-free 的,每个表没有明确的主键,那么 MongoDB 表在底层 KV 引擎中怎么组织呢?或者说在 KV 引擎中存储的时候 Value 是 BSON 文档,那么 Key 是什么?

可能有人会反驳说 _id 是 MongoDB 中的主键,但我认为并不准确。

原因 1: MongoDB 并不使用 _id 来组织表,如果直接使用 _id 字段查数据,也要先查 _id 索引,再查表,通俗一点说就是要查 2 个 Btree;

原因 2:有些表没有 _id 字段(比如 oplog 表)。因此,_id 索引一个特殊的索引,自带唯一属性,行为上和二级索引类似。


为了解决没有主键的问题,MongoDB 在 KV 引擎中存储数据时,为每条文档指定了一个唯一的 RecordId. MongoDB 中每个表都有独立的 RecordId 命名空间,RecordId 本质上是一个从 1 自增的 int64 整数。MongoDB 使用 (RecordId, BSON) 的 KV 对形式在 KV 引擎中存储数据。

MongoDB 中使用索引查询数据会有 2 个阶段:

  1. 查索引,通过索引字段的 KeyString 找到对应的 RecordId;

  2. 查数据, 根据 RecordId 找到 BSON 文档。


RecordId 可以作为一个可选项在 KeyString 存储,一般会作为后缀的形式存在。在 Keystring 的编码上,并没有直接使用 8 字节存储 RecordId 的值,而是采用了变长编码的形式。规则如下:

  1. 头字节的头 3 个 bit,以及尾字节的末尾 3 个 bit, 存储了编码所需的额外字节数。由于 3 个 bit 最大能表示的数是 7,所以 RecordId 最多能占用的字节数为 7+2=9, 其中数据的有效 bit 占位为 98-(23) = 66。RecordId 最少占用的字节数为 2 字节;

  2. 除去头尾各 3 个 bit 外,其余的 bit 表示了 RecordId 的 int64 值;

举例如下,看看 RecordId(259) 和 RecordId(65536) 在 KeyString 中是如何编码的:


相关代码可以参考 KeyString::appendRecordId.

问题:为什么 RecordId 要使用变长编码,而不是直接用 8 字节表示?

我认为是 RecordId 作为一个从 1 开始自增的 id, 绝大多数情况下都不足 8 字节,甚至不足 4 字节。变长编码应该还是为了节省空间。


结合 官方文档 和代码,将 MongoDB 中索引的组织形式总结如下:

看完上表的内容,可能会有如下疑问:

1.  为什么 RecordId 要放在 Key 部分?

如果是根据 KeyString 找 RecordId,直观的想法是 KeyString 作为 Key,RecordId 作为 Value, 放在 KV 引擎中存储。当然可能还有 TypeBits 用于类型转换,和 RecordId 统一放在 Value 部分即可。

但是观察 non-unique index 可以发现 RecordId 是作为 KeyString 的后缀放在一起的。原因在于,KV 引擎中的 Key 是唯一的,但是索引中的 Key 是不唯一的,比如 {a: 1} 索引,很多文档的 "a" 字段都能等于 1,因此在存储索引的时候必须通过方法进行区分。 在 MongoDB 中,由于每个文档都有独立的 RecordId,因此将 RecordId 作为后缀能够实现将非唯一的索引 Key 存放在(Key 唯一)的 KV 引擎中。当然,带来的后果是在查询时需要通过__前缀匹配__来完成,因此查询时只知道索引字段,RecordId 是不知晓的。

2.  唯一索引为什么也是用 RecordId 作为后缀?

如果索引有唯一属性,就不存在上述第 1 个问题了。但是为什么 unique secondary index(new) 还是将 RecordId 作为 KeyString 的后缀呢?

的确,在早期 unique secondary index(old) 的设计中,是将 RecordId 放在 Value 部分的。但是问题在于,主从同步的时候,从节点的 oplog 回放是存在乱序的,回放时的乱序可能会临时打破索引的唯一性质,所以还是面临“非唯一” 到 “唯一”(KV 引擎)的映射问题

当然,回到 RecordId 后缀方式之后,索引唯一性的检查逻辑会更复杂。比如创建了唯一索引 {a: 1},有 2 条文档的 "a" 字段都是 “test”, 第 1 条文档的 RecordId 是 1,第 2 条文档的 RecordId 是 2。则第 1 条文档在索引中对应的 KeyString 是 "test1", 第 2 条文档是 "test2", 它们有相同的前缀 prefixKey = "test"。在唯一性检测时,会根据这个 prefixKey 来进行比对,参考 WiredTigerIndexUnique::_keyExists的实现。

另外,索引唯一性检测也要考虑并发的问题。比如上述例子中的 2 条文档如果是 2 个线程同时插入。线程 1 检测到没有相同的 prefixKey("test"),然后向索引中插入了 KeyString1("test1"),线程 2 也没有检测到相同的 prefixKey("test"),然后插入了 KeyString2("test2")。 2 个操作都能成功,然是最终的状态是不唯一的。为了解决这个问题,MongoDB 在检测索引唯一性之前,先往索引中插入一条 prefixKey 的记录(注意这里是不带 RecordId 后缀的),然后删除这条记录。如果此时有其他线程也插入+删除了相同的 prefixKey,会由于写冲突(writeConflict)导致整个事务提交失败。这样避免了多个相同 prefixKey 同时插入成功的问题,参考 WiredTigerIndexUnique::_insertTimestampSafe中的检测逻辑。

细心的同学可能发现,_id 也是(自带的)唯一索引,为啥就能把 RecordId 放在 Value 部分呢?因为 oplog 回放首先会通过 _id 进行哈希分桶,然后多个桶之间并发回放。也就是说 _id 不会存在乱序回放的问题,因此将 RecordId 挡在 Value 部分是没问题的。


2.4.2 举例说明索引的查找过程

在一个空表中插入 3 条数据 {"a":NumberInt(1), "b":"1"}, {"a":NumberInt(2), "b":"2"}, {"a":NumberInt(3), "b":"3"}. 并分别创建 {"a":1} 索引和 {"a":1,"b":1} 复合索引。

表和索引中的数据存储方式如下:

在测试索引之前,首先需要明确的一点是:扫描索引的目的,是依据索引字段的 Value 找到符合要求的 RecordId。也就是说,用户请求在扫描索引时,只有索引 Key 对应的 Value 信息,并不能提前预知 RecordId,而二级索引在进行 KV 存储时,是将 RecordId 作为 Key 的后缀存储的。 因此,对于二级索引的扫描操作,都是前缀扫描。

对于一个查询操作,比如 find({a:2}), MongoDB 首先会选择合适的索引,然后生成扫描索引的范围。比如在 {"a":1,"b":1} 索引上的扫描范围是:

"indexBounds" : {    "a" : [        "[2.0, 2.0]"    ],    "b" : [        "[MinKey, MaxKey]"    ]}
复制代码

索引扫描的起始位置:0x2B040A0104.

索引扫描的结束位置:0x2B04F0FE04. 每个字节的含义如下:

2B -> 表示 1 字节的整数类型04 -> 数字 2,然后左移1位进行表示0A-> 表示类型 minKeyF0-> 表示类型 maxKey01-> 表示 discriminator LessFE -> 表示discriminator Greater04-> end 终止符
复制代码

确定了起始位置和结束位置之后,可以对索引进行扫描,示意图如下(从小到大排列):

0x2B023C3100040008 -> 第 1 条数据对应的索引 key0x2B040A0104  -> 索引扫描的起始位置0x2B043C3200040010 -> 第 2 条数据对应的索引 key0x2B04F00104  -> 索引扫描的结束位置0x2B063C3300040018 -> 第 3 条数据对应的索引 key
复制代码

在存储引擎中扫描后,得到符合条件的是第 2 条数据对应的索引,通过对应的索引 key 可以得到 RecordId,然后再去获取文档。

同理,对于上述 find({a:2}) 查询,如果使用的是 {a:1} 单字段索引,则扫描的索引范围如下:

"indexBounds" : {    "a" : [        "[2.0, 2.0]"    ]}
复制代码

扫描的过程如下(从小到大排列):

0x2B02040008  -> 第 1 条数据对应的索引 key0x2B040104  -> 索引扫描的起始位置0x2B04040010 -> 第 2 条数据对应的索引 key0x2B04FE04  -> 索引扫描的结束位置0x2B06040018 -> 第 3 条数据对应的索引 key
复制代码

最终找到符合条件的是第 2 条数据。

另外,我们可以看到,在确定索引扫描起止位置时,分别添加了 discriminator: 0x01 或者 0xFE, 在索引的前缀扫描机制中发挥了重要作用。

2.5  小结

  1. 文档数据库涉及到很多比较 BSON 大小的场景。由于 BSON 比较流程复杂,并且包含了很多类型转换操作,导致性能不佳。

  2. Keystring 通过优秀的编码方式,将 BSON 比较流程转换为 memcmp 二进制比较流程,使性能提升 1-2 个数量级。

  3. Keystring 是 MongoDB 索引的基石,通过巧妙的设计能高效处理各种索引的使用场景。


3. 文档模型与存储引擎

3.1  MongoDB 内核架构

引用官方文档的架构图如下:

如图所示,用户使用 MongoDB query language 进行数据的增删查改操作,MongoDB 服务侧生成对应的执行计划,然后调用 Document Model 提供的接口进行数据处理,并将处理后的结果返回给用户端。如果涉及到数据变更,还会将新生成的数据序列化之后存储到底层的 KV 存储引擎。


没有一个存储引擎能完美适配所有的业务场景。当业务在选择合适的数据库时,除了数据库的稳定性、易用性、流行度、基准测试性能等方面的考虑外,还会根据自身的业务特点并结合存储引擎的特性进行分析和压测。比如使用机械硬盘存储日志类的场景,可能会优先考虑 LSMTree 类型的存储引擎;对于使用大内存、NVMe-SSD 并存在大量随机读写的场景,可能会更倾向于使用 Btree 类的存储引擎。

为了适配更多的业务场景,很多数据库产品都支持插件式存储引擎,比如 MySQL 就支持 MyIASM、InnoDB、RocksDB 等多种存储引擎。

MongoDB 从 3.x 版本开始,也通过插件式存储引擎的形式,支持了越来越多的存储引擎。比如官方文档中公布的存储引擎如下所示:

另外,MongoDB 在 3.x 版本也支持 RocksDB 引擎。对于存储日志、账单类型的业务,以及使用机械硬盘的业务场景有比较大的性能优势。 使用方式可以参考 MongoRocks 的介绍。 但是需要说明的是 RocksDB 目前还不是 MongoDB 官方支持的存储引擎。


MongoDB 为了支持插件式存储引擎,定义了一个虚基类 KVEngine 规定 KV 存储引擎接入 MongoDB 的标准,具体规范了建表、建索引、获取引擎元数据等操作的接口。另外,也定义了虚基类 RecordStore 定义了表的操作接口,以及虚基类 SortedDataInterface 定义了索引的操作接口, SnapshotManager 用于 snapshot 管理。

以 WiredTiger 引擎为例,通过 WiredTigerKVEngine 实现存储引擎的接入接口,通过 WiredTigerRecordStoreWiredTigerIndex 实现了表和索引的操作接口,通过 WiredTigerSnapshotManager 实现了 snapshot 的管理机制。

上述相关代码存放在代码目录 "src/mongo/db/storage/wiredtiger/" 中,独立于 WiredTiger 引擎的代码。可以看作是 MongoServer 和底层存储引擎之间的接口层。这个接口层实现了 MongoDB 规定的接口,并调用底层存储引擎的 API 完成具体的数据操作。

而 Wiredtiger 引擎本身有独立的代码仓库,放在 thirdparty 目录下,和 MongoDB 代码进行联合编译。


同理,RocksDB 引擎也是独立的代码仓库,可以通过 MongoRocks 这个接口层接入到 MongoDB 中。在 MongoRocks 中也对应的通过 RocksEngine 实现了存储引擎的接入接口,通过 RocksRecordStoreRocksIndexBase 实现了表和索引的操作接口,通过 RocksSnapshotManager 实现了 snapshot 的管理操作接口。


3.2  MongoDB 如何使用 WiredTiger 存储数据

从前面的分析可以发现,KV 存储引擎是不感知 BSON 文档模型的。 在 KV 引擎中存储的数据有:

  1. recordId: int64 类型,作为表在 KV 引擎中存储时的 Key;

  2. BSON 文档序列化后的字符串: String 或者 byte[] 类型,作为表在 KV 引擎中存储时的 Value;

  3. KeyString: String 或者 byte[] 类型,作为索引在 KV 引擎中存储时的 Key;

  4. (KeyString 的 )typeBits: String 或者 byte[] 类型,作为索引在 KV 引擎中存储时的 Value;

MongoDB 中的表和索引,对应到 Wirediger 存储引擎中都是 wt table. 由于 MongoDB 中表和索引存储的数据类型有所区别,因此在定义 wt table 的 schema 时也会不同。

WiredTiger 目前支持的存储类型 有:



对于 MongoDB 表来说,存储的数据是 recordId -> BSON 文档,因此对应的 wt table schema 为 "key_format=q, value_format=u".

对于 MongoDB 索引来说,存储的数据时 KeyString-> typeBits, 因此对应的 wt table schema 为 "key_format=u, value_format=u".

除了上述 Key/Value 的类型之外,在定义 wt table 时还可以指定 叶子节点大小、数据块对齐大小、数据块分配和压缩算法等参数。详细的配置参数和说明,可以参考 WT 官方文档

MongoDB 在建表和建索引时具体指定的 schema 参数可以参考 WiredTigerRecordStore::generateCreateStringWiredTigerIndex::generateCreateString.

Tips:

对于 MongoDB 用户来说,可以在连接上 MongoDB 节点之后,通过 db.colltest.stats().wiredTiger.creationString 命令来查看建表时的各项参数,举例如下:

access_pattern_hint=none,allocation_size=4KB,app_metadata=(formatVersion=1),assert=(commit_timestamp=none,read_timestamp=none),block_allocation=best,block_compressor=snappy,cache_resident=false,checksum=on,colgroups=,collator=,columns=,dictionary=0,encryption=(keyid=,name=),exclusive=false,extractor=,format=btree,huffman_key=,huffman_value=,ignore_in_memory_cache_size=false,immutable=false,internal_item_max=0,internal_key_max=0,internal_key_truncate=true,internal_page_max=4KB,key_format=q,key_gap=10,leaf_item_max=0,leaf_key_max=0,leaf_page_max=32KB,leaf_value_max=64MB,log=(enabled=false),lsm=(auto_throttle=true,bloom=true,bloom_bit_count=16,bloom_config=,bloom_hash_count=8,bloom_oldest=false,chunk_count_limit=0,chunk_max=5GB,chunk_size=10MB,merge_custom=(prefix=,start_generation=0,suffix=),merge_max=15,merge_min=0),memory_page_image_max=0,memory_page_max=10m,os_cache_dirty_max=0,os_cache_max=0,prefix_compression=false,prefix_compression_min=4,source=,split_deepen_min_child=0,split_deepen_per_child=0,split_pct=90,type=file,value_format=u

3.3  WiredTiger 存储引擎架构简介

参考官方文档,WiredTiger 引擎的整体架构如下:



涉及到的一些重要组件和概念包括:

  • Schema: 引擎中存储的数据格式,其中定义了 Key 和 Value 的类型。

  • Metadata: WiredTiger 引擎的元数据,定义了有哪些表以及对应的文件,Checkpoint 信息等,使用 WiredTiger.wt 这个特殊的表进行存储。

  • Cursor: WiredTiger 对外的数据接口都通过 Cursor 执行,Cursor 指向了表中的某个位置,Cursor 给上层提供了按 Key 检索,迭代,读写数据等操作接口。

  • Dhandle:描述了一个资源的句柄,通俗理解为一个 'wt table'. 一般指向一个 Btree;

  • Btree: WiredTiger 中存储表的一种格式,其中 root page 和 internal page 只存储 key 和下一层 page 的指针,leaf page 存储用户写入的 KV 数据,Btree 是一个按 key 有序的数据结构。WiredTiger 中的 Btree 节点在内存中的表现形式与磁盘上的表现形式有所区别。

  • Transaction: 事务接口。

  • Snapshot: WiredTiger 使用 snapshot 进行多版本并发控制,snapshot 定义了哪些版本的数据对事务可见,哪些版本不可见。

  • Row/Column Store: MongoDB 使用 WiredTiger 引擎的 row store 模式。

  • Cache: WiredTiger 引擎使用内存来缓存最近访问和修改的 page,当 cache 使用率超过设定的阈值时,会通过 eviction 和 checkpoint 操作进行清理。

  • Eviction: 当 Cache 使用率或者脏页比率达到设定的阈值后,会通过 eviction 流程选择合适的 page 刷到磁盘中,并释放内存空间。

  • History Store: 在磁盘上维护数据的历史版本信息,这些历史版本信息可能还会被使用,比如一个长耗时的事务。在 4.0 版本中也叫 Look Aside Table,4.2 之后的版本统计叫 History Store, 使用 WiredTigerHS.wt 这个特殊的表进行持久化存储。个人理解有点类似 InnoDB 中的 undo log.

  • Block Manager: 内存中的 page 对应到磁盘上就是一个 block,因此 block manager 就是用来管理磁盘文件的,提供了文件的读写,extend, truncate 等接口。

  • Logging: 和 WAL(Write ahead log)、journal、redo log 等名词都是一个意思,保证了事务的持久性。在出现异常宕机时,可以通过 checkpoint + WAL 的机制将数据库恢复到最近时间的一致性状态。

  • File System & OS interface: 提供了跨平台(Linux/Windows 等)的文件操作接口和系统操作接口。

3.4  MongoDB 如何使用 WT 引擎存储数据

创建一个 4.0 版本的副本集实例并初始化配置,然后在数据库 db1 下创建表 coll1 并插入数据。可以在 dbPath 目录下看到如下目录和文件:

  • admin 目录:自带的系统库 admin, 其中包含了 system.users 表存储 user 信息, system.roles 表存储用户自建的 role 信息,system.version 存储当前使用的协议版本号。非常不建议用户在 admin 库下建表存储业务数据,由于 SERVER-16092 的原因,admin 库下无法实现并发修改,所有对 admin 库的修改操作都只能串行执行。这个限制直到 5.0 版本才放开,参考 SERVER-48878 .

  • config 目录:自带的系统库 config, 其中包含了 system.sessions 表存储 session 信息,transactions 表存储事务状态信息,对于分片集群来说,还会存储路由表信息。

  • local 目录:自带的系统库 local, 其中 system.replset 表会存储副本集配置信息,oplog.rs 表会存储 oplog. 在设计上, local 数据库是每个 mongod 节点私有的,不会参与主从复制,因此也千万不要在 local 下存储业务数据。

  • journal 目录:存储引擎的 WAL(Write Ahead Log).

  • diagnostic.data 目录:mongod 每秒会将节点的进行状态转储到该目录下的文件中,可以用于故障定位和问题分析。更多细节可以参考官方文档中关于Full-Time Diagnostic Data Capture的描述

  • db1 目录:用户自己创建的数据库。如果配置文件中将 storage.directoryPerDB 设置为 true, 则每次创建的数据库都会有独立的目录。该数据库下创建的表和索引都会存储在这个目录中,比如 collection-24-5744306567866307342.wt 是 coll1 的表文件,index-25-5744306567866307342.wt 是 coll1 表 _id 索引对应的索引文件。

  • _mdb_catalog.wt 文件:记录了 MongoDB 中库表的属性信息,比如 db1.coll1 表对应的哪个表文件,包含了哪些索引以及对应的索引文件又是哪些等。假设在 db1 库下创建了 coll1 表,并创建了 "a" 字段的索引,则 _mdb_catalog.wt 中会记录如下信息。

{    "md": {        "ns": "db1.coll1",        "options": {            "uuid": {                "$binary": {                    "base64": "IW2WNwG9TbW0T3eUOO8ClA==",                    "subType": "04"                }            }        },        "indexes": [            {                "spec": {                    "v": {                        "$numberInt": "2"                    },                    "key": {                        "_id": {                            "$numberInt": "1"                        }                    },                    "name": "_id_",                    "ns": "db1.coll1"                },                "ready": true,                "multikey": false,                "multikeyPaths": {                    "_id": {                        "$binary": {                            "base64": "AA==",                            "subType": "00"                        }                    }                },                "head": {                    "$numberLong": "0"                },                "prefix": {                    "$numberLong": "-1"                },                "backgroundSecondary": false,                "runTwoPhaseBuild": false,                "versionOfBuild": {                    "$numberLong": "1"                }            },            {                "spec": {                    "v": {                        "$numberInt": "2"                    },                    "key": {                        "a": "hashed"                    },                    "name": "a_hashed",                    "ns": "db1.coll1"                },                "ready": true,                "multikey": false,                "head": {                    "$numberLong": "0"                },                "prefix": {                    "$numberLong": "-1"                },                "backgroundSecondary": false,                "runTwoPhaseBuild": false,                "versionOfBuild": {                    "$numberLong": "1"                }            }        ],        "prefix": {            "$numberLong": "-1"        }    },    "idxIdent": {        "_id_": "db1/index-33--3130855521305933865",        "a_hashed": "db1/index-34--3130855521305933865"    },    "ns": "db1.coll1",    "ident": "db1/collection-32--3130855521305933865"}
复制代码
  • mongod.lock:锁文件,存储 mongod 的进程 id.

  • mongod.pid: 当前 mongod 的进程 id.

  • sizeStorer.wt: MongoDB 中每个表的文档条数和逻辑大小。通过单独存储表的文档条数和逻辑大小,可以大大加快 count 和 stats 命令的执行速度,避免全表扫描。

  • storage.bson:存储配置,比如使用的哪个存储引擎,是否有开启 directoryPerDB 参数等。举例如下:

[root@VM-24-2-opencloudos db]# bsondump storage.bson {"storage":{"engine":"wiredTiger","options":{"directoryPerDB":true,"directoryForIndexes":false,"groupCollections":false}}}
复制代码
  • WiredTiger:当前运行的 Wirediger 版本信息。举例如下:

[root@VM-24-2-opencloudos db]# cat WiredTigerWiredTigerWiredTiger 3.3.0: (March 20, 2020)
复制代码
  • WiredTigerLAS.wt:WiredTiger 引擎中存储数据的历史版本信息,在 4.2 版本之后统一改为了 History Store.

  • WiredTiger.lock:锁文件。

  • WiredTiger.turtle:文本信息,存储 WiredTiger.wt 元数据表的 checkpoint 信息。举例如下:

WiredTiger version stringWiredTiger 3.2.2: (August 28, 2019)WiredTiger versionmajor=3,minor=2,patch=2file:WiredTiger.wtaccess_pattern_hint=none,allocation_size=4KB,app_metadata=,assert=(commit_timestamp=none,durable_timestamp=none,read_timestamp=none),block_allocation=best,block_compressor=,cache_resident=false,checksum=uncompressed,collator=,columns=,dictionary=0,encryption=(keyid=,name=),format=btree,huffman_key=,huffman_value=,id=0,ignore_in_memory_cache_size=false,internal_item_max=0,internal_key_max=0,internal_key_truncate=true,internal_page_max=4KB,key_format=S,key_gap=10,leaf_item_max=0,leaf_key_max=0,leaf_page_max=32KB,leaf_value_max=0,log=(enabled=true),memory_page_image_max=0,memory_page_max=5MB,os_cache_dirty_max=0,os_cache_max=0,prefix_compression=false,prefix_compression_min=4,split_deepen_min_child=0,split_deepen_per_child=0,split_pct=90,value_format=S,version=(major=1,minor=1),checkpoint=(WiredTigerCheckpoint.95364=(addr="018081e41de13b488181e423dffcd88281e4f901b406808080e3020fc0e2dfc0",order=95364,time=1727511146,size=69632,newest_durable_ts=0,oldest_start_ts=0,oldest_start_txn=0,newest_stop_ts=-1,newest_stop_txn=-11,write_gen=404307)),checkpoint_backup_info=,checkpoint_lsn=(4294967295,2147483647)
复制代码
  • WiredTiger.wt:WiredTiger 引擎的元数据表,存储了每个 Wirediger 表对应的配置属性、checkpoint 等信息。假设有一个 "access" 表,则 WiredTiger.wt 中会记录如下信息:


了解了基本的文件组织架构之后,下面看看一个 WT 表是如何读写的,在内存和磁盘上的表现形式如何。

4.0 版本的架构如下(高版本引擎在细节上会有些区别,但是整体架构一样):

对于每个表来说:

  1. 在内存中是一个 btree 结构,其中只有 leaf page 存储具体数据。存储的是未压缩未加密的原始数据。

  2. 在磁盘上对应了一个 xxx.wt 文件,每个 page 在磁盘上的表现形式为一个 block,并默认按照 4KB 对齐。每个 block 可以由一个 extent(offset+length) 进行标志。磁盘上存储的是压缩加密后的数据,因此在加载到内存中时需要进行转换。磁盘文件中的 block 有不同的状态,通过 allocation + available + discard 共 3 个 skiplist 进行维护。

  3. 每个表都有对应的 checkpoint 信息,集中存放在 WiredTiger.wt 这个特殊的元数据表中,checkpoint 信息包含了 root page 的位置,对应的文件长度,block skiplist 信息。checkpoint 信息可以方便节点重启时快速找到一致性点。

3.5 小结

  1. MongoDB 通过分层的架构设计将文档模型和存储引擎隔离开,底层 KV 存储引擎不需要理解 BSON 文档模型的细节。

  2. 通过抽象存储引擎接口,实现了插件式存储引擎。通过支持 Memory, Mmap, WiredTiger,RocksDB 等多种存储引擎,使得 MongoDB 能够驾驭更多的业务场景。

  3. WiredTiger 存储引擎通过 Btree 实现了高效的数据读写,通过 BlockManager 实现了高效率的磁盘使用,通过 CheckPoint + journal 机制保证了数据安全,通过 MVCC 提供了高效的并发访问。


4. 总结与思考

文档模型和存储引擎是文档数据库的核心组件。

MongoDB 通过优化后的 BSON 文档很好地支持了灵活的数据类型,并通过 KeyString 编码解决了 BSON 在存储引擎中的检索问题。

MongoDB 通过插件式存储引擎来支持多种业务类型,包括官方支持的存储引擎 Mmap(已淘汰),WiredTiger,Memory(企业版支持) 和非官方支持的存储引擎 RocksDB(4.0 版本以下)。

WiredTiger 凭借优秀的读写性能(cache, MVCC 等机制)、完善的功能(事务,snapshot 等机制)、高效的存储使用率(snappy,zlib,zstd 等压缩算法)、数据安全性(checkpoint, journal, 加密等机制)在 MongoDB 3.2 版本成为官方默认的存储引擎并延续至今。


5. 附录

附录 A:数值子类型定义

参考代码定义,数值子类型从小到大的顺序如下:

const uint8_t kNumericNaN = kNumeric + 0;// 小于 Long 能表示的最小负数,在这个范围内, Double 和 Long 没有交集,此时可以直接通过类型区分。// <= -2**63 including -Infconst uint8_t kNumericNegativeLargeMagnitude = kNumeric + 1;  // 有效字节为 8 字节的负数 Long, 或者整数部分有效字节为 8 字节的负数 Doubleconst uint8_t kNumericNegative8ByteInt = kNumeric + 2;// 有效字节为 7 字节的负数 Long, 或者整数部分有效字节为 7 字节的负数 Doubleconst uint8_t kNumericNegative7ByteInt = kNumeric + 3;// 有效字节为 6 字节的负数 Long, 或者整数部分有效字节为 6 字节的负数 Doubleconst uint8_t kNumericNegative6ByteInt = kNumeric + 4;// 有效字节为 5 字节的负数 Long, 或者整数部分有效字节为 5 字节的负数 Doubleconst uint8_t kNumericNegative5ByteInt = kNumeric + 5;// 有效字节为 4 字节的负数 Long, 或者整数部分有效字节为 4 字节的负数 Doubleconst uint8_t kNumericNegative4ByteInt = kNumeric + 6;// 有效字节为 3 字节的负数 Long, 或者整数部分有效字节为 3 字节的负数 Doubleconst uint8_t kNumericNegative3ByteInt = kNumeric + 7;// 有效字节为 2 字节的负数 Long, 或者整数部分有效字节为 2 字节的负数 Doubleconst uint8_t kNumericNegative2ByteInt = kNumeric + 8;// 有效字节为 1 字节的负数 Long, 或者整数部分有效字节为 1 字节的负数 Doubleconst uint8_t kNumericNegative1ByteInt = kNumeric + 9;// (-1, 0) 之间的 Doubleconst uint8_t kNumericNegativeSmallMagnitude = kNumeric + 10;  // 0 const uint8_t kNumericZero = kNumeric + 11;// (0, 1) 之间的 Doubleconst uint8_t kNumericPositiveSmallMagnitude = kNumeric + 12;// 有效字节为 1 字节的正数 Long, 或者整数部分有效字节为 1 字节的正数 Doubleconst uint8_t kNumericPositive1ByteInt = kNumeric + 13;// 有效字节为 2 字节的正数 Long, 或者整数部分有效字节为 2 字节的正数 Doubleconst uint8_t kNumericPositive2ByteInt = kNumeric + 14;// 有效字节为 3 字节的正数 Long, 或者整数部分有效字节为 3 字节的正数 Doubleconst uint8_t kNumericPositive3ByteInt = kNumeric + 15;// 有效字节为 4 字节的正数 Long, 或者整数部分有效字节为 4 字节的正数 Doubleconst uint8_t kNumericPositive4ByteInt = kNumeric + 16;// 有效字节为 5 字节的正数 Long, 或者整数部分有效字节为 5 字节的正数 Doubleconst uint8_t kNumericPositive5ByteInt = kNumeric + 17;// 有效字节为 6 字节的正数 Long, 或者整数部分有效字节为 6 字节的正数 Doubleconst uint8_t kNumericPositive6ByteInt = kNumeric + 18;// 有效字节为 7 字节的正数 Long, 或者整数部分有效字节为 7 字节的正数 Doubleconst uint8_t kNumericPositive7ByteInt = kNumeric + 19;// 有效字节为 8 字节的正数 Long, 或者整数部分有效字节为 8 字节的正数 Doubleconst uint8_t kNumericPositive8ByteInt = kNumeric + 20;// 大于 Long 能表示的最大正数,在此范围内 Long 和 Double 不再有交集,可以直接通过类型进行区分// >= 2**63 including +Infconst uint8_t kNumericPositiveLargeMagnitude = kNumeric + 21;  
复制代码

附录 B:129.125 编码成 KeyString 的流程

//integerPart= 129, fractionalBytes = 6const size_t fractionalBytes = countLeadingZeros64(integerPart << 1) / 8;//ctype=44const auto ctype = isNegative ? CType::kNumericNegative8ByteInt + fractionalBytes                              : CType::kNumericPositive8ByteInt - fractionalBytes;_append(static_cast<uint8_t>(ctype), invert);
// Multiplying the double by 256 to the power X is logically equivalent to shifting the// fraction left by X bytes.// encoding = 36345456367763456, 0x 00 00 00 00 00 20 81 00uint64_t encoding = static_cast<uint64_t>(magnitude * kPow256[fractionalBytes]);dassert(encoding == magnitude * kPow256[fractionalBytes]);
// Merge in the bit indicating the value has a fractional part by doubling the integer// part and adding 1. This leaves encoding with the high 8-fractionalBytes bytes in the// same form they'd have with _appendPreshiftedIntegerPortion(). The remaining low bytes// are the fractional bytes left-shifted by 2 bits to make room for the DCM.// encoding = 72937203340148736, 0x 00 00 00 00 00 20 03 01encoding += (integerPart + 1) << (fractionalBytes * 8);invariant((encoding & 0x3ULL) == 0);// encoding = 72937203340148736encoding |= dcm;// encoding = 2097921, 0x 01 03 20 00 00 00 00 00encoding = endian::nativeToBig(encoding);_append(encoding, isNegative ? !invert : invert);
复制代码

附录 C:KeyString 和 BSON compare 流程的 Benchmark 代码

BSONObj bson1 = BSON("id" << "zhenyitest"                    << "description" << "test performance of BSON compare"                    << "int" << int(1234)                    << "long" << long(123456789)                    << "double1" << double(1.23456789)                    << "double2" << double(12345678.9));  // 只有最后的字段不同BSONObj bson2 = BSON("id" << "zhenyitest"                    << "description" << "test performance of BSON compare"                    << "int" << int(1234)                    << "long" << long(123456789)                    << "double1" << double(1.23456789)                    << "double2" << double(12345678.8));  // 只有最后的字段不同
void BM_BSONCompare(benchmark::State& state) { if (state.thread_index == 0) { // Nothing } for (auto keepRunning : state) { bson1.woCompare(bson2); } if (state.thread_index == 0) { // Nothing }}BENCHMARK(BM_BSONCompare) ->ThreadRange(1, 1);
void BM_BSON2KeyStringCompare(benchmark::State& state) { if (state.thread_index == 0) { // Nothing } for (auto keepRunning : state) { // bson1 BSONObjBuilder strippedKeyValue1; for (const auto& elem : bson1) { strippedKeyValue1.appendAs(elem, ""_sd); } KeyString ks1(KeyString::Version::V1, strippedKeyValue1.done(), Ordering::make(BSONObj())/*ALL_ASCENDING*/); // bson2 BSONObjBuilder strippedKeyValue2; for (const auto& elem : bson2) { strippedKeyValue2.appendAs(elem, ""_sd); } KeyString ks2(KeyString::Version::V1, strippedKeyValue2.done(), Ordering::make(BSONObj())/*ALL_ASCENDING*/);
ks1.compare(ks2); } if (state.thread_index == 0) { // Nothing }}BENCHMARK(BM_BSON2KeyStringCompare) ->ThreadRange(1, 1);
void BM_KeyStringCompare(benchmark::State& state) { KeyString ks1(KeyString::Version::V1); KeyString ks2(KeyString::Version::V1); if (state.thread_index == 0) { // bson1 BSONObjBuilder strippedKeyValue1; for (const auto& elem : bson1) { strippedKeyValue1.appendAs(elem, ""_sd); } ks1.resetToKey(strippedKeyValue1.done(), Ordering::make(BSONObj())/*ALL_ASCENDING*/); // bson2 BSONObjBuilder strippedKeyValue2; for (const auto& elem : bson2) { strippedKeyValue2.appendAs(elem, ""_sd); } ks2.resetToKey(strippedKeyValue2.done(), Ordering::make(BSONObj())/*ALL_ASCENDING*/); } for (auto keepRunning : state) { ks1.compare(ks2); } if (state.thread_index == 0) { // Nothing }}BENCHMARK(BM_KeyStringCompare) ->ThreadRange(1, 1);
void BM_BSON2KeyString(benchmark::State& state) { if (state.thread_index == 0) { // Nothing } for (auto keepRunning : state) { BSONObjBuilder strippedKeyValue; for (const auto& elem : bson1) { strippedKeyValue.appendAs(elem, ""_sd); } KeyString ks(KeyString::Version::V1, strippedKeyValue.done(), Ordering::make(BSONObj())/*ALL_ASCENDING*/); } if (state.thread_index == 0) { // Nothing }}BENCHMARK(BM_BSON2KeyString) ->ThreadRange(1, 1);
void BM_BSON2Strip(benchmark::State& state) { if (state.thread_index == 0) { // Nothing } for (auto keepRunning : state) { BSONObjBuilder strippedKeyValue; for (const auto& elem : bson1) { strippedKeyValue.appendAs(elem, ""_sd); } strippedKeyValue.done(); } if (state.thread_index == 0) { // Nothing }}BENCHMARK(BM_BSON2Strip) ->ThreadRange(1, 1);
void BM_StripBSON2KeyString(benchmark::State& state) { BSONObj stripBSON; if (state.thread_index == 0) { BSONObjBuilder strippedKeyValue; for (const auto& elem : bson1) { strippedKeyValue.appendAs(elem, ""_sd); } stripBSON = strippedKeyValue.done(); } for (auto keepRunning : state) { KeyString ks(KeyString::Version::V1, stripBSON, Ordering::make(BSONObj())/*ALL_ASCENDING*/); } if (state.thread_index == 0) { // Nothing }}BENCHMARK(BM_StripBSON2KeyString) ->ThreadRange(1, 1);
复制代码

6. 参考文档

1.  https://github.com/mongodb/mongo/tree/r4.2.21

2.  https://bsonspec.org

3.  https://www.mongodb.com/json-and-bson

4.  https://github.com/pengzhenyi2015/bsonjson

5.  https://github.com/mongodb/mongo/blob/r4.0.28

6.  https://github.com/mongodb/mongo/blob/r5.0.0/src/mongo/db/catalog/README.md

7.  http://source.wiredtiger.com/2.0.1/tuning.html

8.  https://developer.aliyun.com/article/66848

9.  https://www.mongodb.com/blog/post/building-applications-with-mongodbs-pluggable-storage-engines-part-1

10.  https://github.com/mongodb-partners/mongo-rocks

11.  https://github.com/mongodb/mongo/blob/master/src/mongo/db/ftdc/README.md

12.  https://jira.mongodb.org/browse/SERVER-16092

13.  https://source.wiredtiger.com/develop/arch-index.html

14.  https://www.postgresql.org/docs/current/datatype-json.html

15.  https://dev.mysql.com/doc/refman/8.0/en/json.html

发布于: 刚刚阅读数: 3
用户头像

彭振翼

关注

路漫漫其修远兮,吾将上下而求索 2023-12-13 加入

后端技术爱好者,熟悉 MongoDB、PostgreSQL、Kafka、Flink

评论

发布
暂无评论
深入理解 MongoDB 文档模型_mongodb_彭振翼_InfoQ写作社区