leveldb 中常用的数据结构
1. Slice
Slice 是 leveldb 中使用最为频繁的数据结构,不管是 Write Batch,还是构建 SSTable,都需要 Slice 的重度参与。这里的 Slice 是一种封装的字节数组类型,类比于 std::string。
C++ 中的 std::string
只提供了简单的 push_back
、pop_back
等方法,诸如 starts_with
、remove_prefix
等方法都没有提供。因此,leveldb 使用 char *
自行封装了 string 对象,以满足功能需要。
Slice 的特性:
- 无生命周期:可以理解为寄存器变量,类似于 int,因为 data_ptr 所指向的字节数组的申请/释放是在别处完成的;
- 轻量:Slice 的拷贝开销仅存在于 const char * 和 size_t,相当于两个 uint64_t 的拷贝开销。是 string_view 的简单实现;
Slice 中的成员变量只有两个,一个是 char
类型的字符指针,另一个则是字符串的长度。此外,Slice 本身并不负责内存分配,只是简单地接收外部传入的指针对象。
class LEVELDB_EXPORT Slice {
public:
/* 从 std::string 中构建 Slice 对象,s.data() 返回 string 首地址指针 */
Slice(const std::string& s) : data_(s.data()), size_(s.size()) {}
/* 主要用于从字面量中构建 Slice,例如 Slice("leveldb") */
Slice(const char* s) : data_(s), size_(strlen(s)) {}
/* 移除 Slice 的前 n 个字符,只是进行简单的指针运算 */
void remove_prefix(size_t n) {
data_ += n;
size_ -= n;
}
/* 将 Slice 转化为 string 对象 */
std::string ToString() const { return std::string(data_, size_); }
/* 判断 Slice 是否以 Slice.data_ 为前缀 */
bool starts_with(const Slice& x) const {
return ((size_ >= x.size_) && (memcmp(data_, x.data_, x.size_) == 0));
}
private:
const char* data_; /* 字符指针,保存字符串起始地址 */
size_t size_; /* 字符串长度,不包括 '\0' 结尾标志 */
};
Slice 的实现并不复杂,甚至没有提供内存分配机制,只是简单地进行了一个封装,但却是 leveldb 中最为重要的数据结构。
2. Status
在 Web 开发中,由于 HTTP 状态码数量有限,不能够完全地表达出调用 API 的结果,因此通常都会采用自定义 error_code
的方式实现,比如微信公众号的全局错误返回码: 全局返回码说明,一个统一的返回状态能够有效地降低后期运维成本。leveldb 并没有使用 Linux Kernel 的错误返回,而是使用 Status
类来制定统一的返回状态。
class LEVELDB_EXPORT Status {
private:
enum Code {
kOk = 0,
kNotFound = 1,
kCorruption = 2,
kNotSupported = 3,
kInvalidArgument = 4,
kIOError = 5
};
/* state_ 是分配在堆上的,使用 new char[] 的方式进行分配 */
const char* state_;
};
leveldb 一共定义了 6 种状态,内部使用枚举类实现,每一个返回状态都会有对应的 2 个方法,一个是构造返回状态,另一个则是判断状态。以 kNotFound
为例:
/* 通过 msg 构造 NotFound Status,其中 msg2 主要用于存储系统调用时的错误码,也就是 errno */
static Status NotFound(const Slice& msg, const Slice& msg2 = Slice()) {
return Status(kNotFound, msg, msg2);
}
/* 判断当前 status 对象的状态是否为 NotFound */
bool IsNotFound() const { return code() == kNotFound; }
可以看到,方法名和状态名称之间是强耦合的,也就是说我们无法在不改变 Status
定义的前提下对其进行拓展,算是一个小小的缺点。
简单地看一下 Status 的构造函数实现,通过状态码和错误信息构造 Status 属于私有方法,只能由 NotFound()
、Corruption()
、InvalidArgument()
等方法调用,这又限制了我们自行拓展 Status 的能力。
/* msg2 通常用于保存 errno */
Status::Status(Code code, const Slice& msg, const Slice& msg2) {
/* 获取 msg 以及 msg2 的长度 */
const uint32_t len1 = static_cast<uint32_t>(msg.size());
const uint32_t len2 = static_cast<uint32_t>(msg2.size());
/* 如果 len2 不为 0 的话,需要对 msg 和 msg2 做分割,leveldb
* 使用 ": " 这两个字符进行分隔,属于硬编码 */
const uint32_t size = len1 + (len2 ? (2 + len2) : 0);
/* 5 就是 4 字节的 message 长度 + 1 字节的状态码 */
char* result = new char[size + 5];
/* 将 message 长度写入 */
std::memcpy(result, &size, sizeof(size));
/* 将状态码写入 */
result[4] = static_cast<char>(code);
std::memcpy(result + 5, msg.data(), len1);
/* 使用 ": " 作为 msg 和 msg2 的分割线 */
if (len2) {
result[5 + len1] = ':';
result[6 + len1] = ' ';
std::memcpy(result + 7 + len1, msg2.data(), len2);
}
state_ = result;
}
Status 的结构如下图所示,在内部只需要使用 state_[4]
即可获得 Status 的具体状态:
3. Skip List
Memory Table 由 Skip List 实现,由于 leveldb 在对 Key 进行修改和删除操作时,采用的是追加方式,因此 Skip List 只需要实现插入和查找两个方法即可:
template <typename Key, class Comparator>
class SkipList {
private:
struct Node;
public:
explicit SkipList(Comparator cmp, Arena* arena);
void Insert(const Key& key);
bool Contains(const Key& key) const;
private:
enum { kMaxHeight = 12 }; /* 最大层高 */
Arena* const arena_; /* 内存分配器 */
Node* const head_; /* 虚拟头节点 */
};
Skip List 的核心结构为 Node
,其内部采用原子变量来进行指针的相关操作,因此,leveldb 中的 Skip List 是线程安全的,并且使用的是无锁实现:
template <typename Key, class Comparator>
struct SkipList<Key, Comparator>::Node {
explicit Node(const Key& k) : key(k) {}
Key const key;
/* 原子性地获取当前 Node 的第 n 层的下一个节点指针 */
Node* Next(int n) {
/* std::memory_order_acquire 表示在 load() 之后的所有读写操作,
* 不允许被重排到这个 load() 的前面 */
return next_[n].load(std::memory_order_acquire);
}
/* 原子性地设置当前 Node 的第 n 层的下一个节点指针 */
void SetNext(int n, Node* x) {
/* std::memory_order_release 表示在 store() 之前的所有读写操作,
* 不允许被重排到这个 store() 的后面 */
next_[n].store(x, std::memory_order_release);
}
private:
std::atomic<Node*> next_[1];
};
跳表实现细节
leveldb 的 SkipList 的大致结构如下。Redis 的跳跃表的细微差别在于,leveldb 并没有使用 prev
指针。因为 leveldb 只会进行单方向的查找,而没有 Redis 那样双向查找的需求。
另外,参数 kBranching
选取为1/4,这意味着上层的节点数量为下层节点数量的 1/4。
为什么选用跳表实现有序集合
我们知道,实现无序集合的方式主要有:哈希表,实现有序集合的方式主要有:B+树、红黑树、跳表。
Q1: 为什么不用红黑树实现?
首先一点,红黑树的代码比较复杂。
其次,针对区间查找
操作,红黑树的效率不如跳表,后者可以以 O(logn)
的时间复杂度定位区间起点,再走链表定位区间终点。
Q2: 为什么不用 B+树实现?
跳表和 B+树的本质都是在单向链表之上建立索引,不过跳表建索引的方式较为随意,不如 B+树那样能显著压缩索引层的高度(进而缩短索引路径长度)。
B+树的索引侧重于读时的优化,而跳表建立的索引更侧重于写多读少的场景,这与 leveldb 的需求是一致的,即大规模写入。故选用跳表。
4. LRUCache
为了优化查询效率,leveldb 自行实现了一个标准的 LRU Cache,即哈希表+双向链表,并且 leveldb 选择了自行实现哈希表,并没有使用 std::unordered_map
,同时使用了 port::Mutex
来保证 LRU Cache 的并发安全性。port::Mutex
其实就是对 std::mutex
的简单封装。