1. 背景
编码指的是内存里的整数和字符串放到磁盘上的方式,其主要目的有两个
- 对不定长整数以及字符串能够在读取的时候感知到已经读取完了整个值
- 最大程度的节省在磁盘上占用的空间
2. 设计
2.1 整数
整数的种类上分为定长整数和不定长整数,而定长整数又分为32位整数和64位整数。
整数在存储方式上可以分为小端(Little Endian)和大端(Big Endian)两种存储方式,小端的低位字节存储在低地址端,高位字节存储在高地址端,LevelDB在这里采用的小端存储方式。
2.1.1 定长整数
对定长整数的存储比较简单,例如存储一个32位的定长整数,就是18位的字节编码放在第一个字节位置,916位的字节编码放到第二个字节位置,以此类推
2.2.2 不定长整数
LevelDB应该是不支持存储任意长度的整数的,在这种背景下理论上所有的整数都可以用定长整数的方式来存储。但是还记得刚才说的吗?要节省在磁盘上占用的空间,不定长整数经常会是100以内的小整数,如果采用这种方式进行存储,每个值就会至少占用4个字节的空间,空间利用率很低。
所以LevelDB中对于采用了不定长整数的编码方式来优化这种小整数带来的磁盘空间浪费,不定长整数的存储原理是将原本一个字节存储8个数据信息的结构转换为使用7个字节来存储信息以及1个标志位来标识当前字节是否已经是这个整数的最后一个字节。读取的时候首先读取第一个字节的低七位作为数据的17位,如果表示为位1,则继续拿下一个字节的低七位作为数据的814位,以此类推,下图是以32位整数作为不定长整数存储的例子。
这么做会导致每个整数最多有1个字节的存储量上升,相比于直接使用定长整数的存储方式,这么做在大多数场景下在空间利用率上是更有优势的。
2.2 字符串
字符串使用了长度前缀编码的方式进行存储,也就是开头使用了32位的变长整数来编码字符串长度,编码长度后面跟着字符串的实际值,这么做的好处是不需要指定特定的结尾符,字符串里可以出现任何字符。
一下图为例,开始使用了1个字节表示字符串的长度为5,后续5个字节分别跟了字符串的内容,总共使用6个字节。
3. 源码解析
3.1 Slice
LevelDB中访问字符串大量的使用了一种叫Slice的对象,对象中包含了指向字符串的指针和字符串的长度。相比于C++里的std::string,Slice不管理内存的分配,无论是初始化还是析构,都需要使用者自身去申请或删除字符串的内存,这就使得Slice的拷贝实际是一种对指针的浅拷贝,相比于string非常轻量,同时多个Slice也可以指向同一个字符串。
class LEVELDB_EXPORT Slice {
public:
Slice() : data_(""), size_(0) {}
Slice(const char* d, size_t n) : data_(d), size_(n) {}
Slice(const std::string& s) : data_(s.data()), size_(s.size()) {}
Slice(const char* s) : data_(s), size_(strlen(s)) {}
Slice(const Slice&) = default; //默认成员变量,也就是指针,所以是浅拷贝
Slice& operator=(const Slice&) = default;
const char* data() const { return data_; }
size_t size() const { return size_; }
bool empty() const { return size_ == 0; }
char operator[](size_t n) const {
assert(n < size());
return data_[n];
}
private:
const char* data_;
size_t size_;
};
3.2 EncodeVarint64
Encode开头的函数是对value进行不定长整数的编码,并将值给到以dst指向的地址开头,返回值指向的地址结尾(不含)的这块内存中
char* EncodeVarint32(char* dst, uint32_t value);
char* EncodeVarint64(char* dst, uint64_t value);
这边以EncodeVarint64为例,当数值大于128(7个bit存储的量)的时候,每次将数值的低7位给到ptr并将标识符置为1,然后指针后移一位,当数值小于128的时候,将数值低7位给到ptr之后后移一位直接返回。
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 | B表示给最高位置1,最高位是标识位,置为1表示这并不是最后一个字节
v >>= 7;
}
*(ptr++) = static_cast<uint8_t>(v); //最后一个字节不用给标识位置1了,所以单独拿出来最后处理
return reinterpret_cast<char*>(ptr);
}
3.3 PutVarint64
这些Put开头的函数都是将数字以追加的形式放入到dst后面,只不过区别是放的是定长整数还是非定长整数还是字符串的长度,这边以相对最复杂的PutVarint64为样例看一下具体代码。
void PutFixed32(std::string* dst, uint32_t value);
void PutFixed64(std::string* dst, uint64_t value);
void PutVarint32(std::string* dst, uint32_t value);
void PutVarint64(std::string* dst, uint64_t value);
void PutLengthPrefixedSlice(std::string* dst, const Slice& value);
PutVarint64首先声明了一块10个字节的内存(不定长的64位整数最多 \(\lceil 64 / 7 \rceil = 10\)个字节),然后将其通过EncodeVarint64得到不定长整数的编码后追加到给定的字符串后面。
void PutVarint64(std::string* dst, uint64_t v) {
char buf[10];
char* ptr = EncodeVarint64(buf, v);
dst->append(buf, ptr - buf);
}
为什么不直接拿string的末尾字符去调用EncodeVarint64?
这个与string的内存管理方式有关,string实际是分配了一个比字符串内存大的内存块,如果字符串不断增大超过了内存块大小,程序就会重新分配一个两倍为原来的新内存块,所以说随着字符串的增大,string对象相邻的内存可能会被占用,只能以调用append的方式往后追加字符串。
3.4 GetVarint64
GetVarint64中的input是包含了长度前缀编码的一个
bool GetVarint32(Slice* input, uint32_t* value);
bool GetVarint64(Slice* input, uint64_t* value);
bool GetLengthPrefixedSlice(Slice* input, Slice* result);
const char* GetVarint32Ptr(const char* p, const char* limit, uint32_t* v);
const char* GetVarint64Ptr(const char* p, const char* limit, uint64_t* v);
const char* GetVarint32PtrFallback(const char* p, const char* limit, uint32_t* value);
const char* GetVarint64Ptr(const char* p, const char* limit, uint64_t* value);
传参p开头的内容是一个包含了前缀长度编码的字符串,GetVarint64Ptr的目的是将字符串长度解析出来并赋值到value里面,并返回除去前缀编码后的字符串开头位置。
其中limit是限制寻找编码长度的位置,防止在不定长整数异常的情况下一路找到未定义的内存中去,一般为(p + 10),因为64位不定长整数的最大字节数为10
const char* GetVarint64Ptr(const char* p, const char* limit, uint64_t* value) {
uint64_t result = 0;
//从前往后枚举p开头的字节,每次将字节的低7位赋值到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);
}
}
//如果最终都没遇到标志为为0的字节,表示没有获取到value
return nullptr;
}
GetVarint64目的是将不定长编码的字符串解析为正常内容的字符串(赋值到input)以及其长度(赋值到value)
bool GetVarint64(Slice* input, uint64_t* value) {
const char* p = input->data();
const char* limit = p + input->size(); //设置limit,防止访问到字符串以外的内存
const char* q = GetVarint64Ptr(p, limit, value);
if (q == nullptr) {
return false; //没有成功解析
} else {
*input = Slice(q, limit - q); //Slice的赋值是一个浅拷贝,也就是说这里不发生字符串的拷贝
return true;
}
}
3.5 不同位整数在代码上的区别
在coding.cc这部分代码里面,针对32位不定长整数和64位不定长整数做了很多编码上的“区别对待”
- 例如EncodeVarint32就选择了硬编码的方式处理数据范围,相比之下EncodeVarint64使用循环显然也能满足需求。
- GetVarint32Ptr也对小于128的数字做了单独处理,不仅单开了函数,甚至加了inline,超过128的值会调用GetVarint32PtrFallback(在逻辑上这个函数已经包含了GetVarint32Ptr)
这部分的目的我暂时没有把握,所以特地开了这以part,为了之后返回来解答,当前的猜测是对于32位的不定长整数而言,大多数场景下都是小数字(v < (1 << 7), 这么做可以在实际处理中对这部分数据避免循环,从而提升执行的效率
char* EncodeVarint32(char* dst, uint32_t v) {
// Operate on characters as unsigneds
uint8_t* ptr = reinterpret_cast<uint8_t*>(dst);
static const int B = 128;
if (v < (1 << 7)) {
*(ptr++) = v;
} else if (v < (1 << 14)) {
*(ptr++) = v | B;
*(ptr++) = v >> 7;
} else if (v < (1 << 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);
}
inline const char* GetVarint32Ptr(const char* p, const char* limit,
uint32_t* value) {
if (p < limit) {
uint32_t result = *(reinterpret_cast<const uint8_t*>(p));
if ((result & 128) == 0) {
*value = result;
return p + 1;
}
}
return GetVarint32PtrFallback(p, limit, value);
}
标签:编码,LevelDB,const,字节,Slice,整数,char,源码,value
From: https://www.cnblogs.com/Hugh-Locke/p/16928854.html