五天玩转MongoDB训练营:MongoDB基础及原理介绍
MongoDB基础及原理介绍
内容介绍
一、 什么是WiredTiger?
二、 WriteConcern写关注
一、什么是WiredTiger
自我介绍一下,我是阿里云的讲师,目前担任技术咨询顾问的职位。自2016年以来一直从事与MongoDB相关的工作,到现在已经有了近七年的时间。
今天我要与大家分享的主题是关于缓存与持久化,因为我发现,无论是哪个客户或行业,都经常关注这些内容。那么为什么大家都对这个话题感兴趣呢?
主要是为了弄清楚,MongoDB是否适合当前的项目,它是否能够提供足够的安全性?又或者,它的机制是什么,使得集群的读写性能如此强大?
在这里不得不提的是MongoDB使用的存储引擎。对于那些有一定基础的人来说,可能已经很熟悉了,但对于不太了解的人来说,可能会问它到底是什么。
现在让我们来了解一下MongoDB的历史,MongoDB的存储引擎开发始于2008年,其创始人之前曾参与并设计过高度MongoDB。而MongoDB公司在2006年被另一家公司收购了一部分。该公司在2014年收购了Y公司,并将其默认存储引擎更改为1.80。MongoDB从版本3.0开始,MongoDB支持存储引擎API,将存储引擎与上层功能分离。
通过API调用,MongoDB将存储引擎与其他功能整合在一起。在早期版本中,阿里云的MongoDB支持了B和F存储引擎,分别增强了数据写入能力和数据高压缩能力。
既然存储引擎与其他功能分离,那么它们各自的职责是什么呢?
简而言之,mongod负责传统意义上的启动进程、建立连接以及某些查询引擎,它还负责选举、复制以及支持架构上的分片扩展能力。而更底层的数据管理由MCC和内存使用等组件来处理,这就是我们熟知的MCC和内存的使用。
让我们首先了解一下MongoDB存储引擎的任务。
存储引擎的工作包括将数据从磁盘读入内存,以供应用程序访问,或者将应用程序修改的数据从内存写入磁盘。存储引擎的数据是以一种结构化形式存储的,不同于我们通常理解的文档或其他数据结构。
从MongoDB的4.0版本开始,它已经实现了对事务的支持,这主要是通过MVCC(多版本并发控制)来实现并发操作的。与其他数据库的锁定并发机制不同,MVCC实现了一种乐观并发机制。
MVCC通过在内存中维护多个版本的行数据来工作,这意味着它会以不同版本号的形式保存对同一行记录的多个写操作,以支持并发事务,它的出色性能部分源于其无锁设计,与其他传统数据库中常见的锁概念不同,MongoDB通过MVCC和其他设计来避免了锁的使用。复杂性越低,性能通常越好,通过MVCC以及MongoDB自身的特性,正在读取的数据可以与正在写入的数据分离开来。
此外,MongoDB在磁盘上的数据文件经过压缩,因此存储引擎具有一定的压缩能力。这个压缩算法的级别是可选和可调整的,但默认情况下在大多数情况下表现最佳,同时也能获得高压缩比。根据经验,它可以将原始数据压缩到原始大小的一半或三分之一。
具体的压缩比例取决于数据文件中的内容,在MongoDB启动时,首先会从整个内存中划分出一部分,分配给引擎的内部缓存,用于构建B树中的各个配置,并基于这些配置执行增加、修改和查询操作。
主机剩余的内存则用作文件系统缓存,这可以将压缩的磁盘文件缓存到内存中,从而减少磁盘I/O的负载。
WiredTiger使用BTree结构来组织数据,每个BTree节点都是一个配置。根配置是BTree的根节点,中间配置是BTree的中间索引节点,而叶子配置是真正存储数据的叶子节点。
BTree的数据以配置为单位,按需从磁盘加载或写入磁盘。对于存储引擎来说,集合所在的数据文件和相应的存储索引文件都是以v结构组织的。不同之处在于,数据文件的BTree叶子节点上存储着集合数据,而索引文件则包含了索引信息。
关于MongoDB数据文件在存储中的格式,您可以查看数据配置文件,找到数据库的参数对应的目录,进入该目录,您会发现以"collection"开头并以"wt"结尾的文件,这些文件存储着集合数据。同样,以"index"开头并以"wt"结尾的文件是索引文件。此外,还有一些文件存储在数据目录中,例如".wt",这些是用于自身元数据的文件。
还有一个非常重要的文件,它存储了MongoDB表的定义元数据,叫做"wt"。除此之外,还有另一个文件,叫做"Wt",它存储了数据的大小和条目数等元数据信息。MongoDB的持久化依赖于两个关键机制:快照和预写日志。
首先,让我们了解一下快照。快照代表内存中的连续数据段,当数据写入磁盘时,会将快照的数据持续写入到磁盘,形成当前可用数据的一个检查点,这个检查点保证数据文件的连续性,并包含了上一个检查点的数据,因此检查点可以被视为一种恢复点。在创建新的检查点时,之前的检查点仍然有效。因此,即使在写入新的检查点时发生错误,MongoDB也可以从上一个有效的检查点恢复数据。只有在元数据表自动更新并指向新的检查点后,新的检查点才可用。一旦新的检查点可用,之前的检查点的存储空间就会被释放,这意味着这部分数据已经完全持久化到磁盘,除非磁盘损坏,否则这部分数据不会丢失。
那么,相邻两个检查点之间的数据是如何持久化和恢复的呢?这引出了另一个概念,预写日志。启用预写日志后,数据的更新首先写入到预写日志中,并集中提交,然后再写入数据文件中。即使在发生宕机的情况下,MongoDB也会首先恢复到最近的一次检查点,然后重放后续的操作来恢复数据。
MongoDB监控文件的大小通常设置为100兆,因此大约每100兆的数据会创建一个新的日志文件。MongoDB首先将数据写入内存,然后每隔60秒进行一次持久化写入,也会首先写入相应的预写日志,然后每隔100毫秒进行一次刷盘操作,以确保数据持久化。预写日志用于数据恢复和持久化,增加了额外的可靠性保障。
接下来,让我们继续讨论缓存。我们之前提到,在MongoDB启动时,会从主机的整个内存中划分一块内存,分配给用作存储引擎的内部缓存。那么,这块缓存的大小是如何计算的呢?从官方文档中我们可以了解到,有一个参数用于配置缓存的大小,默认大小为主机的整个物理内存减去1GB,然后除以2。当主机资源有限时,会触发这个内存下限的设置,使缓存的最小值为256兆。换句话说,在默认情况下,如果计算出的内存大于256兆,MongoDB会选择大于256兆的内存来分配给缓存。通常情况下,只要我们在一台机器上部署多个MongoDB节点,就不需要担心这个参数选项。但是,当我们在同一台机器上部署多个MongoDB实例时,就需要计算好所有节点的缓存大小,以避免资源竞争的情况发生。当然,在实际的生产环境中,这些配置都需要根据具体的需求和硬件来进行调整。
我们也不建议在同一台机器上混合部署多个MongoDB进程。接下来,让我们看一下缓存中包含哪些数据。首先,我们知道数据会首先加载到内存的一部分中,所以缓存中肯定包含了数据和索引。另外,我们的数据通常分为多个部分。另外,不是所有数据都可以一直保留在缓存中。当数据不常用或者需要被替换时,缓存会使用算法来淘汰数据。
让我们来看一下缓存的淘汰策略。当读取缓存的使用率低于80%时,它不会采取任何操作。这也是很多人抱怨内存使用率低的原因,但实际上这是正常的行为。原因是只要数据在缓存中,就有可能被访问。一旦数据在缓存中,就不需要从磁盘再次加载,这加快了访问速度。数据库的目标是高效服务请求,而不是为了节省内存而采取行动。如果您不想占用太多内存,可以通过设置参数来限制内存的使用。当读取缓存的使用率超过80%时,将会有后台线程进行缓存的淘汰,但这对性能的影响非常小。当读取缓存的使用率超过90%时,将会动用服务应用程序的线程来减轻缓存压力,并通过限制新请求来更好地清理缓存。当读取缓存使用率达到110%时,MongoDB将使用紧急处理机制来阻塞所有请求,直到读取缓存使用率降低到80%以下为止。
接下来,让我们来看一下数据的写入。当数据小于50%时,MongoDB不会采取任何特殊操作。数据仅保留在内存中,直到下一次刷盘。当数据超过50%时,表示在清理之间有大量的更新操作。为了减轻清理的压力,MongoDB会使用后台线程查询并找到这些数据,并将其同步到磁盘上,以确保在下一次清理之前,这些数据已经在磁盘上持久化。当数据小于5%时,MongoDB会使用后台线程将数据刷新到磁盘,而当数据超过20%时,表示期间发生了大量的数据写入操作,以至于后台线程已经忙不过来了。这通常是由于磁盘性能不足导致的,而不是人们想象的内存不足。
那么,是否可以增加刷新线程以解决问题呢?在这种情况下,MongoDB仍然会使用自身服务应用程序的线程来帮助刷新数据,目的是降低应用程序请求的处理速度,从而控制新的数据写入速度。如果您想要进一步优化性能,那么就需要根据实际的情况来调整这些配置。运维经验丰富的同学可能会知道,如果内存不足会引发问题。但我想说的是,我们不能过分依赖这些监控指标。更重要的是,我们需要结合I/O负载以及其他性能指标来观察磁盘的读写负载,数据缓存与操作系统之间的交互情况,以及从磁盘读入缓存的数据量以及从缓存写入磁盘的数据量。如果发现磁盘读取量较低但缓存读取量较高,说明有很多数据从磁盘一直读入,那么可以考虑适当增加缓存大小。但如果磁盘读取和缓存读取都很高,那么需要进一步分析并可能优化磁盘性能。
此外,我们也应考虑增加实际的物理内存资源以应对内存不足的情况。接下来,让我们看一下MongoDB的读写过程。当数据库请求数据时,如果数据不在存储引擎的缓存中,那么需要从磁盘中读取数据。此时,磁盘中的数据文件是经过压缩的,因为存储引擎不对这些数据进行处理,它们只是为了加速数据文件的访问而保持在压缩状态。随后,数据会被读取到缓存中。支持数据会以解压后的格式存储在内存中,以供数据访问使用。此外,MongoDB支持对所有集合和索引进行压缩,这可以通过使用额外的CPU资源来实现存储空间的缩小。默认情况下,MongoDB使用zlib压缩,它的压缩比较低,但CPU占用较低。对于索引,MongoDB使用前缀压缩,这是一种特殊的压缩方式,它将每个索引条目存储为已经出现的条目的增量。然后,MongoDB提供了持久化和并发性能的功能,它的并发性能非常关键。
接下来,让我们再次看一下并发设计。在执行写操作时,MongoDB采用文档级别的并发控制,这意味着在同一时间内,多个写操作可以修改同一个集合中的不同文档。但是,如果多个写操作试图修改同一个文档,它们必须以一系列较快的方式进行,这意味着如果文档正在被修改,那么其他写操作必须等待,直到该文档上的写操作完成。然后,对于大多数读写操作,MongoDB完全采用了乐观并发控制。但是,请注意,某些维护操作,如重新建立索引,可能会导致数据库或表级别的排他锁。
经过以上一系列的介绍,我们了解到MongoDB通过这些机制实现了持久化。但是,当涉及到复制和高可用性时,如何保证多个节点上的数据持久化呢?
二、WriteConcern写关注
特别是在使用MongoDB复制集等架构时,主从切换是一个非常常见的情况。但是,如果主节点上的最新数据在某个时间点后被回滚,那么尽管我们有前面提到的清控点和预写日志的保障,但仍然可能导致数据丢失。对于数据库集群来说,持久化是否意味着所有节点都将最新数据写入磁盘文件系统中?但是,谁来决定这种持久化的要求呢?
首先,让我们回顾一下复制集的架构和复制的过程。从下面的图中可以看到,标准的复制集采用两层架构,其中包括主节点(Primary)和多个从节点(Secondary)。通常情况下,应用程序会将写请求发送到复制集的主节点,然后主节点将这些写入操作复制到两个从节点。
再来回顾之前所讲的概念,数据首先由应用程序的驱动程序发出请求到我们的数据节点中。数据会首先写入日志和我们的集合。与此同时,如下图所示,还存在一个集合。
这个集合在我们的复制集中扮演着重要的角色。这个集合是一个固定大小的集合,固定大小意味着它有一个最大上限。当我们往这个集合写入数据时,一旦超过了这个上限,那么最近写入的数据将被清理掉。在复制集中,这个集合充当主从同步的媒介,所有的写操作都会同时记录在这个集合中。当节点进行数据复制时,就需要使用它来比较数据的差异,从而实现主从复制。让我们再来梳理一下复制集同步的过程。应用程序会将所有的更新写入主节点。假设给这部分数据的批次打上时间戳,
用时间戳t表示这批数据。主节点会将这些数据写入集合中,同时还会写入另一份到集合中。
接下来,从节点会观察主节点在一段时间内所做的数据更改。然后,从节点会将这些数据应用到自己,同时记录在自己节点中的op集合中,接着,从节点可以请求时间戳t之后的数据。在这个过程中,我们需要知道主节点知道每个节点最新时间t的记录。如果把复制集中的数据复制更加形象地展示出来,如图所示。
应用程序写入 x 等于 1 到 x 等于 99 的数据。主节点接收并将这些数据写入自己的数据集合,然后复制到接下来的两个从节点上。这是一个正常的数据写入和复制过程。然而,假设在主从复制过程中发生异常,例如主节点在写入过程中突然宕机了。我们继续往下看,此时我们要写入 x 等于 100,但是我们的应用程序已经收到了来自数据库的确认,即数据已成功写入。然而,假设此刻数据库突然崩溃,并且这些数据尚未同步到任何从节点上。
此时主节点已经宕机了。复制集将触发一轮新的选举,选出一个存活的从节点升级为新的主节点。然后从 x 等于 101 开始写入数据。我们再将原本的主节点重新启动,一旦它重新启动后,它会发现自己还有 x 等于 1 到 x 等于 100 的这些数据。然后,主节点检测到主从数据之间的差异,它会进入回滚状态,并将 x 等于 100 回滚掉。回滚完成后,原本的主节点将成为新的从节点,接受新主节点的数据,并继续写入 x 等于 101。
对于刚刚描述的情景,总结一下,数据实际上已经写入主节点,主节点已经发送了写入成功的确认信息。然而,在从节点读取到最新数据之前,主节点宕机了,而新的主节点产生导致已确认的操作出现了丧失。对于这种情景,我们引入了另一个概念,即写关注。在上述情况下,我们可以定义为“大多数节点提交”,也就是将数据提交到大多数节点,即大部分节点都确认接收到数据之后,才认为数据已提交。
这种情况下,主节点知道数据已写入每个从节点的时间,因为主节点使用 MVCC(多版本并发控制),它知道每个节点上的多个版本数据,包括从节点上存储的最新版本。此外,如果数据尚未百分百确认被持久化到大多数节点,主节点会将这些数据保存在内存中。为了更详细地定义写入的安全级别,我们可以指定 "w" 和 "j" 参数。除了 "w" 参数外,我们还可以指定 "w" 为需要持久化到节点的数量。
"j" 参数表示是否将数据写入日志文件(journal),即是否进行写入到 journal 日志中。即使写入操作未达到定义的外存算级别,写入操作会等待直到超时,但超时并不意味着写入失败。
接下来,我们来谈谈读关注,读关注决定了我们从哪些节点读取数据。
默认情况下,如果您设置为读主节点,那么对应的读关注级别是 "local",也就是读取主节点的数据。如果您读取从节点的数据,那么 "local" 对应的是从节点。"majority" 级别表示已提交到大多数节点的数据,不包括最新数据尚未同步到其他从节点的情况。还有一个 "linearizable" 级别,它在读取时构建一个数据快照,确保读取的数据是在您发起请求时的数据,不会受后续写入数据的影响。 "local" 和 "majority" 的区别在于 "majority" 读取的数据与当前访问的节点相关,但如果当前子节点上的数据比大多数节点上的数据新,则返回旧的数据。如果当前数据库的数据版本比大多数节点的数据版本要旧,那仍然会返回主节点的版本作为标准。因此,读签名的标准是以主节点的版本为参考。这意味着读签名针对主节点特定,返回的数据是已提交到大多数节点的数据。但是,如果在当前查询之前发出的请求没有得到大多数节点的协议,那么它会等待协议的返回。在等待写入完成时,表现类似于 "majority",再加上等待的等级。但是 "linearizable" 的性能受到一定的控制,因为它需要等待大多数节点的确认。
再次回顾我们的读关注级别,我们现在有 "primary",这意味着首选读取主节点的数据,除非主节点不可用,那么我们可以读取从节点的数据,或者首选从最近的位置读取数据。这对于多数据中心的部署和应用非常有用,因为它允许您选择从特定的节点读取数据。您只需为这些特定节点打一个"pa"(位置属性),然后您的读取请求将针对这些位置属性读取数据,包括缓存和持久化的内容。