一、数据持久化
diydb是一个实际上是文档型数据库(并不是内存型数据库),他需要将数据持久化,那么就需要 读写磁盘上的数据。怎样读写磁盘上的数据更高效呢?目前linux上的方法就是使用mmap,即内存映射机制。
为什么说mmap高效呢?我们知道,当我们在进程中读文件时,一般都是先将磁盘上的文件的相应数据块复制到进程的内核空间,然后从内核空间将需要的数据复制到用户空间。你会发现,数据经过了内核空间的转存,对于应用程序来说,这个过程是没必要的,也是很消耗资源的。mmap正是省略了数据在内核的转存,他使得磁盘上的数据直接映射到进程的虚拟内存空间,而且是虚拟内存空间中的用户空间,当我们读取由mmap映射过的磁盘数据时,相应的数据块会直接复制到进程的用户空间,这样一来就不用经过内核空间的转存了。下面是mmap映射后的进程地址空间分布图:
下面,我们就来通过源码分析一下diydb中管理内存映射的类。
#ifndef OSSMMAPFILE_HPP_ #define OSSMMAPFILE_HPP_ #include "core.hpp" #include "ossLatch.hpp" #include "ossPrimitiveFileOp.hpp" class _ossMmapFile { protected : class _ossMmapSegment//内存映射的一个数据段,主要是怕内存中没有连续的大内存段 { public : void *_ptr ;//内存地址 unsigned int _length ;//内存段长度 unsigned long long _offset ;//偏移 _ossMmapSegment ( void *ptr, unsigned int length, unsigned long long offset ) { _ptr = ptr ; _length = length ; _offset = offset ; } } ; typedef _ossMmapSegment ossMmapSegment ; ossPrimitiveFileOp _fileOp ;//文件 ossXLatch _mutex ;//互斥锁,保证同时只有一个线程对数据段进行操作 bool _opened ;//文件是否已经打开 std::vector<ossMmapSegment> _segments ;//这个文件所映射到的多个段 char _fileName [ OSS_MAX_PATHSIZE ] ;//文件名 public : typedef std::vector<ossMmapSegment>::const_iterator CONST_ITR ;//迭代器 inline CONST_ITR begin () { return _segments.begin () ; } inline CONST_ITR end () { return _segments.end() ; } inline unsigned int segmentSize () { return _segments.size() ; } public : _ossMmapFile () { _opened = false ; memset ( _fileName, 0, sizeof(_fileName) ) ; } ~_ossMmapFile () { close () ;//回收所有映射的内存空间,遍历_segments,对每个内存段进行反映射 } int open ( const char *pFilename, unsigned int options ) ; void close () ; int map ( unsigned long long offset, unsigned int length, void **pAddress ) ; } ; typedef class _ossMmapFile ossMmapFile ; #endif
这里可以看到,我们的映射文件类_ossMmapFile实际上是管理一个文件的内存映射,因为有的数据库文件可能非常大,如果要把数据库文件直接映射到一个连续的虚拟地址空间,很可能会映射失败,所以_ossMmapFile是把文件映射到多个内存段,每个内存段对应的是一个_ossMmapSegment类型对象。所有映射到了内存的数据段放在一个集合中(std::vector<ossMmapSegment> _segments)。上面是ossMmapFile.hpp的代码,ossMmapFile.cpp这里不细说,最后会将带注释的代码po出来。
二、数据的存储
上面说了diydb数据持久化时,是通过内存映射的方式去高效读写磁盘文件的,那么数据库数据是以什么格式存放在磁盘上面,我们又是怎样去操作这些格式化的数据的呢?
1、数据库文件结构总览
头:存放数据库文件的元数据。包括字符串标识(相当于一个魔数,用来表示这是一个diydb的数据库文件)、数据页数量、数据 库状态、版本信息。
数据页:我们的数据库文件时分成一个一个大小一致的数据页的,而空闲空间的管理都放到了数据页内部。另外,由于每条数据不 能跨数据页,这里每个数据页的大小为4M,所以diydb中一条数据的大小不能超过4M。
数据段:数据段由多个数据页构成,表示数据库文件中由mmap映射到虚拟内存中的连续的一段数据。所以数据段是只存在于内存 中的一个单位,数据库文件中没有数据段。
注:diydb的数据库文件比较简化,她只包含一个数据库,而且索引没有持久化存储。
2、数据页的结结构
长度:数据页的长度,以便以后扩展数据页的长度。
标识:标识数据页的状态,比如:是否可用
槽数量:该数据页中包含的槽的数量。
最后一个槽所在的偏移:数据页的数据部分的前面放的是槽,后面部分放的是数据块(两者中间就是空闲空间)。这个属性就是该 数据页中最后一个槽的偏移。
空闲空间大小:表示该数据页中没有使用的空间,即槽区域和数据区域之间的部分。
空闲空间起始地址偏移:在数据页中数据块是从后往前分配空间的,所以存放数据块的区域的位于数据页的尾部区域,本属性就表 示该尾部区域的起始位置的地址偏移。
注:数据页的大小固定为4M,槽的大小为4B
3、数据记录的结构
数据记录长度:一个数据记录的整体长度。
数据记录标识:表示该数据记录是否可用(即是否被删除了)。
数据:存放真正的一条数据(这里是一个BSON对象)。
4、对外操作
(1)数据插入( insert)
(2)数据删除( remove)
(3)数据查找( find)
(4)初始化( initialize)
5、内部操作
(1)增加数据段( _extendSegment)
1、扩展文件 2、把扩展的文件映射到内存里面
(2)初始化空文件( _initNew)
当没有数据文件的时候,创建新的数据库文件,扩展文件,填入数据库文件的头信息,然后把文件映射到内存里
(3)扩展文件( _extendFile)
把文件延长128M,即将磁盘上的文件扩展128M(一个段的长度)
(4)装载数据( _loadData)
启动一个数据库时,如果已经有一个数据库文件,则需要把这个数据库文件装载进去。1、将数据库文件的头装载进去 2、将数据库中的每个段映射到内存中 3、计算每个数据页中的空闲空间,把结果保存到一个std::map里面,这个map对象就是空闲空间管理容器
(5)搜索槽( _searchSlot)
给定一个数据页,给定一个RID,这个函数算出这个槽的偏移是多少
(6)回收空间( _recoeverSpace)
即页内重组
(7)更新剩余空间( _updateFreeSpace)
将页内插入数据后,页的空闲空间就少了,这样就得更新空闲空间管理容器
(8)查找数据页( _findPage)
给定一个数据的长度,通过这个这个方法去找到一个有合适空闲空间的页
6、带着上面的介绍,我们来看看代码实现
(1)每条数据记录的id由页id和槽id组成,即每次找一条记录时,我们先找记录所在的页,然后找记录所在的槽,然后根据槽去找数据记录
typedef unsigned int PAGEID ;//页号 typedef unsigned int SLOTID ;//槽号 //每个记录id由页id和槽id组成 struct dmsRecordID { PAGEID _pageID ; SLOTID _slotID ; } ;
(2)每条记录的结构
struct dmsRecord//数据记录 { unsigned int _size ; unsigned int _flag ; char _data[0] ; } ;
(3)数据库文件的头
//数据库文件的首部 struct dmsHeader { char _eyeCatcher[DMS_HEADER_EYECATCHER_LEN] ;//数据库文件的魔数 unsigned int _size ; unsigned int _flag ; unsigned int _version ; } ;
(4)数据页的结构
// page structure /********************************************************* PAGE STRUCTURE ------------------------- | PAGE HEADER | ------------------------- | Slot List | ------------------------- | Free Space | ------------------------- | Data | ------------------------- **********************************************************/ #define DMS_PAGE_EYECATCHER "PAGH"//数据页的魔数 #define DMS_PAGE_EYECATCHER_LEN 4 #define DMS_PAGE_FLAG_NORMAL 0 #define DMS_PAGE_FLAG_UNALLOC 1 #define DMS_SLOT_EMPTY 0xFFFFFFFF//当slot对应的数据记录被删除时,要将该slot设为-1 struct dmsPageHeader { char _eyeCatcher[DMS_PAGE_EYECATCHER_LEN] ; unsigned int _size ; unsigned int _flag ; unsigned int _numSlots ; unsigned int _slotOffset ; unsigned int _freeSpace ; unsigned int _freeOffset ; char _data[0] ; } ;
(5)数据库文件中各个单位的大小
#define DMS_PAGESIZE 4194304//linux中一个数据块的大小为4096,diy数据库一个page的大小设置为4M #define DMS_MAX_PAGES 262144//数据库文件最大256K个数据页,所以数据库文件最大为1T #define DMS_FILE_SEGMENT_SIZE 134217728//段长128M #define DMS_FILE_HEADER_SIZE 65536//数据库文件头部的长度 #define DMS_EXTEND_SIZE 65536//扩展磁盘时一次扩展的大小,实际上就是一个段的长度
(7)DMS数据管理模块的实现类的声明如下
class dmsFile : public ossMmapFile { private : dmsHeader *_header ;//数据库文件的头 std::vector<char *> _body ;//每个SEGMENT在虚拟内存中的起始位置 std::multimap<unsigned int, PAGEID> _freeSpaceMap ;//管理空闲空间,每次要插入记录时,根据记录大小来 ossSLatch _mutex ;//读写锁 ossXLatch _extendMutex ;//扩展数据库文件时的互斥锁,防止同时有两个线程扩展这个文件 char *_pFileName ;//文件名 ixmBucketManager *_ixmBucketMgr ;//数据索引 public : dmsFile ( ixmBucketManager *ixmBucketMgr ) ; ~dmsFile () ; // 初始化 dms 文件 int initialize ( const char *pFileName ) ; // 插入数据,将record插入到rid指定的槽对应的数据记录中,并且用outRecord返回record在插入后在内存映射中的位置 int insert ( bson::BSONObj &record, bson::BSONObj &outRecord, dmsRecordID &rid ) ; //给定一个记录id,删除对应的记录 int remove ( dmsRecordID &rid ) ; //根据记录id查找对应的记录 int find ( dmsRecordID &rid, bson::BSONObj &result ) ; private : int _extendSegment () ;//为数据库文件扩展一个段 int _initNew () ;//初始化一个空的数据库文件,只创造一个数据库文件头 int _extendFile ( int size ) ;//扩展文件,扩展指定的大小 int _loadData () ;//装载数据库文件 // search slot int _searchSlot ( char *page,//给定一个数据页 dmsRecordID &recordID, SLOTOFF &slot ) ;//搜索槽 void _recoverSpace ( char *page ) ;//重组 void _updateFreeSpace ( dmsPageHeader *header, int changeSize, PAGEID pageID ) ;//更新空闲空间 PAGEID _findPage ( size_t requiredSize ) ;//在空闲空间列表中找满足<span style="font-family: Arial, Helvetica, sans-serif; font-size: 12px;">requiredSize大小的页</span> }
注:根据上面的描述,我们可以发现,数据库的元数据有:数据库文件头中的信息(主要是数据库文件的大小)、数据库文件映射到内存中的每个段的起始位置、数据库空闲空间列表、数据库文件名、数据库的索引
(6)数据插入( insert)实现
int dmsFile::insert ( BSONObj &record, BSONObj &outRecord, dmsRecordID &rid ) { int rc = DIY_OK ; PAGEID pageID = 0 ; char *page = NULL ; dmsPageHeader *pageHeader = NULL ; int recordSize = 0 ; SLOTOFF offsetTemp = 0 ; const char *pGKeyFieldName = NULL ; dmsRecord recordHeader ; recordSize = record.objsize() ;//记录的大小 if ( (unsigned int)recordSize > DMS_MAX_RECORD )//每一条记录最大4m减去页的头部 { rc = DIY_INVALIDARG ; PD_LOG ( PDERROR, "record cannot bigger than 4MB" ) ; goto error ; } pGKeyFieldName = gKeyFieldName ; //检测是否有_id字段 if ( record.getFieldDottedOrArray ( pGKeyFieldName ).eoo () ) { rc = DIY_INVALIDARG ; PD_LOG ( PDERROR, "record must be with _id" ) ; goto error ; } retry : // 对全局锁加锁 _mutex.get() ; pageID = _findPage ( recordSize + sizeof(dmsRecord) ) ;//找足够的空间 // if there's not enough space in any existing pages, let's release db lock if ( DMS_INVALID_PAGEID == pageID ) { _mutex.release () ;//如果找不到合适大小的数据页就释放锁 // if there's not enough space in any existing pages, let's release db lock and // try to allocate a new segment by calling _extendSegment if ( _extendMutex.try_get() )//扩展锁,即增加数据段 { // 同时只有一个线程可以扩展数据段,扩展时,先扩展数据库文件,然后将扩展的段映射到内存中 // 接着初始化每个数据页的元数据,然后初始化数据库的元数据,包括更改空闲空间列表,将映射 // 进内存的段的起始位置列表 rc = _extendSegment () ; if ( rc ) { PD_LOG ( PDERROR, "Failed to extend segment, rc = %d", rc ) ; _extendMutex.release () ; goto error ; } } else { // if we cannot get the extendmutex, that means someone else is trying to extend // so let's wait until getting the mutex, and release it and try again _extendMutex.get() ; } _extendMutex.release () ; goto retry ;//然后继续找拥有足够空间的页 } // 同过pageID找到该页在映射在内存中的位置 page = pageToOffset ( pageID ) ; // 如果找不到对应页在内存中的位置,释放扩展锁,并返回error if ( !page ) { rc = DIY_SYS ; PD_LOG ( PDERROR, "Failed to find the page" ) ; goto error_releasemutex ; } // 读取页的元数据 pageHeader = (dmsPageHeader *)page ; // 检测页的标识字段有没有问题 if ( memcmp ( pageHeader->_eyeCatcher, DMS_PAGE_EYECATCHER, DMS_PAGE_EYECATCHER_LEN ) != 0 )//检测是不是数据库的页 { rc = DIY_SYS ; PD_LOG ( PDERROR, "Invalid page header" ) ; goto error_releasemutex ; } // 我们找到的页只是说空闲空间总和够插入一条数据,但是页中的空间并不一定是连续的, // 所以,要看有没有连续的空间够插入一条数据,如果没有,这要进行业内重组,即把页 // 内多块空闲空间调整成一块连续的空闲空间 if ( pageHeader->_slotOffset + recordSize + sizeof(dmsRecord) + sizeof(SLOTID) > pageHeader->_freeOffset )//看有没有足够的空间和足够的连续空间 { _recoverSpace ( page ) ;//页内重组 } offsetTemp = pageHeader->_freeOffset - recordSize - sizeof(dmsRecord) ; recordHeader._size = recordSize + sizeof( dmsRecord ) ; recordHeader._flag = DMS_RECORD_FLAG_NORMAL ; // 填写给带插入记录分配的槽 *(SLOTOFF*)( page + sizeof( dmsPageHeader ) + pageHeader->_numSlots * sizeof(SLOTOFF) ) = offsetTemp ; // 填写记录的头部信息 memcpy ( page + offsetTemp, ( char* )&recordHeader, sizeof(dmsRecord) ) ; // 填写记录体 memcpy ( page + offsetTemp + sizeof(dmsRecord), record.objdata(), recordSize ) ; outRecord = BSONObj ( page + offsetTemp + sizeof(dmsRecord) ) ; rid._pageID = pageID ; rid._slotID = pageHeader->_numSlots ; // 更改数据页的元数据信息 pageHeader->_numSlots ++ ; pageHeader->_slotOffset += sizeof(SLOTID) ; pageHeader->_freeOffset = offsetTemp ; // 更改数据库的元数据信息(即空闲空间列表) _updateFreeSpace ( pageHeader, -(recordSize+sizeof(SLOTID)+sizeof(dmsRecord)), pageID ) ; // 释放全局锁 _mutex.release () ; done : return rc ; error_releasemutex : _mutex.release() ; error : goto done ; }
注:这里我们可以看出,当一个线程在对数据库操作时,加的是数据库的全局锁,这个锁的粒度是相当大的,非常不建议这么做,这正是本数据库不能商用的原因之一。
另外,我们可以看到,数据的插入操作包括:
? 判定输入数据的合法性
? 锁数据库
? 找到拥有足够空间的数据页
? 如果无法找到拥有足够空间的数据页,释放锁,分
配新的数据段, 得到锁, 然后重新查找
? 如果找到的空闲页不包括足够的连续大小内存页,
则进行数据页重组
? 将记录写入数据页
? 更新数据页元数据信息
? 更新空闲空间信息
? 解锁
三、总结
1、本章主要讲解了diydb的DMS模块,即数据的管理模块,runtime模块在执行请求时,正是基于这个模块对数据库中的数据进行操作的(当然还要根据索引模块去查找记录)
2、diydb中的数据是存在磁盘上面的,传统读磁盘上的数据会导致对数据的两次复制,非常耗时,所以diydb采用了将数据库文件中的数据映射进用户内存空间(虚拟),来提高磁盘读写的效率。而且为了防止用户内存空间没有足够的连续地址,所以每次将一个数据段映射到用户内存空间中
3、diydb的数据管理模块在操作数据时,会锁住整个数据库,所以效率不敢恭维
4、diydb的数据库文件中的数据存储格式是比较机智的,他为每个数据记录分配了一个指向该记录的槽,因为数据记录的长度是变化的,而槽的大小是一定的,所以对槽的查找更方便。正因为这样,每个记录id由页id和槽id组成。