本系列前两章已经描述了系统架构以及系统构建的基础内存映射,本章将详细描述lmdb的核心,外存B+Tree的操作。本文将从基本原理、内存操作方式、外存操作方式以及LMDB中的相关函数等几方面描述LMDB中关于B+Tree的使用方式。
介绍
动态查找树主要有:二叉查找树(Binary Search Tree),平衡二叉查找树(Balanced Binary Search Tree),红黑树 (Red-Black Tree ),B-tree/B+-tree/ B*-tree (B~Tree)。前三者是典型的二叉查找树结构,其查找的时间复杂度O(log2N)与树的深度相关,那么降低树的深度自然对查找效率是有所提高的;在大规模数据存储前提下,比如有100万key需要进行比较,二叉树进行查询时,需要访问的磁盘IO次数将有20次,以现有的磁盘随机访问性能,对于大型应用程序这是不可接受的性能,基础的想法就是讲多个key或者二叉树的子树存放在一起,以页面为单位进行访问,比如组织成下图形式,100万key比较时io访问次数只有两次,通过进一步的优化,B-Tree应运而生。
B-Tree系列可以认为是多路平衡查找树,能有效降低树的层次以及支持外存数据组织。
定义
B-tree又叫平衡多路查找树。一棵m阶的B-tree (m叉树)的特性如下:
(其中ceil(x)是一个取上限的函数)
1) 树中每个结点至多有m个孩子;
2) 除根结点和叶子结点外,其它每个结点至少有有ceil(m / 2)个孩子;
3) 若根结点不是叶子结点,则至少有2个孩子(特殊情况:没有孩子的根结点,即根结点为叶子结点,整棵树只有一个根节点);
4) 所有叶子结点都出现在同一层,叶子结点不包含任何关键字信息(可以看做是外部结点或查询失败的结点,实际上这些结点不存在,指向这些结点的指针都为null);
5) 每个非终端结点中包含有n个关键字信息: (n,P0,K1,P1,K2,P2,......,Kn,Pn)。其中:
a) Ki (i=1...n)为关键字,且关键字按顺序排序K(i-1)< Ki。
b) Pi为指向子树根的接点,且指针P(i-1)指向子树种所有结点的关键字均小于Ki,但都大于K(i-1)。
c) 关键字的个数n必须满足: ceil(m / 2)-1 <= n <= m-1。
B-tree中的每个结点根据实际情况可以包含大量的关键字信息和分支(当然是不能超过磁盘块的大小,根据磁盘驱动(disk drives)的不同,一般块的大小在1k~4k左右);这样树的深度降低了,这就意味着查找一个元素只要很少结点从外存磁盘中读入内存,很快访问到要查找的数据。
为了简单,这里用少量数据构造一棵3叉树的形式。上面的图中比如根结点,其中17表示一个磁盘文件的文件名;小红方块表示这个17文件的内容在硬盘中的存储位置;p1表示指向17左子树的指针。
其结构可以简单定义为:
typedef struct {
/*文件数*/
int file_num;
/*文件名(key)*/
char * file_name[max_file_num];
/*指向子节点的指针*/
BTNode * BTptr[max_file_num+1];
/*文件在硬盘中的存储位置*/
FILE_HARD_ADDR offset[max_file_num];
}BTNode;
假如每个盘块可以正好存放一个B-tree的结点(正好存放2个文件名)。那么一个BTNode结点就代表一个盘块,而子树指针就是存放另外一个盘块的地址。
模拟查找文件29的过程:
(1) 根据根结点指针找到文件目录的根磁盘块1,将其中的信息导入内存。【磁盘IO操作1次】
(2) 此时内存中有两个文件名17,35和三个存储其他磁盘页面地址的数据。根据算法我们发现17<29<35,因此我们找到指针p2。
(3) 根据p2指针,我们定位到磁盘块3,并将其中的信息导入内存。【磁盘IO操作2次】
(4) 此时内存中有两个文件名26,30和三个存储其他磁盘页面地址的数据。根据算法我们发现26<29<30,因此我们找到指针p2。
(5) 根据p2指针,我们定位到磁盘块8,并将其中的信息导入内存。【磁盘IO操作3次】
(6) 此时内存中有两个文件名28,29。根据算法我们查找到文件29,并定位了该文件内存的磁盘地址。
分析上面的过程,发现需要3次磁盘IO操作和3次内存查找操作。关于内存中的文件名查找,由于是一个有序表结构,可以利用折半查找提高效率。至于3次磁盘IO操作时影响整个B-tree查找效率的决定因素。
当然,如果我们使用平衡二叉树的磁盘存储结构来进行查找,磁盘IO操作最少4次,最多5次。而且文件越多,B-tree比平衡二叉树所用的磁盘IO操作次数将越少,效率也越高。
上面仅仅介绍了对于B-tree这种结构的查找过程,还有树节点的插入与删除过程,以及相关的算法和代码的实现,将在以后的深入学习中给出相应的实例。
上面简单介绍了利用B-tree这种结构如何访问外存磁盘中的数据的情况,下面咱们通过另外一个实例来对这棵B-tree的插入(insert),删除(delete)基本操作进行详细的介绍:
下面以一棵5阶B-tree实例进行讲解(如下图所示):
其满足上述条件:除根结点和叶子结点外,其它每个结点至少有ceil(5/2)=3个孩子(至少2个关键字);当然最多5个孩子(最多4个关键字)。下图中关键字为大写字母,顺序为字母升序。
结点定义如下:
typedef struct{
int Count; // 当前节点中关键元素数目
ItemType Key[4]; // 存储关键字元素的数组
long Branch[5]; // 伪指针数组,(记录数目)方便判断合并和分裂的情况
} NodeType;
插入(insert)操作:插入一个元素时,首先在B-tree中是否存在,如果不存在,即在叶子结点处结束,然后在叶子结点中插入该新的元素,注意:如果叶子结点空间足够,这里需要向右移动该叶子结点中大于新插入关键字的元素,如果空间满了以致没有足够的空间去添加新的元素,则将该结点进行“分裂”,将一半数量的关键字元素分裂到新的其相邻右结点中,中间关键字元素上移到父结点中(当然,如果父结点空间满了,也同样需要“分裂”操作),而且当结点中关键元素向右移动了,相关的指针也需要向右移。如果在根结点插入新元素,空间满了,则进行分裂操作,这样原来的 根结点中的中间关键字元素向上移动到新的根结点中,因此导致树的高度增加一层。
咱们通过一个实例来逐步讲解下。插入以下字符字母到空的5阶B-tree中:C N G A H E K Q M F W L T Z D P R X Y S,5序意味着一个结点最多有5个孩子和4个关键字,除根结点外其他结点至少有2个关键字,首先,结点空间足够,4个字母插入相同的结点中,如下图:
当咱们试着插入H时,结点发现空间不够,以致将其分裂成2个结点,移动中间元素G上移到新的根结点中,在实现过程中,咱们把A和C留在当前结点中,而H和N放置新的其右邻居结点中。如下图:
当咱们插入E,K,Q时,不需要任何分裂操作
插入M需要一次分裂,注意M恰好是中间关键字元素,以致向上移到父节点中
插入F,W,L,T不需要任何分裂操作
插入Z时,最右的叶子结点空间满了,需要进行分裂操作,中间元素T上移到父节点中,注意通过上移中间元素,树最终还是保持平衡,分裂结果的结点存在2个关键字元素。
插入D时,导致最左边的叶子结点被分裂,D恰好也是中间元素,上移到父节点中,然后字母P,R,X,Y陆续插入不需要任何分裂操作。
最后,当插入S时,含有N,P,Q,R的结点需要分裂,把中间元素Q上移到父节点中,但是情况来了,父节点中空间已经满了,所以也要进行分裂,将父节点中的中间元素M上移到新形成的根结点中,注意以前在父节点中的第三个指针在修改后包括D和G节点中。这样具体插入操作的完成,下面介绍删除操作,删除操作相对于插入操作要考虑的情况多点。
删除(delete)操作:首先查找B-tree中需删除的元素,如果该元素在B-tree中存在,则将该元素在其结点中进行删除,如果删除该元素后,首先判断该元素是否有左右孩子结点,如果有,则上移孩子结点中的某相近元素到父节点中,然后是移动之后的情况;如果没有,直接删除后,移动之后的情况.。
删除元素,移动相应元素之后,如果某结点中元素数目小于ceil(m/2)-1,则需要看其某相邻兄弟结点是否丰满(结点中元素个数大于ceil(m/2)-1),如果丰满,则向父节点借一个元素来满足条件;如果其相邻兄弟都刚脱贫,即借了之后其结点数目小于ceil(m/2)-1,则该结点与其相邻的某一兄弟结点进行“合并”成一个结点,以此来满足条件。那咱们通过下面实例来详细了解吧。
以上述插入操作构造的一棵5阶B-tree为例,依次删除H,T,R,E。
首先删除元素H,当然首先查找H,H在一个叶子结点中,且该叶子结点元素数目3大于最小元素数目ceil(m/2)-1=2,则操作很简单,咱们只需要移动K至原来H的位置,移动L至K的位置(也就是结点中删除元素后面的元素向前移动)
下一步,删除T,因为T没有在叶子结点中,而是在中间结点中找到,咱们发现他的继承者W(字母升序的下个元素),将W上移到T的位置,然后将原包含W的孩子结点中的W进行删除,这里恰好删除W后,该孩子结点中元素个数大于2,无需进行合并操作。
下一步删除R,R在叶子结点中,但是该结点中元素数目为2,删除导致只有1个元素,已经小于最小元素数目ceil(5/2)-1=2,如果其某个相邻兄弟结点中比较丰满(元素个数大于ceil(5/2)-1=2),则可以向父结点借一个元素,然后将最丰满的相邻兄弟结点中上移最后或最前一个元素到父节点中,在这个实例中,右相邻兄弟结点中比较丰满(3个元素大于2),所以先向父节点借一个元素W下移到该叶子结点中,代替原来S的位置,S前移;然后X在相邻右兄弟结点中上移到父结点中,最后在相邻右兄弟结点中删除X,后面元素前移。
最后一步删除E,删除后会导致很多问题,因为E所在的结点数目刚好达标,刚好满足最小元素个数(ceil(5/2)-1=2),而相邻的兄弟结点也是同样的情况,删除一个元素都不能满足条件,所以需要该节点与某相邻兄弟结点进行合并操作;首先移动父结点中的元素(该元素在两个需要合并的两个结点元素之间)下移到其子结点中,然后将这两个结点进行合并成一个结点。所以在该实例中,咱们首先将父节点中的元素D下移到已经删除E而只有F的结点中,然后将含有D和F的结点和含有A,C的相邻兄弟结点进行合并成一个结点。
也许你认为这样删除操作已经结束了,其实不然,在看看上图,对于这种特殊情况,你立即会发现父节点只包含一个元素G,没达标,这是不能够接受的。如果这个问题结点的相邻兄弟比较丰满,则可以向父结点借一个元素。假设这时右兄弟结点(含有Q,X)有一个以上的元素(Q右边还有元素),然后咱们将M下移到元素很少的子结点中,将Q上移到M的位置,这时,Q的左子树将变成M的右子树,也就是含有N,P结点被依附在M的右指针上。所以在这个实例中,咱们没有办法去借一个元素,只能与兄弟结点进行合并成一个结点,而根结点中的唯一元素M下移到子结点,这样,树的高度减少一层。
为了进一步详细讨论删除的情况。再举另外一个实例:
这里是一棵不同的5阶B-tree,那咱们试着删除C
于是将删除元素C的右子结点中的D元素上移到C的位置,但是出现上移元素后,只有一个元素的结点的情况。
又因为含有E的结点,其相邻兄弟结点才刚脱贫(最少元素个数为2),不可能向父节点借元素,所以只能进行合并操作,于是这里将含有A,B的左兄弟结点和含有E的结点进行合并成一个结点。
这样又出现只含有一个元素F结点的情况,这时,其相邻的兄弟结点是丰满的(元素个数为3>最小元素个数2),这样就可以想父结点借元素了,把父结点中的J下移到该结点中,相应的如果结点中J后有元素则前移,然后相邻兄弟结点中的第一个元素(或者最后一个元素)上移到父节点中,后面的元素(或者前面的元素)前移(或者后移);注意含有K,L的结点以前依附在M的左边,现在变为依附在J的右边。这样每个结点都满足B-tree结构性质。
B+-tree:是应文件系统所需而产生的一种B-tree的变形树。
一棵m阶的B+-tree和m阶的B-tree的差异在于:
1.有n棵子树的结点中含有n个关键字; (B-tree是n棵子树有n-1个关键字)
2.所有的叶子结点中包含了全部关键字的信息,及指向含有这些关键字记录的指针,且叶子结点本身依关键字的大小自小而大的顺序链接。 (B-tree的叶子节点并没有包括全部需要查找的信息)
3.所有的非终端结点可以看成是索引部分,结点中仅含有其子树根结点中最大(或最小)关键字。 (B-tree的非终节点也包含需要查找的有效信息)
a) 为什么说B+树比B-tree更适合实际应用中操作系统的文件索引和数据库索引?
1) B+-tree的磁盘读写代价更低
B+-tree的内部结点并没有指向关键字具体信息的指针。因此其内部结点相对B-tree更小。如果把所有同一内部结点的关键字存放在同一盘块中,那么盘块所能容纳的关键字数量也越多。一次性读入内存中的需要查找的关键字也就越多。相对来说IO读写次数也就降低了。
举个例子,假设磁盘中的一个盘块容纳16bytes,而一个关键字2bytes,一个关键字具体信息指针2bytes。一棵9阶B-tree(一个结点最多8个关键字)的内部结点需要2个盘快。而B+-tree内部结点只需要1个盘快。当需要把内部结点读入内存中的时候,B-tree就比B+-tree多一次盘块查找时间(在磁盘中就是盘片旋转的时间)。
2) B+-tree的查询效率更加稳定
由于非终结点并不是最终指向文件内容的结点,而只是叶子结点中关键字的索引。所以任何关键字的查找必须走一条从根结点到叶子结点的路。所有关键字查询的路径长度相同,导致每一个数据的查询效率相当。
关于B-Tree/B+-Tree的相关代码可以见后续参考。后续个人也会再发系列博文详述。
外存操作
前面的理论性介绍都是从别的地方摘抄过来的,详细描述了B-Tree在内存中的操作步骤以及需要考虑的各种情形以保证B-Tree的结构。从前文中描述可知,此种数据结构是很适合进行持久化保存的,其主要原因就是,每次数据的变化(增加、删除)导致的树的形状变化时影响的节点比较小,这样在存放到外存时,就不需要更改整个B-Tree,只需修改被影响的节点,从而大大减少了IO次数。
在真正的系统中(数据库和文件系统),使用B-Tree存储时,不会使用以上类似方式存储数据,而是采取页的方式管理,页式内存管理已经在操作系统实现中被证明是一种成熟高效的管理方式。在其他应用系统中使用页管理数据文件,有如下几大优点:
1. 批量化IO操作,避免不必要的IO操作
通过将相邻的IO操作归一化到同一个页面中进行操作,可以避免多次IO,若操作系统比较繁忙,且同一页面的随机IO请求次数较多但时间比较分散时,此时效率最低,系统将不得不每次进行寻道。归一化到以页面为单位,则可以避免类似情形。
2. 能尽量使用操作系统优势
操作系统同样以页为单位,若将B-Tree数据管理的页面大小设置成系统页面大小,在进行IO操作时、内存操作时,OS将能以最优方式进行存储、传输,从而提升性能。
3. 让B-Tree进入实战阶段
根据前文描述,B-Tree需要预先定义阶数,比如5阶,7阶等,若实际情况采取类似方式定义,则需要预先评估应用需要存储的数据个数,这对于类似于数据库系统、文件系统是不可能任务。以页为单位,则用近似的方案避免了此问题,在真正数据库系统中,采取将索引存储到一个页面当中,一个页面相当于一个节点,一个页面节点满了再进行数据插入时则进行页面分裂。这样既避免了需要预先定义阶数,又避免了因不同节点数据大小不一致导致的内存大小不一致问题。为什么使用页面而不定义阶数,使用页面的管理方式能够保持B-Tree结构和性质呢?
LMDB使用
下面讲述LMDB中如何实现B-Tree结构的?
LMDB中代码主要分为两块以实现B-Tree结构。
1. page管理
page管理实现了上述描述的外存操作方式,具体实现的功能包括
mdb_page_malloc:为新页面分配内存
从操作系统中申请1个或者n个页面,一般为一个页面,n个页面为overflow页面,分默认会在分配时将初始化最后一个页面为0。
mdb_page_free:释放单个页面,将它放入可重用页面列表。
mdb_page_new::新建页面
首先调用mdb_page_alloc分配页面,然后初始化页面,新建一个页面时,认为这个页面是一个全新的页面,因此需要其整个空间可用,初始化设置将体现这点。
mdb_page_alloc: 分配页面
分配一个或n个页面,若分配n个,则n个页面是连续页面。若事务中可用脏空间没有了,则分配失败,可用脏空间是指存储脏页ID的数组大小,配置为131071。LMDB中所有可用的脏页同样被维护成一颗B-Tree,freeDB中记录了最后一次放入页面的事务ID,每次分配时都从freedb中寻找足够大重用空间,一般分配一个页面能满足,连续页面,可能需要尝试多次,因此多个页面一般是overflow 页面,必须是连续页面才能满足要求。FreeDB的构建过程以及存储格式见本系列其他博文。
将页面内容从一个页面复制到另外一个页面,此功能主要用于cow
页面的分裂合并是用于B-Tree插入删除时,为满足平衡子节点需要进行的操作。具体应用条件参见本文前面关于插入、删除的描述。
页面分裂:
mdb_page_split:实现了上述B-Tree的操作过程,考虑了仅有一个节点时、append模式、braches/leaf/leaf2等不同页面的处理过程,基本流程就是根据一定的算法确定分裂点,根据B-Tree的定义,在分裂时,不一定需要保证平分,只需要保证页面节点保持半满即可。分裂点确定之后,就进行数据的移动并插入导致分裂的数据以及修改指针以维持B-Tree结构,同时再决定是否会导致上层分裂以及root分裂,若会则进行递归处理。
页面合并:
mdb_page_merge:同样是实现了上述因为节点删除导致的merge过程。基本过程是,将合并的目标页面置为脏页,然后根据上述理论情况进行节点的一个个复制,或者对于内部节点而言进行页面指针调整以及进行上下节点的移动,对于本页完成之后进行平衡操作,其中平衡操作可能会又导致merge操作,直到B-Tree重新满足定义为止。
mdb_page_spill:将脏页写回磁盘,这是为了嵌套长事务进行的设计,有些嵌套长事务会使用大量的页,为了避免耗光内存,可以将脏页写回磁盘,写回磁盘如同commit一样,因为多个进程、线程之间将只会存在一个写事务,因此在未提交之间前写回磁盘没有任何问题。而且只要能有空间,页面就不会刷入磁盘。在执行时,先计算是否空间足够,不够的将id存入idl数组,然后刷入磁盘,再根据环境变量决定是否保留p_dirty标记。
mdb_page_unspill:将spill的页面重新读回,这就不需要进行touch,直接设置dirty标志就可以了。lmdb支持嵌套事务,因此在查找页面是否属于已经被spilled的页面需要查找整个嵌套路径,从叶子到跟,找到之后确认midl列表(脏空间)是否有足够空间,没有的提示事务空间已满,否则加载页面并设置脏页标记。
mdb_page_dirty:设置脏页标记,并将脏页加入到事务中的脏页列表当中。
mdb_page_flush:用在事务提交时,当清除页面脏页标记后,将数据更新到磁盘(通过写文件方式).若使用试验的特征(mmap写),则在清除脏页就完成工作,因为写操作交给系统完成,否则需要计算文件起止地址后将页面一页页的写入磁盘,最后释放脏页内存,特殊原因需要留存内存的页面不参与flush。
mdb_page_touch: 实现COW的技术,复制一个页面,并将更新过B-Tree指针关系的页面插入到B-Tree当中,这样意味着在修改时是在复制的页面上进行修改,别的事务在本事务没有提交之前看到还是以前的数据,提交之后的新事物看到的才是修改之后的数据。
mdb_page_search_root:从B-Tree根节点检索,根据key的值,从根节点开始遍历子树获取每一层对应的page,在page之内检索key,再根据B-Tree查找方法确定下一层子节点的page,层层遍历,从而最终确定key的位置或者判断B-Tree中没有对应的key。同时将页面存放到cursor页堆栈中。这样cursor将可以重用对应的页面,为后续进行更新等操作提供便利。
mdb_page_search/mdb_page_search_lowest都将调用mdb_page_search_root以完成检索
mdb_page_search,除了完成检索为的附加工作是确保所使用的B-Tree在本事务可见范围内是最新版本,同时在需要时将页面置为脏页。
mdb_page_search_lowest: 从当前分支页开始,检索第一个符合条件的值。
mdb_page_get:获取页面,本来根据MMAP原则,读取对应的页面非常简单,计算下地址即可,但lmdb中,考虑到事务可能使用大量的页面,事务可用空间满时,将一部分页面spill/flush到磁盘中,因此需要在get时判断是否在spill列表中,在的话从中获取,否则直接计算获取。
mdb_page_list:显示页面中的所有key,是个工具方法。
2. cursor操作
cursor操作实现了B-Tree节点操作,cursor指向当前需要进行操作的B-Tree节点,然后依据提供的操作方式(insert、del)进行数据操作,然后进行一系列复杂的操作流程以维持B-Tree结构。具体实现的功能包括:
mdb_cursor_first: 将游标定位至B-Tree的最小叶子节点(第一个),而非根据key查询时得到第一个结果位置。若支持重复数据,还要特殊处理,移动到重复数据第一个。
mdb_cursor_last:与first类似,只不过定位至最大叶子节点(最后一个)
mdb_cursor_next: 游标移动至下一个节点
mdb_cursor_prev: 游标移动至前一个节点
mdb_cursor_sibling:将游标移动至兄弟节点,可以是前一个页面或者下一个页面。
若当前页有key,则行为与next、prev类似,否则移动到下一个页面的对应key位置。
mdb_cursor_get:根据游标位置和条件获取值,最常用:MDB_GET_CURRENT,获取游标所指节点的值,基本思路是看页面中索引是否已经大于key个数,大于则说明游标已经需要指向下一页,对于取当前值的不重复key来说,这不可能,因此获取失败。然后根据是否为leaf2页面(key重复),是根据宏取值,否则判断叶子值是否有副本(完全重复key和值,有的话初始化xcursor并开始取值,否则话直接读取对应位置的值。
mdb_cursor_set :将游标设置(定位)到指定key位置,假如已经在正确页面,只需要判断key是否在页面key的范围之内,判断最大、最小值可以确定。然后根据相应标志,如同get中所说,进行判断以及读取或设置某些变量。否则话进行页面查找先定位key所在页面(mdb_page_search),然后定位页面中位置(mdb_node_search),然后再设置相关变量。
mdb_cursor_count:返回游标代表的结果数,唯一key返回一,重复key返回重复个数。
mdb_cursor_put:将key、value对存放到数据库中,默认是新增加,若key已经存在则是更新,基本流程是:判断前提cursor、key非空,确认各种标志是否合法,比如多个value,但是数据库不支持重复key这种情形就不合法,标志合适之后,判断是否为空树,非空时将cursor指向正确的位置,比如append模式指向数据库最大节点之后,正常指向应该插入的位置。然后touch所有页面使所有页面可写。若为leaf2类型页面,说明key、value完全重复,增加key就OK了,然后再判断value值是否太大,太大则转换为子树进行存储。转化为subdb/subpage时,首先根据各种标志设置各种变量,包括申请新页等,然后其余的就是根据各种标志完成上述理论描述的节点插入动作,将值放置对应位置、进行分页等,需要时进行unspill,放置到overflow页面等,若一次插入多条数据还需要多次重复进行一次一条的插入。
mdb_cursor_del,mdb_cursor_del0:删除指定key、value。首先是根据各种标志设置各种变量,其次设置页面为脏页,其次若删除之后,subdb/subpage,overflowpage等收到影响,则需要将对应页面回收到free-list,比如subdb删除最后一个节点时,需要删除整棵子树。真正的key删除在del0中,它从页面中删除对应的key,删除完成后对整个B-Tree进行rebalance,然后修正所有指向当前删除页的同一事务内的其他cursor,通知其他cursor此页面已经被删除。
mdb_cursor_copy:复制游标,将所有内容从一个复制到新游标。
mdb_cursor_shadow:备份cursor对应事务的游标,用于envcopy
mdb_cursor_init:设置各种变量,若数据库状态为DB_STALE,则需获取最新的root节点。
以上内容简单介绍了lmdb内部使用的B-Tree原理,以及针对B-Tree操作的各种函数的简单介绍,关于B-Tree存储subdb,subpage,overflowpage等还未足够仔细,若有时间将在后续博文中单开一篇详细描述以上非正常key-value的数据存储和操作方式。LMDB中的B-Tree实现的是B+-Tree,所有叶子节点都在同一层级。
本文中不足之处很多,欢迎交流指正。
参考文献:
https://en.wikipedia.org/wiki/B-tree
http://blog.csdn.net/hbhhww/article/details/8206846
http://slady.net/java/bt/view.php
计算机程序和艺术