MongoDB源码概述——使用日志提升单机数据可靠性
在MongoDB源码概述——内存管理和存储引擎一文的最后,我们留下了一个问题,在使用MongoDB的内存管理与存储引擎时,因为其依仗操作系统的MMAP方式,将磁盘上的文件映射到进程的内存空间,这给MongoDB带来了极大的便利,可也给我们带来了不小的问题。到底隔多久一次将映射的在内存的视图持久化硬盘才能保证我们服务器在宕机时丢失的数据最少呢?针对flushAll过程中宕机有可能造成的数据错乱,有没有什么好的解决方案呢?
MongoDB的团队成员1.7版本的最新分支上开始对单机高可靠性的提升,这就是引入的Journal\Durability模块,这个模块主要解决上面提出来的问题,对提高单机数据的可靠性,起了决定性的作用.其机制主要是通过log方式定时将操作日志(对数据库有更改的操作,查询不在记录范围之类)记录到dbpath的命名为journal文件夹下,这样当系统再次重启时从该文件夹下恢复丢失的数据。
下面我们针对源码,对他进行简要分析
Journal记录模块
Journal\durability模块的调用路径如下:
Main()——》initAndListen()——》_initAndListen()——》dur::startup();
Startup()的代码如下:
void startup() { if( !cmdLine.dur ) return; //DurableInterface的工厂模式 用DurableImpl来实例化getDur获取的对象 DurableInterface::enableDurability(); journalMakeDir();//确认日志目录 try { recover();//修复模式 } catch(...) { log() << "exception during recovery" << endl; throw; } //预分配两个日志文件 preallocateFiles(); boost::thread t(durThread); }
上述代码中,DurableInterface::enableDurability()确保系统使用DurableImpl来实例化内部_impl变量指针,此指针默认指向一个NonDurableImpl实例。他们的关系如下:
NonDurableImpl不会持久化任何的journal,而DurableImpl,提供journal的持久化。
journalMakeDir()函数会检查日志的目录是否存在,若不存在,则负责创建。
recover()函数则负责检查现有的journal持久化文件,若有相关文件,则意味着上次系统宕机,需要根据journal恢复数据,这部分内容将会在本文的后面讲到。
preallocateFiles(),给持久化journal提供存储文件,系统会根据当前环境来判定到底需不需要预分配.
接着系统开启了一个新的线程来运行durThread(),为了极大的减少文中粘贴的代码数量,我还是描述一下流程,说几个重要的步骤吧。毕竟我觉得贴代码没意思,文章膨胀,可实际有用的内容又少的可怜。这也就是为什么我喜欢叫我的文章为源码概述而不是源码分析的缘故.
durThread主要负责每90毫秒commit一次journal(记录用户对数据库更改的操作,查询操作不再记录范围),他是一个单独的线程,而记录接口,在内存中存储journal这两大部分则是在用户调用journal接口时完成的,这部分的内容我在 MongoDB源码概述——日志 一文中已经完成,
具体可以分为以下几个过程:
- 记录最后一次MMAP的Flush时间,清理不再需要的日志文件
调用journalRotate()会更新lsn文件,此文件用于记录最后一次MMAP文件Flush到磁盘的时间,此数据来源于lastFlushTime属性,而与此属性相关的赋值如下:
void Journal::init() { assert( _curLogFile == 0 ); MongoFile::notifyPreFlush = preFlush;//两个指向函数的指针 MongoFile::notifyPostFlush = postFlush;//用于模拟事件通知 } void Journal::preFlush() { j._preFlushTime = Listener::getElapsedTimeMillis();//获取系统启动后的初略时间 } void Journal::postFlush() { j._lastFlushTime = j._preFlushTime; j._writeToLSNNeeded = true; }
至此,我们知道其lastFlushTime是存储着在Listener类一个初略估计系统启动时间的数值,且这个数值会随着MMAP的视图Flush到磁盘的时候通知lastFlushTime更新值(函数指针通知)。另外,此次调用还会检查是否已经写满了journal存储文件,系统给32位和64位的环境设定了不同的最大值
DataLimit = (sizeof(void*)==4) ? 256 * 1024 * 1024 : 1 * 1024 * 1024 * 1024;
若当前写的位置超出了最大值范围,会相继调用
closeCurrentJournalFile(); removeUnneededJournalFiles();
这两个函数的代码我就不贴了,其实他就是关闭当前已经写满的Journal记录文件,删除掉那些在最后一次FlushTime之前的记录文件(同时存在多个Journal记录文件)。因为这部分记录的更改已经顺利持久化了,不再需要Journal记录之前的操作了.
- 序列化用户操作并持久化
序列化之前,系统需要调用commitJob.wi()._deferred.invoke(),此函数将遍历TaskQueue<D>内存有的D(记录用户操作那步存下来的),逐个运行D::go(),最后将所有D内的数据封装为WriteIntent存到Writes :: _writes中(set<WriteIntent>),细看WriteIntent与D结构体的区别,D存储数据源的首地址,而WriteIntent存储数据源的首地址,官方的解释是这样做能够让我们在_writes(set<WriteIntent> 内部实现是红黑树)运行重载符” < “更快.我实在对他这种做法很费解,为什么这些东西不能由一个D来完成呢?非得弄个WriteIntent,干扰阅读代码的人的视线.
好了 至此为止,所有的WriteIntent在_writes(set<WriteIntent>整装待发,正准备的系统对他进行序列化,就像砧板上的肉,洗干净了身子正准备等待主人来切.
在_groupCommit内调用PREPLOGBUFFER(),开始了journal的序列化操作
AlignedBuilder& bb = commitJob._ab;//可以将其理解为一个Buf ... for( vector< shared_ptr<DurOp> >::iterator i = commitJob.ops().begin(); i != commitJob.ops().end(); ++i ) { (*i)->serialize(bb); } … for( set<WriteIntent>::iterator i = commitJob.writes().begin(); i != commitJob.writes().end(); i++ ) { prepBasicWrite_inlock(bb, &(*i), lastDbPath); }
通过上面代码我们可以得知,DurOp的序列化是自己的serialize方法完成的,他们的序列化操作不牵扯到被修改数据,所以序列化结果可以很简洁。例如一个DropDbOp,卸载掉某个数据库,如果需要恢复,指向需要再运行一次卸载过程即可,所以只需要用一个东西(甚至代码数字也可以)来标识就行了。而BasicWrites就不一样了,例如新插入了一个Record,我们需要记录下整个Record作为恢复的数据源,没错,这就是上面没有解释的代码prepBasicWrite_inlock所干的事情。
JEntry e; ... bb.appendStruct(e); bb.appendBuf(i->start(), e.len)
对于AlignedBuilder,我们可以理解为我们序列化过程中的Buf,存储着已经序列化好的待持久化的数据,appendBuf将会将参数指定位置的数据进行memcopy,实际上这里说是序列化还有些问题,像这些数据源,存储在journal日志文件时就是二进制。好了,不纠结这个名称了,AlignedBuilder除了放入了数据源之外,还放入了JEntry来表示一些基本属性,JEntry与WriteIntent是1:1的关系,也只有这样,读取的时候才能正确的寻址.
在全部序列化之后,系统调用WRITETOJOURNAL(commitJob._ab)来将AlignedBuilder持久化到journal日志文件,最终通过调用LogFile::synchronousAppend负责向外部存储文件写入。接着系统调用WRITETODATAFILES(),事实上我在第一次看源码的时候我非常的不解,举个例子,我们在插入数据的时候,已经将将用户要插入的数据memcopy过一次了,已经存到内存里面的视图(View)上了,为什么这里的WRITETODATAFILES还需要memcopy一次呢?我在这个问题上也纠结了很久,最后才找到了答案。这个奥秘就在于如果启用了dur模式,对于每个MemoryMappedFile实际上会产生两个视图,一个_view_private,一个_view_write(对于未开启dur模式的mongodb在32bit系统上运行,官方说db数据不能超过2.5G,现在通过这个原理我们可以看到他的水分,实际上最优的大小也就1G)。代码如下:
bool MongoMMF::finishOpening() { if( _view_write ) {//_view_write先创建 if( cmdLine.dur ) { _view_private = createPrivateMap();//创建 _view_private if( _view_private == 0 ) { massert( 13636 , "createPrivateMap failed (look in log for error)" , false ); } privateViews.add(_view_private, this); // note that testIntent builds use this, even though it points to view_write then... } else {//若不允许dur 则只用一个view _view_private = _view_write; } return true; } return false; }
MongoMMF内的两个视图,只有一个能被Flush到磁盘,那就是第一个创建的视图,_view_write一定是第一个创建的,所以只有他才能真正持久化。
void MemoryMappedFile::flush(bool sync) { uassert(13056, "Async flushing not supported on windows", sync); if( !views.empty() ) { WindowsFlushable f( views[0] , fd , filename() , _flushMutex); f.flush(); } }
我们现在还只是知道dur模式有两次memcopy,可是为什么会有两次呢?从此模式下有两个不同的视图出发,你有没有想到什么?没错,我们在Insert方法中(pdfile.cpp 1596行)调用memcopy是将内容复制到_view_private上(pdfile.cpp 1596行可知recordAt使用的是p,p=》_mb=》 _mb = mmf.getView();所以,实际上那个record在view_private上),不是可以被持久化的_view_write,所以在WRITETODATAFILES需要在复制一次,而此时,数据源则就是view_private上被复制过来的数据.
通过上面两段的代码,我们还能发现,在非dur模式下,_view_private与_view_write实际上是同一个东西。这也就解释了为什么非dur模式不需要两次memcopy就能很好的完成工作(非dur模式不运行WRITETODATAFILES)。
好,至此为止,我们所有的结论都已经对接上了。
最后用一张非常蹩脚的时序图来描述这一过程(过程非完全面向对象)。
Journal恢复模块
此模块在系统启动时运行,他完成对上次宕机遗留的Journal文件进行解读(也是通过MMAP的方式)并将没有Flush到数据库记录文件的记录重新通过memcopy的方式放入_view_write中。以备存储引擎线程执行持久化。
若系统上次是正常退出,则在退出流程中会进行最后的Flush(仅dur模式),并清理现有的Journal文件,所以正常退出是不会遗留任何的Journal文件的.
这个部分的操作也非常的简单,因为时间的关系,本文就不再详细阐述了,时序图如下:
不早了. 洗洗睡了!!!
另寻找热爱底层技术(C/C++ linux)的朋友一起研究和创造有意思的东西!