生成index的表面操作流程:建立View——遍历bucket下所有数据生成index。
本文分析的起点是已经通过View获得了key-value键值对但还未进行加工生成index,之后会结合代码分析杂乱无绪的键值对是如何先进行整理排序得到有序的数据,然后将数据组织为合适的数据结构并最终存入硬盘。(当然,对数据库有了解的人都知道这里的数据结构八成是B+TREE)。
在了解couchbase源码之前推荐先了解couchbase的基本用法,尤其是通过view建立index的过程。
为了支撑复杂的内部实现,该部分有大量的结构体,这些结构体将在需要时顺便说一下,就不专门叙述了。
下面从一个测试函数开始,一步一步分析途中遇到的函数。
void TestCouchIndexer(void) {
fprintf(stderr, "Indexer: ");
srandom(42); // to get a consistent sequence of random numbers
GenerateKVFile(KVPATH, 1000);
srandom(42); // to get a consistent sequence of random numbers
GenerateBackIndexKVFile(KVBACKPATH, 1000);
IndexKVFile(KVPATH, KVBACKPATH, INDEXPATH, COUCHSTORE_REDUCE_STATS);
ReadIndexFile(INDEXPATH);
unlink(KVPATH);
unlink(INDEXPATH);
fprintf(stderr, "OK\n");
}
这个测试函数的流程很简单:
1.随机生成1000个key-value键值对并存入路径KVPATH;
2.随机生成1000个id-key键值对并存入路径KVBACKPATH;
3.使用生成的数据去建立index并存入路径INDEXPATH;
4.读取建立的index检查是否正确。
这里解释一下这两个键值对的意义。
在View中最后会emit一个键值对出来,这里emit的第一个传入参数就是key,第二个传入参数就是value。
而id则是文档存入数据库时自动获得的独特标识符。这两个键值对就相当于把id和value可以联系起来了,以备后用。
我们称key-value生成的为primary index,id-key生成的为back index。
这个测试函数包括了index建立的必要过程,从外到里把这个函数的逻辑搞明白也就搞明白了index。
进入测试函数之后,首先生成可供使用的两组数据,然后把两组数据加工成index,最后读取index检测正误。
我们直接就看加工INDEX的函数即可,整个测试函数就是为了测试这一个函数而已。
IndexKVFile(KVPATH, KVBACKPATH, INDEXPATH, COUCHSTORE_REDUCE_STATS);
前三个参数分别是三个文件的路径,最后一个参数是个跟reduce函数有关的状态量。
如果知道couchbase的view是如何工作在map/reduce上的话,这里看注释也就知道这四种状态量的含义了。
不明白的话等以后分析到reduce的实现时候再说吧。
enum {
COUCHSTORE_REDUCE_NONE = 0, /**< No reduction */
COUCHSTORE_REDUCE_COUNT = 1, /**< Count rows */
COUCHSTORE_REDUCE_SUM = 2, /**< Sum numeric values */
COUCHSTORE_REDUCE_STATS = 3, /**< Compute count, min, max, sum, sum of squares */
};
static void IndexKVFile(const char* kvPath, const char* backIndexPath, const char* indexPath, couchstore_json_reducer reducer)
{
couchstore_error_t errcode;
CouchStoreIndex* index = NULL;
try(couchstore_create_index(indexPath, &index));
try(couchstore_index_add(kvPath, COUCHSTORE_VIEW_PRIMARY_INDEX, reducer, index));
try(couchstore_index_add(backIndexPath, COUCHSTORE_VIEW_BACK_INDEX, 0, index));
try(couchstore_close_index(index));
cleanup:
assert(errcode == 0);
}
这段代码已经开始出现各种没出现过的结构体了,以后随着分析的深入结构体只会越来越多,在分析中比较重要的结构体会特别贴出来并加上对各成员的注解,辅助性的中间结构体就不多描述了,感兴趣的自己拿源码看吧。顺便吐槽一下,couchbase的源码想拿到都比较麻烦,因为中间有些代码是在googlesource上,还得特殊处理一下才能获取。此函数流程就是index建立的总流程,先生成文件,然后把数据添加进文件,最后关闭文件。CouchStoreIndex是个相当重要的数据结构,贯穿于整个index建立过程之中。
struct _CouchStoreIndex {
tree_file file;
uint32_t back_root_index;
uint32_t root_count;
node_pointer** roots;
};
先看第一项tree_file,从名字直接就可以看出来这index跟树形结构一定是有关系的,具体结构等到分析建树的时候再拆解,总之知道这代表一个树就好。第四项又出现了一个新的结构体node_pointer,这也是个重要的结构体,数据主要就在这个结构体里,但现在也先不拆开。第二项和第三项马上就会提到,知道有这两个成员就好。
couchstore_error_t couchstore_create_index(const char *filename,
CouchStoreIndex** index)
{
couchstore_error_t errcode = COUCHSTORE_SUCCESS;
CouchStoreIndex* file = calloc(1, sizeof(*file));
error_unless(file != NULL, COUCHSTORE_ERROR_ALLOC_FAIL);
error_pass(tree_file_open(&file->file, filename, O_RDWR | O_CREAT | O_TRUNC,
couchstore_get_default_file_ops()));
file->back_root_index = UINT32_MAX;
*index = file;
cleanup:
return errcode;
}
这个函数的作用是新建一个(若已有则清空)index并新生成一个树。这里的error_unless和error_pass只是两个异常处理用的函数,很好推测用途。这个初始化函数里面给以后需要用到的结构体开辟了空间,然后把back_root_index这个值赋予最大值。至此一个index的初始化完成,可以进行后续的操作了。
完成index初始化后我们将依次把key-value键值对和id-key键值对加工成index。
couchstore_error_t couchstore_index_add(const char *inputPath,
couchstore_index_type index_type,
couchstore_json_reducer reduce_function,
CouchStoreIndex* index)
{
if (index_type == COUCHSTORE_VIEW_BACK_INDEX && index->back_root_index < UINT32_MAX) {
return COUCHSTORE_ERROR_INVALID_ARGUMENTS; // Can only have one back index
}
couchstore_error_t errcode;
TreeWriter* treeWriter = NULL;
node_pointer* rootNode = NULL;
CurrentIndexType = index_type;
CurrentReducer = NULL;
if (index_type == COUCHSTORE_VIEW_PRIMARY_INDEX) {
switch (reduce_function) {
case COUCHSTORE_REDUCE_COUNT:
CurrentReducer = &JSONCountReducer;
break;
case COUCHSTORE_REDUCE_SUM:
CurrentReducer = &JSONSumReducer;
break;
case COUCHSTORE_REDUCE_STATS:
CurrentReducer = &JSONStatsReducer;
break;
}
}
error_pass(TreeWriterOpen(inputPath,
(index_type == COUCHSTORE_VIEW_PRIMARY_INDEX) ? keyCompare : ebin_cmp,
view_reduce,
view_rereduce,
&treeWriter));
error_pass(TreeWriterSort(treeWriter));
error_pass(TreeWriterWrite(treeWriter, &index->file, &rootNode));
// Add new root pointer to the roots array:
node_pointer** roots;
if (index->roots) {
roots = realloc(index->roots, (index->root_count + 1) * sizeof(node_pointer*));
} else {
roots = malloc(sizeof(node_pointer*));
}
error_unless(roots, COUCHSTORE_ERROR_ALLOC_FAIL);
if (index_type == COUCHSTORE_VIEW_BACK_INDEX)
index->back_root_index = index->root_count;
roots[index->root_count++] = rootNode;
index->roots = roots;
rootNode = NULL; // don't free it in cleanup
cleanup:
CurrentReducer = NULL;
free(rootNode);
TreeWriterFree(treeWriter);
return errcode;
}
这个函数主要办两件事:把数据写入树,把树的根节点保存在index中。TreeWriterOpen是个对树初始化的函数,主要是确定key的比较函数和reduce函数,最后返回树的指针。也就是初始化下面这个结构体:
struct TreeWriter {
FILE* file;
compare_callback key_compare;
reduce_fn reduce;
reduce_fn rereduce;
};
第一个成员就是待处理的文件;第二个成员是函数指针,用来确定使用的比较函数;第三个成员是primary key用的reduce函数;第四个成员是back index使用的reduce函数。
TreeWriterSort函数使用在刚才初始化的树把数据排序。排序使用非递归的归并排序。TreeWriterWrite函数将排序后的数据做成树,最后返回树的根节点。
在以上过程中,排序和建树是重点,也是分析时间复杂度主要注意的两个方面。本文只分析排序。
一、排序
int merge_sort(FILE *unsorted_file, FILE *sorted_file,
int (*read)(FILE *, void *, void *),
int (*write)(FILE *, void *, void *),
int (*compare)(void *, void *, void *), void *pointer,
unsigned max_record_size, unsigned long block_size, unsigned long *pcount)
这个函数是TreeWriterSort调用的排序函数,从名称就可以看出来这不出意外是个归并排序。从参数名称则可以看到这个函数传入一个乱序的文件并返回一个顺序的文件。参数中的三个函数指针是排序时用来读写和比较的函数。这里贴出来TreeWriterSort中传给merge_sort的参数表。(writer->file, writer->file, read_id_record,write_id_record, compare_id_record, writer, ID_SORT_MAX_RECORD_SIZE,ID_SORT_CHUNK_SIZE,
NULL)。其中ID_SORT_MAX_RECORD_SIZE的值是4196,ID_SORT_CHUNK_SIZE的值是100*1024*1024。这个函数内部代码较长,所以下面分段进行分析。
struct tape {
FILE *fp;
unsigned long count;
};
struct tape source_tape[2];
char *record[2];
/* allocate memory */
if ((record[0] = malloc(max_record_size)) == NULL) {
return INSUFFICIENT_MEMORY;
}
if ((record[1] = malloc(max_record_size)) == NULL) {
free(record[0]);
return INSUFFICIENT_MEMORY;
}
/* create temporary files source_tape[0] and source_tape[1] */
source_tape[0].fp = tmpfile();
source_tape[0].count = 0L;
if (source_tape[0].fp == NULL) {
free(record[0]);
free(record[1]);
return FILE_CREATION_ERROR;
}
source_tape[1].fp = tmpfile();
source_tape[1].count = 0L;
if (source_tape[1].fp == NULL) {
fclose(source_tape[0].fp);
free(record[0]);
free(record[1]);
return FILE_CREATION_ERROR;
}
这是最开始准备空间的代码,注意source_tape[2]这个数组,这是这个函数中数据交互的核心数组。Record[2]这个数组则是暂存数据的指针数组,暂存数据的空间大小则为max_record_size,这里传入的参数是4096,也就是4KB。Tape结构体第一个成员是文件指针,也就是用来存放内容的文件;第二个成员是统计文件中数据项的个数。
/* read blocks, sort them in memory, and write the alternately to */
/* tapes 0 and 1 */
{
struct record_in_memory *first = NULL;
unsigned long block_count = 0;
unsigned destination = 0;
struct compare_info comp;
comp.compare = compare;
comp.pointer = pointer;
while (1) {
int record_size = (*read)(unsorted_file, record[0], pointer);
if (record_size > 0) {
struct record_in_memory *p = (struct record_in_memory *)
malloc(sizeof(struct record_in_memory) + record_size);
if (p == NULL) {
fclose(source_tape[0].fp);
fclose(source_tape[1].fp);
free(record[0]);
free(record[1]);
free_memory_blocks(first);
return INSUFFICIENT_MEMORY;
}
p->next = first;
memcpy(p->record, record[0], record_size);
first = p;
block_count++;
}
if (block_count == block_size || (record_size == 0 && block_count != 0)) {
first = sort_linked_list(first, 0, compare_records, &comp, NULL);
while (first != NULL) {
struct record_in_memory *next = first->next;
if ((*write)(source_tape[destination].fp, first->record,
pointer) == 0) {
fclose(source_tape[0].fp);
fclose(source_tape[1].fp);
free(record[0]);
free(record[1]);
free_memory_blocks(first);
return FILE_WRITE_ERROR;
}
source_tape[destination].count++;
free(first);
first = next;
}
destination ^= 1;
block_count = 0;
}
if (record_size == 0) {
break;
}
}
}
这部分代码就开始排序了。先看两个辅助用的结构体。
struct record_in_memory {
struct record_in_memory *next;
char record[1];
};
struct compare_info {
int (*compare)(void *, void *, void *);
void *pointer;
};
结构简单的两个结构体,知道了结构后面用到时候就好理解了。
这段代码一开始申请的变量要尤其注意,first变量是包含结果的链表头指针,block_count是统计数据量的变量,destination是决定使用source_tape[0]还是source_tape[1]的路标。接下来进入一个循环,该循环除非已经没有等待排序的数据或发生错误才会终止。循环内一开始读取待排序文件中的数据并以头插法存入first的链表中,读取持续至文件已经被彻底读完或者已读取的数据个数和规定的单次数据块大小已经相同。这里传入的数据块大小是100MB。接下来就是用sorted_linked_list函数对数据排序了,传入的参数是链表头指针和比较函数。Sorted_linked_list是个很有趣的函数,下面会仔细分析这个函数。
void *sort_linked_list(void *p, unsigned idx,int (*compare)(void *, void *, void *), void *pointer, unsigned long *pcount)
函数声明为了灵活性用了大量void类型。
unsigned base;
unsigned long block_size;
struct record {
struct record *next[1];
/* other members not directly accessed by this function */
};
struct tape {
struct record *first, *last;
unsigned long count;
} tape[4];
这是这个函数中需要的辅助结构体,tape[4]数组需要高度注意,数据就在这个数组中来回变动。
/* Distribute the records alternately to tape[0] and tape[1]. */
memset(&tape, 0, sizeof(struct tape) * 4);
base = 0;
while (p != NULL) {
struct record *next = ((struct record *)p)->next[idx];
((struct record *)p)->next[idx] = tape[base].first;
tape[base].first = ((struct record *)p);
tape[base].count++;
p = next;
base ^= 1;
}
这部分的作用就是把传入的链表中的数据,平均分散在tape[0]和tape[1]中,为归并排序做准备。 /* If the list is empty or contains only a single record, then */
/* tape[1].count == 0L and this part is vacuous. */
for (base = 0, block_size = 1L; tape[base + 1].count != 0L;
base ^= 2, block_size <<= 1) {
int dest;
struct tape *tape0, *tape1;
tape0 = tape + base;
tape1 = tape + base + 1;
dest = base ^ 2;
tape[dest].count = tape[dest + 1].count = 0;
for (; tape0->count != 0; dest ^= 1) {
unsigned long n0, n1;
struct tape *output_tape = tape + dest;
n0 = n1 = block_size;
while (1) {
struct record *chosen_record;
struct tape *chosen_tape;
if (n0 == 0 || tape0->count == 0) {
if (n1 == 0 || tape1->count == 0) {
break;
}
chosen_tape = tape1;
n1--;
} else if (n1 == 0 || tape1->count == 0) {
chosen_tape = tape0;
n0--;
} else if ((*compare)(tape0->first, tape1->first, pointer) > 0) {
chosen_tape = tape1;
n1--;
} else {
chosen_tape = tape0;
n0--;
}
chosen_tape->count--;
chosen_record = chosen_tape->first;
chosen_tape->first = chosen_record->next[idx];
if (output_tape->count == 0) {
output_tape->first = chosen_record;
} else {
output_tape->last->next[idx] = chosen_record;
}
output_tape->last = chosen_record;
output_tape->count++;
}
}
}
这个for循环内部就是执行排序的代码,这里首先要注意base和block_size这两个变量:base控制tape[4]数组中哪些是待排序数组,哪些是排序后数组;block_size则决定归并排序的深度。循环中变量tape0和tape1是代表待排序数据的数组,dest则代表目的数组的标号。一开始,设置base=0,block_size=1,也即源数组为tape[0]和tape[1]。 tape0 = tape + base;
tape1 = tape + base + 1;
之后目的数组的计数设置为0。
dest = base ^ 2;
tape[dest].count = tape[dest + 1].count = 0;
接下来进入另一个for循环,这个循环的功能是把待排序的数组中所有数据按照指定深度排序并存入目的数组中。
unsigned long n0, n1;
struct tape *output_tape = tape + dest;
n0 = n1 = block_size;
n0和n1控制着排序的深度,output_tape则代表着目的数组。接下来的while循环则是在指定深度和目的数组下排序数据,指定深度的数据排序完毕则循环中断。
struct record *chosen_record;
struct tape *chosen_tape;
if (n0 == 0 || tape0->count == 0) {
if (n1 == 0 || tape1->count == 0) {
break;
}
chosen_tape = tape1;
n1--;
} else if (n1 == 0 || tape1->count == 0) {
chosen_tape = tape0;
n0--;
} else if ((*compare)(tape0->first, tape1->first, pointer) > 0) {
chosen_tape = tape1;
n1--;
} else {
chosen_tape = tape0;
n0--;
}
这段代码就是从源数组中挑出当前位置较大者,若有一个数组已经全部输出则把另一个数组剩余数据直接输出,若两个数组都已经输出完毕则中断循环。首次运行block_size=1,也就是深度为1,源数组各输出一个数据则中断循环。
chosen_tape->count--;
chosen_record = chosen_tape->first;
chosen_tape->first = chosen_record->next[idx];
if (output_tape->count == 0) {
output_tape->first = chosen_record;
} else {
output_tape->last->next[idx] = chosen_record;
}
output_tape->last = chosen_record;
output_tape->count++;
被选中的数组的剩余数据量减一,然后将数据以尾插法插入目的数组,同时目标数组的统计值加一。跳出循环后dest^=1,此时目的数组变为tape[3],并对源数组剩余数据继续执行上述操作。当跳出for循环时,源数组的所有数据都已经按照深度排序并将结果均匀地分散存储在目的数组中。此时目的数组中的数据特征很明显,因为排序深度为1,也就是奇数项要大于偶数项,,相当于归并排序中最底层的排序。
此时第一次循环完毕,进入第二次循环之前首先判断tape[base + 1].count != 0L,这里也即是判断tape[1].count!=0,这个判断是用来判断是否已经排序完毕,也即排序深度是不是需要进一步加深来完成排序。base^=2是在调换源数组和目标数组,也即把上一次排序好的数组再次排序,block_size<<=1则是让深度加倍,相当于递归排序中完成了底层排序后回退一层,这里是从1变2。之后这样重复排序,直到深度大于等于总数组长度的一半,以致于所有的结果都输出到了单一数组之中(tape[0]或tape[2]),此时跳出外层循环(本质就是归并排序,注意分而治之的原理就比较容易理解)。 if (tape[base].count > 1L) {
tape[base].last->next[idx] = NULL;
}
if (pcount != NULL) {
*pcount = tape[base].count;
}
return tape[base].first;
在最后把结果数组的尾指针规范化并返回结果数组的首指针。
现在回到merge_sort函数,当sorted_linked_list函数执行完毕后,会将排序后的数据写入source_tape[destination]代表的文件之中。这里destination的值只会是0或者1。第一次进入函数时候值为0,若数据量超过了100MB则先把100MB的数据排序之后写入source_tape[0]代表的文件末尾,然后继续取数据,只不过这一次会把结果写入source_tape[1]的末尾,若数据量巨大又超过100MB则再回到source_tape[0],如此循环直至包含原始数据的文件被彻底读入并分别排序。
从上面的过程可以看出来根据数据量大小的不同,得到的排序结果可能需要进一步加工。在这里如果数据量不大于100MB,那么排序后的数据就是彻底排序的数据,可以直接写入文件了。如果数据量大于100MB,那么数据就需要再次进行总排序。
if (sorted_file == unsorted_file) {
rewind(unsorted_file);
}
rewind(source_tape[0].fp);
rewind(source_tape[1].fp);
/* delete the unsorted file here, if required (see instructions) */
/* handle case where memory sort is all that is required */
if (source_tape[1].count == 0L) { /* 数据可以直接写入文件 */
fclose(source_tape[1].fp);
source_tape[1] = source_tape[0];
source_tape[0].fp = sorted_file;
while (source_tape[1].count-- != 0L) {
(*read)(source_tape[1].fp, record[0], pointer);
if ((*write)(source_tape[0].fp, record[0], pointer) == 0) {
fclose(source_tape[1].fp);
free(record[0]);
free(record[1]);
return FILE_WRITE_ERROR;
}
}
}
else {
/* merge tapes, two by two, until every record is in source_tape[0] */
while (source_tape[1].count != 0L) {
/* 如果source_tape[1]中的数据已经全部处理就中断循环。 */
unsigned destination = 0;
struct tape destination_tape[2];
/*如果source_tape[0]中包含多个有序数组就建立一个临时文件供destination_tape[0]使用,否则只包含一个有序数组就直接调用最终文件sorted_file给其使用。*/
destination_tape[0].fp = source_tape[0].count <= block_size ?
sorted_file : tmpfile();
destination_tape[0].count = 0L;
if (destination_tape[0].fp == NULL) {
fclose(source_tape[0].fp);
fclose(source_tape[1].fp);
free(record[0]);
free(record[1]);
return FILE_CREATION_ERROR;
}
destination_tape[1].fp = tmpfile();
destination_tape[1].count = 0L;
if (destination_tape[1].fp == NULL) {
if (destination_tape[0].fp != sorted_file) {
fclose(destination_tape[0].fp);
}
fclose(source_tape[0].fp);
fclose(source_tape[1].fp);
free(record[0]);
free(record[1]);
return FILE_CREATION_ERROR;
}
/*再次进行排序,实现方式也是归并排序,只不过深度一开始就很大*/
(*read)(source_tape[0].fp, record[0], pointer);
(*read)(source_tape[1].fp, record[1], pointer);
while (source_tape[0].count != 0L) {
unsigned long count[2];
count[0] = source_tape[0].count;
/* 每次只对不超过block_size的数据量再排序*/
if (count[0] > block_size) {
count[0] = block_size;
}
count[1] = source_tape[1].count;
if (count[1] > block_size) {
count[1] = block_size;
}
while (count[0] + count[1] != 0) {
unsigned select = count[0] == 0 ? 1 : count[1] == 0 ? 0 :
compare(record[0], record[1], pointer) < 0 ? 0 : 1;
if ((*write)(destination_tape[destination].fp, record[select],
pointer) == 0) {
if (destination_tape[0].fp != sorted_file) {
fclose(destination_tape[0].fp);
}
fclose(destination_tape[1].fp);
fclose(source_tape[0].fp);
fclose(source_tape[1].fp);
free(record[0]);
free(record[1]);
return FILE_WRITE_ERROR;
}
if (source_tape[select].count > 1L) {
(*read)(source_tape[select].fp, record[select], pointer);
}
source_tape[select].count--;
count[select]--;
destination_tape[destination].count++;
}
destination ^= 1;
}
fclose(source_tape[0].fp);
fclose(source_tape[1].fp);
if (fflush(destination_tape[0].fp) == EOF ||
fflush(destination_tape[1].fp) == EOF) {
if (destination_tape[0].fp != sorted_file) {
fclose(destination_tape[0].fp);
}
fclose(destination_tape[1].fp);
free(record[0]);
free(record[1]);
return FILE_WRITE_ERROR;
}
rewind(destination_tape[0].fp);
rewind(destination_tape[1].fp);
memcpy(source_tape, destination_tape, sizeof(source_tape));
block_size <<= 1;
}
}
fclose(source_tape[1].fp);
if (pcount != NULL) {
*pcount = source_tape[0].count;
}
通过上述代码可以看到,在进行排序时没有直接把数据排序,而是以100MB数据量为临界点,小于插个数据量就直接一次排序完成,大于这个数据量就需要在初次排序之后再进一步排序。这里就出现一个问题:问什么要刻意将数据分片排序?事实上无非就是调用一个函数进行低深度排序之后再进行高深度排序,进行的操作也并没有减少,最后的结果也一样,那为什么不一开始就直接全部排序?这个问题我暂时没想明白,希望有人指点一下,等有了答案我再把结论更新上来。