前言
Leveldb作为一个 kv 数据库,使用的 lsm-tree,一句话解释 lsm-tree 就是将数据按照字符排序。首先将数据写入内存,然后将数据刷入磁盘,定时合并文件。在每个文件头部记录下当前最小最大的 key,然后使用 bloom 记录是否存在 key,查询的时候根据二分查找等方式进行数据查找。 数据是紧凑的写在内存或磁盘上的,所以每条数据都会记录当前值的长度和具体的值形如:key_lengthvalue_length,如 key 为 key_123,value 为 value_123,记录方式就是79。一个 32位整型的空间一般是 4 个字节,Leveldb就是觉得这个 4 个字节实在是太多了,如上文,当前的 key 是 7,其实只需要一个字节就可以表示,所以在这个上面 Leveldb使用了 Varint。具体在leveldb/util/coding.cc和leveldb/util/coding.h中实现,本文就是对其中的实现做一个简单的介绍和分析。
具体实现
Leveldb中上文提到的两个源码文件中,包含了两种,一种是Varint 和Fixed,包含了32位和64位,也就是32位整型和64位整型 的值。核心都是使用char数组来表示32位整型和64位整型。
编码具体实现
下面是编码方法列表
char* EncodeVarint32(char* dst, uint32_t value);
char* EncodeVarint64(char* dst, uint64_t value);
// 在coding.h 文件中直接实现的内联函数
inline void EncodeFixed32(char* dst, uint32_t value);
inline void EncodeFixed64(char* dst, uint64_t value);
为了将32位整型或者64位整型 使用char 数据记录,肯定需要对int的4位字节分别存储,也就是每次移动8位,为了标识当前字节处于整个字节的位置,还需要预留一个标识符,所以每一个char 最多有7位标识一个int,2的7次方,也就是一个char 最多标识128的数据。也就是说如果超过了2的28次方,可能需要额外的字节来标识。也就是说int值最多需要5个字节来表示。对应的64位整型类型则需要使用64/7 +1 也就是10个字节来标识。
32位整型
下面的代码核心其实就是分为5个字节如何存储,每一个if 就是一个多出一个字节。
*(ptr++) = v;的写法结果:
- 从
ptr指向的地址获取一个指向的值。- 将变量
v的值赋给该地址处。- 将
ptr的值增加 1,使其指向下一个地址。
代码如下:
char* EncodeVarint32(char* dst, uint32_t v) {
// Operate on characters as unsigneds
uint8_t* ptr = reinterpret_cast<uint8_t*>(dst);// 将当前指针变成uint8_t类型的指针
static const int B = 128; // 2的 七次方,确1000 0000
if (v < (1 << 7)) {
*(ptr++) = v; // 如果当前的值小于2^7,则直接将值写入指针,最大标识0111 1111 即127
} else if (v < (1 << 14)) {
*(ptr++) = v | B; // 如果值大于2^7,小于2^14,则首先小后七位写入第一个字节,也就是说是小端存储,此时该字节首位是1,因为B 1000 0000 使用或的关系只是将后七位和原值相等,首位赋值1
*(ptr++) = v >> 7; // 将剩下的值写入,此时首位为0
} else if (v < (1 << 21)) { // 大于2^14小于2^21,和上文类似
*(ptr++) = v | B;
*(ptr++) = (v >> 7) | B;
*(ptr++) = v >> 14;
} else if (v < (1 << 28)) {
*(ptr++) = v | B;
*(ptr++) = (v >> 7) | B;
*(ptr++) = (v >> 14) | B;
*(ptr++) = v >> 21;
} else {
*(ptr++) = v | B;
*(ptr++) = (v >> 7) | B;
*(ptr++) = (v >> 14) | B;
*(ptr++) = (v >> 21) | B;
*(ptr++) = v >> 28;
}
return reinterpret_cast<char*>(ptr); // 将值转换会原来的位置
}
举例说明下面的各个字节标识:
| 原始值 | 32位二进制 | 转换后 |
|---|---|---|
| 1 | 00000000 00000000 00000000 00000001 | 00000001 |
| 129 | 00000000 00000000 00000000 10000001 | 10000001 00000001 |
| 65537 | 00000000 00000001 00000000 00000001 | 10000001 10000000 00000100 |
上面分别为1,2^7+1 ,2^16+1,也就是分别需要1字节,2字节和3字节的数据存储。所以varint32的值有以下特征:
- 小端存储
- 如果当前char的最高位是1 ,则说明当前的值没有结束,一直到最高位为0的char
64位整形
64 位和32位基本上没有区别:
char* EncodeVarint64(char* dst, uint64_t v) {
static const int B = 128;
uint8_t* ptr = reinterpret_cast<uint8_t*>(dst);
while (v >= B) {
*(ptr++) = v | B;
v >>= 7;
}
*(ptr++) = static_cast<uint8_t>(v);
return reinterpret_cast<char*>(ptr);
}
实现上只是使用循环的方式,其他的都是一样的。这里就不赘述了。
解码具体实现
相对编码,解码里面提供了较为多方法,包含了和类外一个LevelDB比较重要的对象Slice的交互,这里先不看这个Slice的交互,只看解码的部分。
下面是我觉得两个比较核心的方法:
// Pointer-based variants of GetVarint... These either store a value
// in *v and return a pointer just past the parsed value, or return
// nullptr on error. These routines only look at bytes in the range
// [p..limit-1]
// 传入的待解码char开始指针p,待解码char结束指针,最后值写入的指针v。看方法参数可以看到,一般情况下都是在某个char* 类型的数据上进行顺序读取来获取数据,也就是p 和limit 应该是属于一个char* 的不同位置的指针,他的返回值是指向当前值结尾指针的下一个指针。如果出错,则返回nullptr
const char* GetVarint32Ptr(const char* p, const char* limit, uint32_t* v);
const char* GetVarint64Ptr(const char* p, const char* limit, uint64_t* v);
在实现上分为两种情况:
-
当前的值小于128,即传入的p的第一个字节为0,也就是上文编码中说的,如果首位为0 ,则说明后续都没有数据了。
inline const char* GetVarint32Ptr(const char* p, const char* limit, uint32_t* value) { if (p < limit) { //确保p是在limit 前面的 uint32_t result = *(reinterpret_cast<const uint8_t*>(p)); if ((result & 128) == 0) { // p的第一个字节为0,说明当前值7位就可以标识,字节返回。 *value = result; return p + 1; // 返回当前解码后值的后一个字节 } } return GetVarint32PtrFallback(p, limit, value); }
如果超过7位,则进入到GetVarint32PtrFallback方法中:
const char* GetVarint32PtrFallback(const char* p, const char* limit,
uint32_t* value) {
uint32_t result = 0;
// 最多执行5次
for (uint32_t shift = 0; shift <= 28 && p < limit; shift += 7) {
uint32_t byte = *(reinterpret_cast<const uint8_t*>(p));
p++; // 移动p的指针
if (byte & 128) { // 如果当前char的首位是1,说明后续还有值
// More bytes are present
// 取当前值的后7位,并且移动shift
result |= ((byte & 127) << shift);
} else {
// 当前已经到尾部字节,将值写入即可
result |= (byte << shift);
*value = result;
return reinterpret_cast<const char*>(p);
}
}
// 出现如limit>p这种情况,字节返回nullptr
return nullptr;
}
64位的解码基本上和32位一样,只是移动次数变成了10次
const char* GetVarint64Ptr(const char* p, const char* limit, uint64_t* value) {
uint64_t result = 0;
for (uint32_t shift = 0; shift <= 63 && p < limit; shift += 7) {
uint64_t byte = *(reinterpret_cast<const uint8_t*>(p));
p++;
if (byte & 128) {
// More bytes are present
result |= ((byte & 127) << shift);
} else {
result |= (byte << shift);
*value = result;
return reinterpret_cast<const char*>(p);
}
}
return nullptr;
}
总结
leveldb针对32位和64位的整形进行了优化,能够节约空间。个人觉得这么做的主要目的是因为我们的key或者value的值长度一般不会很大,很少人会使用2^32 个字节来作为key存储。所以这么做积少成多,确实能够节约不少的空间。使用0,1 判断是否到数据尾部,确实很秀。