mdb - memory db
mdb是cabinet的一种数据组织方式,其他还有hdb(hash)、bdb(btree)等,详见"tokyo cabinet源码分析"。
由名字可知,mdb使用纯内存(不一定,见下面),速度最快。它是后面其他较高级的数据组织方式的基础,hdb、bdb的read cache都是直接使用mdb实现。而且mdb的实现最简单,所以先从mdb分析。
先贴出mdb结构图片:
这张是mdb的结构图,它表达了mdb数据组织和访问的方式:
1、最上层是maps数组,长度固定且有限(8个)。对key 的访问先hash到其中一个map;
2、每个map由buckets数组组成(较大,由参数决 定)。key经过第二次hash(不同于第一个的hash)到其中一个bucket;
3、hash到同一bucket的records用二叉搜索树 组织(查找效率高);
4、对key的查找在二叉搜索树中进行
这就是mdb的大体结构。
----------------------------------
接下来一层描述细致一些的方面:访问流程、多线程读写的同步、锁的粒度,等。
关键的数据结构。别被吓跑~ 很简单的:
typedef struct {
void **mmtxs; /* pthread_rwlock_t */
void *imtx; /* pthread_mutex_t */
TCMAP **maps;
int iter;
} TCMDB;
@mmtxs : 读写锁。用于锁maps,所以其个数和map个数相同(8个);
@imtx : iterator互斥锁。用于支持游标-遍历
@maps :即图片中的maps,结构定义见下。
typedef struct { /* type of structure for a map */
TCMAPREC **buckets; /* bucket array */
TCMAPREC *first; /* pointer to the first element */
TCMAPREC *last; /* pointer to the last element */
TCMAPREC *cur; /* pointer to the current element */
uint32_t bnum; /* number of buckets */
uint64_t rnum; /* number of records */
uint64_t msiz; /* total size of records */
} TCMAP;
@buckets : 图中的buckets,指向用二叉搜索树组织起来的records,结构定义见下。
@first
@last
@cur : 这三个指针分别指向该map中第一个、最后一个和当前一个record。显然是用于支持游标-遍历的实现。
@bnum
@rnum
@msiz : 这三个是统计信息:buckets num, records num, key value total size
typedef struct _TCMAPREC { /* type of structure for an element of a map */
int ksiz; /* size of the region of the key */
int vsiz; /* size of the region of the value */
unsigned int hash; /* second hash value */
struct _TCMAPREC *left; /* pointer to the left child */
struct _TCMAPREC *right; /* pointer to the right child */
struct _TCMAPREC *prev; /* pointer to the previous element */ /* for listing */
struct _TCMAPREC *next; /* pointer to the next element */ /* queue */
} TCMAPREC;
@ksiz
@vsiz : key,value的长度
@hash : key的hash值。用于record沿二叉树查找时比较。(当然hash值相同并不能证明一定是相同key,但不同的话肯定是不同key,详见下)
@left
@right : 用于组织树。left child, right child
@prev
@next : 双向链表,将该map所有records串连起来。不用说,肯定是用来实现游标-遍历
解释:
在这个record结构体的定义里并没发现key,value 的定义,是因为它是一个变长结构(C编程技巧)--因为key、value长度事先并不知,运行时实际插入record时才能知道,所以采用了这种结构。 优缺点:优点是灵活和空间节约;缺点是需要动态分配,拖累性能--你也可以使用内存池预分配自己做管理,但对这种长度完全不定的串的管理似乎也不是件容易 的事。具体我还没细想。不管怎样,cabinet在此是完全使用动态分配。
关于C的变长结构小技巧,就是分配 时:malloc(sizeof(TCMAPREC) + ksiz + vsiz),这样就分配了足够的空间,结构体外多余的空间是紧挨着该结构体的(一次分配的),利用成员ksiz、vsiz就能对它们进行访问。一种常用技 巧是在结构尾部定义key[0]这种方式访问,另一种方式是直白的record指针+sizeof(TCMAPREC)来访问,据此你甚至可以用宏定义来 虚拟出key指针,小技巧,不多说。
另外实际的存储中key、value间是有做对齐的 (padding),但对描述mdb设计并没影响。
-------------------------------
下面描述一些函数的实现流程:
初始化一个mdb(tcmdbnew):
1、参数处理:
参数bnum: buckets num。调用者不指定的话使用默认值:65536
程序对传入的bnum做进一步处理:
bnum = bnum / 8 + 17 ;
也就是:传入参数过小的话就使用17(质数,提高hash效 果),大的话就用maps数平均一下(8个map)
2、锁的初始化等;
3、分别对8个maps的buckets进行存储分配:
如果新bnum > 32768 , 使用mmap(所以前面说不一定纯内存);
否则 ,使用malloc
解释:
如果你用参数指定过多buckets的话,就会使用mmap。 实际使用中一般就是用默认值,根据上面的等式简单计算知道它用malloc,即纯内存;
关心多少的buckets临界值会让mdb由malloc转为 使用mmap的话,可以自己算一下~
---------------------------
record插入(tcmdbput):
TCMDBHASH得到map下标;
wrlock该map;
TCMAPHASH1得到bucket下标;
TCMAPHASH2得到key的hash值,用于下面的比较;
key的二叉搜索树查找:
如果key的hash值大于当前record的hash值,goto left child;
如果大于,goto right child;
如果等于,strcmp:
如果key小于 此record的key,goto left child;
如果大于,goto right child;
如果等于,找到。写入新的value。(比旧value大就realloc)
没找到key,此时key在二叉树应插入的位置。分配空间填入key、value并把此record插入树
unlock map
解释:
根据上面的图片应该能很好理解插入的流程,它通过不同hash 函数一级级往下找到bucket,再在二叉树中用hash值对key进行查找。因为相同hash值不代表key一定相同,所以找到相等hash值时还需用 strcmp进行串比较。但显然它先用hash比较排除了大量不符的key,大大提高了性能。
本节前面提到mdb的内存分配完全采用动态分配,并认为它会影 响到性能。这里有这样一个处理:put时如果旧value空间不足以放下新value,当然就realloc;如果足够,则是直接使用旧的空间,避免了内 存的分配。这样程序运行一段时间后需要做动态内存分配的次数会越来越少(因为只增加不减少)。这里有这样一个问题:如果新value所需空间远小于旧 value空间(比如小于1/2),是否需要free掉旧的大空间然后分配一个小空间给新value呢?我觉得取决于应用,作为一个通用的系统,我觉得它 应该在空间比到某个系数(比如1/2)时做重分配。但mdb没这么做。
这里有一个问题就是锁的粒度,在后面函数流程描述完了再细说;
这几个hash函数的实现代码放在本节的最后面,它们属于细枝 末节。
---------------------------
record删除(tcmdbout):
record查找逻辑和put一模一样,hash到map下标,锁住map,hash到bucket下标,计算
key的hash值,沿二叉搜索树往下查找:
没找到
要删除的key不存在,直接返回
找到
树结构更新:该record只有左子树或只有右子树,则把相应的子树接上去;都有的话就用左子树接上去,并把右子树接到左子树
最右边record的右边...(二叉搜索树的基本操作,不细说了,详细资料可
以搜索得到)
释放record
---------------------------
r ecord查找(tcmdbget):
record查找逻辑和put一样,唯一的区别是对map加读 锁(很显然)
返回value
-------------
关于锁粒度:
我觉得mdb的锁粒度太粗(才8个锁),做插入或删除的时候直 接写锁住整个map(全部数据的1/8左右),其他需要访问此map的操作全部得串行、阻塞。(很怀疑我是不是代码看错了~~ )
后面分析hdb实现可以看到:hdb没有maps这一层,整个 就一个大的buckets,锁粒度细到了接近每个record对应一个锁(行级锁)。那是一个比较好(也比较好理解)的设计;
我对mdb这点设计不太能理解,过几天会再细看下并调参数测一 下。
-->
之后又想了下:
要考虑到mdb在tc中的作用主要是作为hdb、bdb的 read cache使用的,所以:
1、它不应该使用大量读写锁,因为hdb、bdb都使用了大 量的读写锁(默认十几万个)。原因从浅层分析是会占用过多资源,进而影响性能。(知道深层更细致原因的可以补充);
2、作为hdb等的read cache,对它的主要操作是get,锁粒度的粗细对rwlock的read并没影响。mdb也有delete操作,一个例子是对hdb做put时,需要 先从其mdb的cache里delete掉那条record(详细可参见对hdb的分析)。
所以,这个设计和参数选择应该也算是一种折中。这种参数应该 算是"实验数据",可能做了大量测试后才能找到一个好的平衡点。
---------------
游标-遍历 实现
--------------
从最上面结构体定义和说明可以看到,每个map都有三个指针:first、last、cur分别用于指向该map第一个、最后一个和当前一个 record。record结构内部的prev、next则将所有的records串连起来。
就是它们为游标-遍历的实现提供支持。
游标初始化(tcmdbiterinit):
互斥锁锁住mdb->imtx
初始化各map的cur指向其first
解锁
取下一条record的key(tcmdbiternext):
互斥锁锁住mdb->imtx
写锁锁住第mdb->iter个map
返回map的当前游标cur指向record的key和 ksiz
解锁
解释:
iternext函数只是返回key和ksiz,并未修改它, 为何要对map加写锁,而非读锁呢?是因为每次读取后需要修改map的当前游标cur,让它指向next,显然这是不允许多个线程同时执行的;
iterator使用互斥锁而不是读写锁也是这个原因:每次操 作都有数据的更新。
----------------------
最下一层:一些资料
上面描述中提及的几个hash函数(第一个是hash界大名鼎鼎的times33~~,接着的几个是变种吧):
#define TCMDBHASH(TC_res, TC_kbuf, TC_ksiz) /
do { /
const unsigned char *_TC_p = (const unsigned char *)(TC_kbuf) + TC_ksiz - 1; /
int _TC_ksiz = TC_ksiz; /
for((TC_res) = 0x20071123; _TC_ksiz--;){ /
(TC_res) = (TC_res) * 33 + *(_TC_p)--; /
} /
(TC_res) &= TCMDBMNUM - 1; /
} while(false)
#define TCMAPHASH1(TC_res, TC_kbuf, TC_ksiz) /
do { /
const unsigned char *_TC_p = (const unsigned char *)(TC_kbuf); /
int _TC_ksiz = TC_ksiz; /
for((TC_res) = 19780211; _TC_ksiz--;){ /
(TC_res) = (TC_res) * 37 + *(_TC_p)++; /
} /
} while(false)
#define TCMAPHASH2(TC_res, TC_kbuf, TC_ksiz) /
do { /
const unsigned char *_TC_p = (const unsigned char *)(TC_kbuf) + TC_ksiz - 1; /
int _TC_ksiz = TC_ksiz; /
for((TC_res) = 0x13579bdf; _TC_ksiz--;){ /
(TC_res) = (TC_res) * 31 + *(_TC_p)--; /
} /
} while(false)
----------
补充说明
----------
关于一些函数的具体实现代码要不要列出来
有些人对其设计有兴趣的话会想看看它的具体实现代码是怎样的, 我也不太清楚要不要列出来。因为只是简单的罗列,而且会让文章显得很长很乱。所以想研究具体实现细节的请自己直接看源代码。