Mongo的持久性
持久性(durability)是操作被提交后可持久保存在数据库中的保证。从完全没有保障到完全保证持久性,MongoDB可高度配置与持久性相关的设定。本章内容包括:
-
- MongoDB如何保证持久性;
- 如何配置应用和服务器,从而获得所需的持久性级别;
- 运行时关闭日记系统(journaling)可能带来的问题;
- MongoDB不能保证的事项。
如磁盘和软件运行正常,则MongoDB能够在系统崩溃或强制关闭后,确保数据的完整性。
注意:关系型数据库通常使用持久性一词来描述数据库事务(transaction)的持久保存。由于MongoDB并不支持事务(4.0之前不支持),因此该词义在这里有些许不同。
1.日记系统的用途
MongoDB会在进行写入时建立一条日志(journal),日记中包含了此次写入操作具体更改的磁盘地址和字节。因此,一且服务器突然停机,可在启动时对日记进行重放(replay),从而重新执行那些停机前没能够刷新(flush)到磁盘的写入操作。
数据文件默认每60秒刷新到磁盘一次,因此日记文件只需记录约60秒的写入数据。 日记系统为此预先分配了若干个空文件,这些文件存放在/data/db/journal目录中,文件名为_j.0、_j.1等。
长时间运行MongoDB后,日记目录中会出现类似_j.6217、_j.6218和_j.6219的文件。这些是当前的日记文件。文件名中的数字会随着MongoDB运行时间的增长而增大。数据库正常关闭后,日记文件则会被清除(因为正常关闭后就不再需要这些文件了)。
如发生系统崩溃,或使用kill -9命令强制终止数据库的运行,mongod会在启动时重放日记文件,同时会显示出大量的校验信息。这些信息冗长且难懂,但其存在说明一切都在正常运行。可在开发时运行kill -9命令来终止mongod进程并重新启动,这样在生产环境中,如果发生相同状况,也会明白此时显示哪些信息是正常的。
1.1 批量提交写入操作
MongoDB默认每隔100毫秒,或是写入数据达到若干兆字节时,便会将这些操作写入日记。这意味着MongoDB会成批量地提交更改,即每次写入不会立即刷新到磁盘。不过在默认设置下,系统发生崩溃时,不可能丢失间隔超过100毫秒的写入数据。
然而,对于一些应用而言,这一保障还不够牢固,因此可通过若干方式来获得更强有力的持久性保证。可通过向getLastError传递j选项,来确保写入操作的成功。getLastError会等待前一次写入操作写入到日记中,而日记在下一批操作写入前,只会等待30毫秒(而非100毫秒):
> db.foo.insert({"x" : 1}) > db.runCommand({"getLastError" : 1, "j" : true}) > // The {"x" : 1} document is now safely on disk
注意:这意味着如果在每次写入操作中都使用了 "j":true选项,则写入速度实际上会被限制为每秒33次:
(1次/30毫秒)x (1000毫秒/秒)=33.3次/秒
通常将数据刷新到磁盘并不会耗费这么长时间,所以如果允许MongoDB对大部分数据进行批量写入而非每次都单独提交,数据库的性能则会更好。然而,重要的写入操作还是会经常选用此选项。
提交一次写入操作,会同时提交这之前的所有写入操作。因此,如果有50个重要的写入操作,可使用“普通的” getLastError (不包括j选项),而在最后一次写入后使用含有j选项的getLastError。如果成功的话,就可知道所有50次写入操作都已安全刷新到磁盘上。
如果写入操作含有很多连接,可通过并发写入,来减少使用j选项所带来的速度开销。此种做法可增加数据吞吐量,但也会增加延迟。
1.2 设定提交时间间隔
另一个减少日记被干扰几率的选项是,调整两次提交间的时间间隔。运行setParameter命令,设定journalCommitInterval的值(最小为2毫秒,最大为500毫秒)。以下命令使得MongoDB每隔10毫秒便将数据提交到日记中一次:
> db.adminCommand({"setParameter" : 1, "journalCommitInterval" : 10})
也可使用命令行选项--journalCommitInterval来设定这一值。
无论时间间隔设置为多少,使用带有"j" : true的getLastError命令都会将该值减少到原来的三分之一。
如客户端的写入速度超过了日记的刷新速度,mongod则会限制写入操作,直到日记完成到磁盘的写入。这是mongod会限制写入的唯一情况。
2.关闭日记系统
对于所有生产环境的部署,都推荐使用日记系统,但有时我们可能需要关闭该系统。即使不附带j选项,日记系统也会影响MongoDB的写入速度。如果写入数据的价值不及写入速度降低带来的损失,我们可能就会想要禁用日记系统。
禁用日记系统的缺陷在于,MongoDB无法保证发生崩溃后数据的完整性。在没有日记系统的前提下,一旦发生崩溃,那么数据肯定会遭到损坏,从而需要对数据进行修复或替换。这种情况下遭到损坏的数据不应继续投入使用,除非我们不在乎数据库会突然停止工作。
如果希望数据库在崩溃后能够继续工作,有以下几种做法。
2.1 替换数据文件
这是最佳选择。删除数据目录中的所有文件,然后获取新文件:可从备份中恢复,使用确保正确的数据库快照,如果是副本集成员的话,也可对其进行初始化同步。如果是一个数据量较小的副本集,重新同步可能是最好的选择,即先停止此成员的运行(如果它还没有停止运行的话),删除数据目录中的所有内容,然后重新启动它。
2.2 修复数据文件
如果既没有备份和复制,也没有副本集中的其他成员,则需抢救所有可能的数据。需对数据库使用一个”修复“工具,修复实质上是删除所有受损数据,不过可能不会留有太多完好的数据。
mongod自带了两种修复工具,一种是mongod内置的,另一种是mongodump内置的。mongodump的修复更加接近底层,可能会找到更多的数据,但耗时要更长(而另一种自带的修复方式也不见得很快)。另外,如使用mongodump的修复,在准备再次启动前,依然需要恢复数据的操作。
因此,应根据愿意在数据恢复中消耗的时间长短来进行决定。
要使用mongod内置的修复工具,需附带--repair选项运行mongod:
$ mongod --dbpath /path/to/corrupt/data --repair
进行修复时,MongoDB不会开启27017端口的监听,但我们可通过査看日志(log) 的方式得知它正在做什么。注意,修复过程会对数据进行一份完整的复制,所以如有80 GB的数据,则需80 GB的空闲磁盘空间。为尽量解决这一问题,修复工具提供了--repairpath选项。这一选项允许在主磁盘空间不足时挂载一个“紧急驱动器”,并使用它来进行修复操作。--repairpath选项的用法如下:
$ mongod --dbpath /path/to/corrupt/data \
> --repair --repairpath /media/external-hd/data/db
如果修复过程被强行终止,或者出现故障(如磁盘空间不足),至少不会使情况变得更糟。修复工具将所有的输出都写入新的文件中,不会对原有文件进行修改。因此原始数据文件不会比开始修复时变得更糟。
另一个选择是使用mongodump的--repair选项,就像这样:
$ mongodump --repair
这些选择都不是特别好,但它们应该可以让mongod重新运行在一个干净的数据集上。
2.3 关于mongod.lock文件
数据目录中有一个名为mongod.lock的特殊文件。该文件在关闭日记系统运行时十分重要(如启用了日记系统,则这一文件不会出现)。
当mongod正常退出时,会清除mongod.lock文件,这样在启动时,mongod就会得知上一次是正常退出的。相反,如果该文件没被清除,mongod就会得知上一次是异常退出的。
如果mongod监测到上一次是异常退出的,则会禁止再启动,这样我们就会意识到一份干净数据副本的需求。然而,有些人意识到可通过删除mongod.lock文件来启动mongod。请不要这么做。通常,在启动时删除这一文件,意味着我们不知道也不在乎数据是否受损。除非如此,否则请不要这么做。如果mongod.lock文件阻止了 mongod的启动,请对数据进行修复,而非删除该文件。
2.4 隐蔽的异常退出
不要删除锁文件的另一重要原因在于,我们甚至可能意识不到这是一次异常退出。假设我们需要重启机器进行例行维护。初始化脚本应负责在服务器关闭之前关闭mongod。初始化脚本通常会先尝试正常关闭程序,但如在若干秒后依然没有关闭的话,则会选择强行关闭。在一个繁忙的系统上,MongoDB完全可能耗费30秒来结束运行,正常的初始化脚本不会等待它正常关闭。因此,异常退出的次数可能比我们知道的要多得多。
3.MongoDB无法保证的事项
在硬件或文件系统出现故障等情况下,MongoDB无法保证操作的持久性。尤其是在硬盘发生损坏的情况下,MongoDB根本无法保证数据安全。
另外,不同的硬件和软件对于持久性的保障可能有所不同。例如,一些破旧的硬盘会在写入操作还在列队中等待之际,便报告称写入成功。MongoDB无法防止这一层次的误报,如果此时系统崩溃,数据就可能会发生丢失。
基本上,MongoDB的安全性与其所基于的系统相同,MongoDB无法避免硬件或文件系统导致的数据损坏。可使用副本应对系统问题。如果一台机器发生了故障,还有另一台在正常工作。
4.检验数据损坏
可使用validate命令,检验集合是否有损坏。如检验名为foo的集合,代码如下:
> db.foo.validate() { "ns" : "test.foo", "firstExtent" : "0:2000 ns:test.foo", "lastExtent" : "1:3eae000 ns:test.foo", "extentCount" : 11, "datasize" : 75960008, "nrecords" : 1000000, "lastExtentSize" : 37625856, "padding" : 1, "firstExtentDetails" : { "loc" : "0:2000", "xnext" : "0:f000", "xprev" : "null", "nsdiag" : "test.foo", "size" : 8192, "firstRecord" : "0:20b0", "lastRecord" : "0:3fa0" }, "deletedCount" : 9, "deletedSize" : 31974824, "nIndexes" : 2, "keysPerIndex" : { "test.foo.$_id_" : 1000000, "test.foo.$str_1" : 1000000 }, "valid" : true, "errors" : [ ], "warning" : "Some checks omitted for speed. use {full:true} option to do more thorough scan.", "ok" : 1 }
需重点注意的是结尾附近的valid字段,字段值为true。否则,输出内容中会包含找到的数据损坏细节。
输出中的大部分内容,是有关集合的内部结构信息,于调试而言没有太大用处。
- firstExtent (首区段)
该集合首区段(extent)的磁盘偏移量(disk offset)。本例中位于文件test.O处, 字节偏移量(byte offset)为 0x2000。
- lastExtent (尾区段)
该集合尾区段的偏移量。本例中位于文件test.1处,字节偏移量为0x3eae000。
- extentCount
该集合所占区段数量。
- lastExtentSize
最近分配区段的字节数量。区段大小随集合的增长而增长,最大可达2 GB。
- firstExtentDetails
描述集合中首区段的子对象。其中包含指向相邻两个区段的指针(xnext和 xprev)、区段的大小(注意,它比尾区段要小得多,通常首区段是很小的),以及指向区段中第一条和最后一条记录(record)的指针。记录是真正承载着文档的结构。
- deletedCount
该集合从存在至今,共删除的文档数目。
- deletedSize
该集合中空闲列表(freelist),即所有有效空余空间的大小。不仅包括被删除文档所占的空间,还包括已被预分配给该集合的空间。
validate命令只适用于集合,而不适用于索引。因此我们通常无法判断索引是否被损坏,除非遍历检查一遍,即査询每个索引在集合中对应的文档。通过遍历得出的结果即可判断索引是否被损坏。
如果程序提示了非法的BSON对象(invalidBSONObj), —般说明数据损坏了。最糟糕的错误则是提到了 pdfile的错误。pdfile可以说是MongoDB的数据存储核心,源于pdfile的错误基本说明数据文件已经损坏了。
如果遇到了数据损坏,则可在日志中看到类似如下内容:
Tue Dec 20 01:12:09 [initandlisten] Assertion: 10334: Invalid BSONObj size: 285213831 (0x87040011) first element: _id: ObjectId('4e5efa454b4ae20fa6000013')
如果显示的第一个元素已经被废弃,就没什么可做的了。如果第一个元素还是可见的(如上例中的ObjectId),也许可删除损坏文档。可尝试运行:
> db.remove({_id: ObjectId('4e5efa454b4ae20fa6000013')})
将其中的 _id 替换为日志中看到的对应_id。注意,如果数据损坏影响的不只是该文档,则这种技术可能不会奏效。这种情况下,我们可能仍需对数据进行修复。
5.副本集中的持久性
副本章节中,曾讨论过副本集中的投票问题,即一次对副本集的写入操作,在写入副本集中的大多数成员中之前,可能先会进行回滚(rollback)。可将与此相关的选项和之前提到的日记系统的选项结合起来使用:
> db.runCommand({"getLastError" : 1, "j" : true, "w" : "majority"})
进行这一操作后,可保证写入操作写入到了主节点和备份节点中,其中只有对主节点的写入可保证持久性。理论上来讲,在进行写入到记录到日记内的100毫秒时间内,多数的服务器同时崩溃也是有可能的,这种情况下数据库会回滚到当前主节点的状态。虽然这是一种极端情况,但也说明其并非是完美的。遗憾的是要解决这一问题并不简单,但目前MongoDB社区正尝试改变这一情况。