符合X/Open技术规范的UNIX版本自备了一个数据库dbm。它使用hash表来保存非结构化数据,不支持SQL。它只是一个存储检索数据的例程。数据以key/data对的形式存储在文件中。规范中允许把关键字/数据对的长度限制为1023个字节,但通常实现时不限制。关键字的取值被用作存储数据的索引。dbm被X/Open组织标准化为ndbm。GNU的dbm实现为gdbm(GNU dbm的缩写),它本身的接口实现与旧版本不同,但它同时也提供了旧版本的dbm和ndbm实现,因此gdbm兼容dbm和ndbm。dbm、gdbm适合存储静态的,索引化的数据结构。适用于处理那些被频繁访问但却很少被更新的数据,因为它创建数据项时非常慢,但检索数据项时非常快。(摘自http://blog.csdn.net/zhoudaxia/article/details/4379313)
在上面的网站上周大侠详细地剖析了gdbm的源代码,我自己也从Git上下了一个版本看了看。谈谈自己的收获。
Gdbm是一个小巧的数据库系统。虽然很短小,但是读完以后却是收获颇丰,感觉对数据库的整个理解又明朗了一些。
个人认为数据库只不过是索引+数据存储的组合,把这两个概念延伸到分布式系统中来,就是状态节点+数据节点。这又一次说明其实分布式系统真的是单机系统的延伸。类似地,存储在磁盘中的数据库系统其实也是内存中查找结构的一种延伸。
由熟悉到陌生,先来看看如果我们需要在内存中存储查找数据会怎么做。假如所有的数据都驻留在内存,最简单的方法就是使用一个list存储一些pair
这样我们就可以得到一个只驻留在内存中的数据库。那还有什么优化没有呢?最理所当然的一个优化,就是对于频繁删除更新的数据库来说,申请和释放内存都浪费时间,那好,我们就建立一个自己的内存池。
在内存中操作数据库,应该注意什么?
1.时间上,我们需要保证的是性能。这包括
1.1.优越的查找性能。使用B+树而不是链表。
1.2.对增删友好。使用红黑树而不是B+树。
1.3.高效的内存利用。也就是内存的申请释放机制。
2.空间上,必须认识到内存是有限的。我们能存储的数据是有限的,但是一般高效的索引结构又很占内存,所以必须在时间和空间上做权衡。
但是人们总是希望数据库是持久化的,当关机重启时,数据仍然是可获得的。于是可以把数据库放在一个磁盘里。定期将内存中的数据持久化到磁盘,也就是序列化。当重启时,从磁盘中读取数据库的相关索引信息,也就是反序列化。但是由内存迁移到磁盘,存储环境发生了很大的变化。有一点需要时刻记住的就是:
磁盘对随机读写很不友好,但是对于顺序读写相对友好。
因此,除了考虑内存上的需求,我们还需要考虑对磁盘读写的优化。
1.时间上,尽量避免随机读,尤其是随机写。可以使用cache来避免多次读取,使用copyu-on-write技术避免多次写入。可以使用leveldb的思想,以空间换时间,将所有修改放在文件的末尾。
2.空间上,虽然磁盘相对内存来说很大,但是也不是无限大的。而且对于一些数据库之间的拷贝操作来说,比如使用网络从一台机器拷贝一个数据库的数据到另一台机器,则需要计算传输代价和空间代价的轻重了。
3.它需要对程序的异常退出做善后处理。
4.更加高深的内容,就是数据库的事务了。
总体来看,Gdbm使用了双重哈希(不太清楚这个哈希到底是叫啥)作为索引结构解决上述内存的问题。对于磁盘的问题,它使用了Cache技术和copy-on-write技术,并建立类似于内存池的文件可用块链表,以申请文件空间存放数据。个人收获最大的是双重哈希和文件可用块链表的使用。
GDBM的哈希技术
双重哈希
对于哈希算法应该满足的特性,一致性哈希作如下的解答:平衡性、单调性以及在分布式环境中的分散性和负载。(http://baike.baidu.com/view/1588037.htm) GDBM的哈希技术解决了单调性的问题。
一般的哈希算法是分配一个初始大小的缓冲,例如是4个,使用一定的寻址算法将值映射到缓冲区内,当发生冲突时又定义相应的冲突解决方案。例如使用最简单的线性探查函数elem_loc = hval %bsz来寻找初始位置,冲突解决方案定位elem_loc =(elem_loc+1)%bsz,那么插入哈希值为0,1,4,8后缓冲区的情况为
如果再插入5,就需要继续扩大缓冲区,假设扩为8,那么就需要重新安排各个元素的位置,这需要4次计算。最后的一种可能结果为
哈希算法的单调性是指:当缓冲区大小变化时哈希算法应该尽量保护已分配的内容不会被重新映射到新缓冲区。上面的解决方案显然没有做到:原来所有的4个元素都被重新计算了位置。
为了解决这个问题,GDBM使用了双重哈希(这个哈希应该有一个学名,不过我给忘了- -)。具体的结构如下
如上图,最左边的叫做“dir”,通过查询它的元素的值可以查找到相应的hash_backet,这个bucket类似于普通哈希的table,它的元素叫做bucket_elem。Dir的大小总是2^dir_bits个,一个哈希值首先要取它的头dir_bits位来计算到底在dir的哪个位置,即对应哪一个hash_bucket。然后根据一般的寻址算法将元素放入到hash_bucket中,占据一个bucket_element的位置。dir_bits的位数是会改变的,而这也正是双重哈希能够满足单调性的秘诀所在。
例如对于hash值为11111111111111111111111111111111(二进制,最高位在左边),由于dir_bits为2,那么使用最左边的11来作为dir的哈希值。3%4=3,那么它对应第二个桶。找到相应的桶后,再用普通的哈希就可以放置元素了。
如上图,可以看到0、1两个dir_elem对应同一个hash_bucket1,2、3则对应hash_bucket2。当任意一个bucket,比如hash_bucket1,满负载时,则进行分裂,一个bucket一分为2,这时,一个dir_elem会因为对应两个bucket而不满足哈希要求。dir_elem0和dir_elem1都对应hash_bucket1,所以可以将dir_elem0指向hash_bucket0,dir_elem1指向hash_bucket1:
这样最高两位为00的哈希值将被映射到3,01的被映射到4,而10和11则仍然被映射到2。
继续插入,如果3满了,则3将要分裂为5,6,但是dir_elem0下面的dir_elem1已经指向了一个hash_bucket,不能用来指向6了,这时就需要分裂dir:
如果hash_bucket4分裂,由于dir_elem2和dir_elem3都指向了桶4,所以只需要将2和3的指针变化一下就可以了。分裂后,需要更新dir_elem的指针,更新的代码为:
其中new_bits是hash_bucket原来的bit加1,每个hash_bucket的bucket_bits是不同的,例如对于上图,5,6的bit为3,4为2,而2为1(因为其实2是由桶0分裂来的,图中没有画出来),bucket_bits表示这个桶是由最先的桶(bucket_bits为1)分裂多少次得到的。
冲突解决方案
GDBM中的冲突解决方案为elem_loc = (elem_loc+1)%bucket_size,这是最简单的冲突解决方案了。在看数据结构和算法导论时看到冲突解决函数总是一看就过去:如果插入时冲突了就怎么怎么着,觉得挺简单,但是真正看到哈希的代码时顿时就愣住了:以前只考虑了元素的插入,并没有考虑元素的删除! 例如对于
如果删除时不做处理,当我们删除掉0元素,查找元素8时,首先定位到位置0,然后发现位置0的哈希值为-1,无法继续前行,则会返回空值,但实际上8是存在的。
所以我们需要更新哈希表,以便在查找时能得到正确结果。首先最简单的一种方法是将依次遍历位置0以后的元素,如果它们的哈希值在取余后跟为0,那么将其位置移动到位置0,这时位置2是空的,接着遍历位置2后的元素,如此反复,直到指针到达位置0,。但是这样做有一个明显的问题就是对于下面的情况:
位置3为空的,但是查找元素2时我们还是得不到正确结果。
综合考虑,之所以会出现查找不到的情况,是因为由于删除的原因,我们在哈希表里制造了一个坑,对于这个坑以后的元素,我们的查找算法是没有办法越过这个坑而到达正确位置的,所以需要将这个坑往后移,直到它不再阻挡任何元素的查找:
last_loc是坑的位置,elem_loc是元素现在在hash_bucket中的位置,而home则是查找该元素的起始位置。理解这段代码时应该从home出发,看看坑(last_loc)是否在我们到达elem_loc的途径中,如果是,则使用elem_loc处的元素填补该坑,elem_loc成为一个坑。循环的结束条件是dbf->bucket->h_table[elem_loc].hash_value == -1,也就是坑被放到了末尾和elem_loc != last_loc,这个坑不再阻碍我们达到存储元素。
对于其它冲突解决方案,应该也是这么个思路吧。
GDBM的文件碎片管理
falloc.c文件是GDBM对文件空间的操作。因为数据的删除以及更新的长度变大,一些文件空间会被释放掉从而可以写入其它数据。这样文件中会存在许多(最坏情况下)的碎片——就好像内存的申请释放一样。为了使数据库文件尽可能小,需要记录文件碎片的位置并在适当的时候加以利用。
GDBM使用链表来记录文件的可用块——av_element(呃,这个名字好诱惑)就是一个可用的文件空间,包括这个空间的起始地址和大小。许多的av_element组成一个av_block,然后多个av_block串成一个链表。其中GDBM的header所占的第一个block_size大小的文件块中其余部分成为一个av_block并处于链表的头部。av_block的next_block域相当于next指针。另外,每个hash_bucket也对应一个av_element的数组叫做bucket_avail。这样,GDBM中就有三个地方存储av_element:header中的av_block(最多size个),文件中的av_block(固定的size/2+1个),还有hash_buck中的最多BUCKET_AVAIL个。
插入新的元素需要文件空间时,从dbf当前的bucket的bucket_avail中取到满足大小的块。如果没有满足条件的块,则从header的av_table中分配——为了使查找更快,av_table中的元素是按照element的大小由小到大排列的,如果没有足够大小的块,则直接从文件末尾分配。
删除元素或者因为更新释放文件的可用空间时,将可用的文件块放入到当前bucket的bucket_avail中。
如果可用文件块太多,超过了header的av_table的容量,则需要把可用块记录在文件中。这时av_table中的一些av_element被分离出来,成为一个链表节点放入到文件中的可用位置上。在GDBM中,header的av_table以后的链表元素被成为“stack”,所以上面的操作也叫做push_avail_block。当av_table中的元素数目下降到一定的数目时,则从stack中取出一个av_block,将其中的元素加入到av_table中,这被称作pop_avail_block。
综上,文件的分配其实是先查询bucket的bucket_avail,然后再查询header的av_table,不满足时,最后av_table又跟stack交互,push或者pop可用块。
把可用文件块的操作跟普通的内存池操作练习起来,falloc的原理应该就比较好理解了。这里值得学习的是把文件当做内存来管理的方法和思路。
GDBM的优劣
GDBM有如下的亮点:
1.首先GDBM实现了一个轻巧的数据库系统,从它身上我们可以学习到数据库的索引存储结构和数据存储部分的相关知识。尤其是双重哈希和冲突解决方案的回溯。
2.GDBM对文件碎片的类似内存池的管理提供了一种将文件看做内存来操作的思路。
但是GDBM也有自己的缺点:
1.首先,它的哈希索引结构是一种无序的存储结构。哈希使用取余来寻址数据的存储位置,而不是像B+树一样有序地存储数据,所以在寻找范围数据时显得力不从心。所以GDBM不适合作为范围查询的数据库。
2.GDBM的文件碎片管理有一定的问题。对于数据大小波动大的数据,会出现随机写非常严重的情况。所以它适合频繁访问但是很少更新的数据。
3. GDBM没有处理数据库异常关闭的情况。
文章来源: http://blog.csdn.net/lemonk3664/article/details/9837523