LevelDB 是 C++ 开发的优秀的 LSM Tree 的存储组件,整体代码量不大,但是设计精巧,值得学习。在阅读源码过程中,整理了系列文章,逐步拆解 LevelDB 的实现细节。不过在阅读代码前,最好先准备好整个开发环境。

本文会从最基本的拉取代码开始,记录自己准备整个环境的过程,包括配置 VSCode IDE 和 clangd 插件使用,以及如何配置编译选项等。然后会通过简单的读写代码 demo,来简单使用下 LevelDB,对这个库有个感性的认识。另外,还会介绍如何运行测试用例,LevelDB 的测试用例写的很好,在代码阅读过程中,可以借助用例更好的理解代码。

源码编译

首先是拉代码,这里使用的是 git clone --recurse-submodules,可以一次性拉取所有的子模块。虽然 leveldb 的实现不依赖第三方库,不过压测用到了 benchmark,功能测试用到了 googletest,这两个库都是作为子模块引入的。

如果拉取代码遇到网络问题,比如下面这种,需要先绕过防火墙才行,可以参考安全、快速、便宜访问 ChatGPT,最新最全实践教程! 这篇文章中的方法。

1
2
3
4
Cloning into '/root/leveldb/third_party/googletest'...
fatal: unable to access 'https://github.com/google/googletest.git/': GnuTLS recv error (-110): The TLS connection was non-properly terminated.
fatal: clone of 'https://github.com/google/googletest.git' into submodule path '/root/leveldb/third_party/googletest' failed
Failed to clone 'third_party/googletest'. Retry scheduled

接下来就是编译整个源码,leveldb 用的 cmake 来构建,为了方便后面阅读代码,这里编译的时候加上了 -DCMAKE_EXPORT_COMPILE_COMMANDS=1,这样会生成一个 compile_commands.json 文件,这个文件是 clangd 等工具的配置文件,可以帮助 VSCode 等 IDE 更好的理解代码。有了这个文件,代码跳转、自动补全等功能就会更好用。另外,为了方便用 GDB 进行调试,这里加上了 -DCMAKE_BUILD_TYPE=Debug 生成带调试信息的库。

完整的命令可以参考下面:

1
2
3
4
5
git clone --recurse-submodules  git@github.com:google/leveldb.git

cd leveldb
mkdir -p build && cd build
cmake -DCMAKE_BUILD_TYPE=Debug -DCMAKE_EXPORT_COMPILE_COMMANDS=1 -DCMAKE_INSTALL_PREFIX=$(pwd) .. && cmake --build . --target install

其中 CMAKE_INSTALL_PREFIX 选项用来指定安装目录,这里指定为当前目录(build 目录),这样编译完之后,生成的库文件和头文件都会放在 build 目录下,方便后续使用。

这里 CMake 构建有不少选项,比如 BUILD_SHARED_LIBS 用来控制生成的库是静态链接库(.a 文件)还是动态链接库(.so 文件)。如果在 CMakeLists.txt 或通过命令行传递给 CMake 的参数中没有明确设置 BUILD_SHARED_LIBS,CMake 的默认行为通常是不启用构建共享库。命令行可以用 cmake -DBUILD_SHARED_LIBS=ON .. 来启用构建共享库。

IDE 配置

个人平时用 vscode 比较多,vscode 作为代码 IDE,可以说是十分好用。对 C++ 项目来说,虽然微软提供了官方的 C++ 插件,方便代码跳转等,但从个人使用体验来说,并不好用。这里强烈推荐使用 clangd 来阅读 C++ 代码,只需要在服务器安装 Clangd,然后在 vscode 安装 clangd 插件,再配合前面 Cmake 生成的编译数据库文件 compile_commands.json 即可

Clangd 是一个基于 LLVM 项目的语言服务器,主要支持 C 和 C++ 的代码分析。它可以提供代码补全、诊断(即错误和警告)、代码跳转和代码格式化等功能。和微软自带的 C++ 插件比,clangd 响应速度十分快,并且借助 clang 能实现更精准的跳转和告警等。还支持用 clang-tidy 对项目代码进行静态分析,发现潜在错误。

比如在下面的代码中,clang-tidy 发现一个可疑问题:Suspicious usage of ‘sizeof(A*)’,还给出了 clang-tidy 的检查规则项 bugprone-sizeof-expression,这个规则是用来检查 sizeof 表达式的使用是否正确。

clangd 插件用 clang-tidy 找到的可疑地方

这里 new_list 本身是一个指向指针的指针,new_list[0] 实际上就是一个指针,sizeof(new_list[0]) 是获取指针的大小,而不是指针所指向的元素的大小。不过这里设计本意就是如此,就是要给新的 bucket 设置初始值 nullptr。其实这个规则想防止的是下面这种错误:

A common mistake is to compute the size of a pointer instead of its pointee. These cases may occur because of explicit cast or implicit conversion.

比如下面这类代码:

1
2
3
4
5
int A[10];
memset(A, 0, sizeof(A + 0));

struct Point point;
memset(point, 0, sizeof(&point));

整体看,LevelDB 的代码质量很高,极少有 clang-tidy 提示。和业务代码的真是云泥之别,所以很值得学习。

LevelDB 读改写

LevelDB 并不是一个类似 mysql 这样的数据库,也不支持 SQL 查询等功能,它只是一个快速的 key-value 存储库。LevelDB 没有自带的客户端和服务器代码,如果需要提供存储功能,需要自己实现相应逻辑。此外,只支持单进程访问指定数据库,不支持多进程访问。

业界一般把 LevelDB 作为存储组件底层依赖的一个库来使用,比如微信的核心存储 paxosstore,就会用 LevelDB 来存储数据。LevelDB 的使用入门比较简单,只需要引入头文件,然后调用相应的接口即可。下面代码实现了一个简单的命令行接口,使用 LevelDB 库来读写 key。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
// simple_client.cpp
#include <iostream>
#include <string>

#include "leveldb/db.h"

int main() {
leveldb::DB* db;
leveldb::Options options;
options.create_if_missing = true;
leveldb::Status status = leveldb::DB::Open(options, "./db", &db);

if (!status.ok()) {
std::cerr << "Unable to open/create test database './db'" << std::endl;
std::cerr << status.ToString() << std::endl;
return -1;
}

std::string key;
std::string value;
std::string cmd;

while (true) {
std::cout << "leveldb> ";
std::cin >> cmd;

if (cmd == "set") {
std::cin >> key >> value;
status = db->Put(leveldb::WriteOptions(), key, value);
if (status.ok()) {
std::cout << "OK" << std::endl;
} else {
std::cout << "Error setting value: " << status.ToString() << std::endl;
}
} else if (cmd == "get") {
std::cin >> key;
status = db->Get(leveldb::ReadOptions(), key, &value);
if (status.ok()) {
std::cout << value << std::endl;
} else {
std::cout << "Not found" << std::endl;
}
} else if (cmd == "del") {
std::cin >> key;
status = db->Delete(leveldb::WriteOptions(), key);
if (status.ok()) {
std::cout << "OK" << std::endl;
} else {
std::cout << "Error deleting key: " << status.ToString() << std::endl;
}
} else if (cmd == "exit") {
break;
} else {
std::cout << "Unknown command. Supported commands are: set, get, del, exit" << std::endl;
}
}

delete db;
return 0;
}

这里用 Cmake 来构建,可以参考下面的 CMakeLists.txt 文件,当然下面的 include 和 lib 库的目录要根据前面编译好的目录来更改。

1
2
3
4
5
6
7
8
9
cmake_minimum_required(VERSION 3.10)
project(SimpleLevelDBExamples VERSION 1.0)
set(CMAKE_CXX_STANDARD 11)
set(CMAKE_CXX_STANDARD_REQUIRED True)
# 设置构建类型为 Debug 以包含调试信息
set(CMAKE_BUILD_TYPE Debug)
add_executable(SimpleClient simple_client.cpp)
include_directories(../build/include)
target_link_libraries(SimpleClient ${CMAKE_SOURCE_DIR}/../build/libleveldb.a pthread)

接着就可以用 cmake --build . 来编译二进制文件了。当然不习惯 cmake,直接用 gcc 也是可以的,只是需要手动指定头文件和库文件的路径。然后执行如下图,可以在类似 redis 的命令行 client 中操作 LevelDB。

LevelDB 简单读写命令行接口

可以在当前目录的 db 文件夹,看到 LevelDB 的数据存储文件,如下:

1
2
$ ls db 
000005.ldb 000018.ldb 000020.ldb 000031.ldb 000036.log CURRENT LOCK LOG LOG.old MANIFEST-000035

后面会详细介绍 LevelDB 的数据存储方式,也会展开讲这些文件的作用,这里先不展开。

跑好测试用例

到现在为止,我们已经编译 LevelDB 库,并且用 LevelDB 写了一个简单的读写命令行接口。接下来看看 LevelDB 的测试用例。LevelDB 的核心代码都有配套的测试用例,比如 LRU cache 中的 cache_test.cc,db实现中的 db_test.cc,table 中的 table_test.cc 等等。用前面编译命令生成库的同时,会生成测试用例的可执行文件 build/leveldb_tests

动态库依赖

如果直接运行 leveldb_tests 可能会提示缺少 libtcmalloc 动态库,这是 Google Perftools 的一个内存分配器,LevelDB 用到了这个库,需要在系统上安装。

1
./build/leveldb_tests: error while loading shared libraries: libtcmalloc.so.4: cannot open shared object file: No such file or directory

安装命令也很简单,比如在 debian 系统上,可以使用下面的命令:

1
2
sudo apt-get update
sudo apt-get install libgoogle-perftools-dev

安装完之后,可以用 ldd 查看是否能找到,正常如下就可以运行二进制了。

1
2
3
4
5
6
7
8
ldd ./build/leveldb_tests
linux-vdso.so.1 (0x00007ffc0d1fc000)
libtcmalloc.so.4 => /usr/local/lib/libtcmalloc.so.4 (0x00007f5277e91000)
libstdc++.so.6 => /lib/x86_64-linux-gnu/libstdc++.so.6 (0x00007f5277c77000)
libm.so.6 => /lib/x86_64-linux-gnu/libm.so.6 (0x00007f5277b98000)
libgcc_s.so.1 => /lib/x86_64-linux-gnu/libgcc_s.so.1 (0x00007f5277b78000)
libc.so.6 => /lib/x86_64-linux-gnu/libc.so.6 (0x00007f5277997000)
/lib64/ld-linux-x86-64.so.2 (0x00007f52782ed000)

这里在没有安装库之前,提示 libtcmalloc.so.4 => not found,安装动态库之后就自动链接到了正确的路径。怎么做到的呢?这是因为二进制文件包含了对动态库的引用,特别是库的名字和所需的符号(functions 或 data)。动态链接器(在 Linux 中通常是 ld-linux.so)负责处理这些引用。它会确定二进制文件需要哪些库,然后按照指定的路径和方法加载用到的库。

我们安装 tcmalloc 库之后,动态库文件 libtcmalloc.so.4 被复制到系统的库目录 /usr/local/lib 中。然后安装程序会执行 ldconfig 更新 ld.so.cache,这个缓存包含库的路径信息,用来加快库的查找速度。这样后面再次运行二进制时,动态链接器查看缓存,找到新安装的库,并解析所有相关的符号引用,从而完成链接。

修改、运行

这些功能测试用例都是用 gtest 框架编写的,我们可以通过 --gtest_list_tests 参数查看所有的测试用例。如下图所示:

LevelDB 目前所有的测试用例

如果直接运行 leveldb_tests,会执行所有的测试用例,不过我们可以通过 --gtest_filter 参数来指定只运行某个测试用例,比如 --gtest_filter='CacheTest.*' 只运行 LRU cache 相关的测试用例。结果如下:

只运行某个测试用例

测试用例可以帮助更好的理解代码逻辑。在阅读代码的过程中,有时候想验证一些逻辑,因此可以改动一下测试用例。比如我把一个能通过的测试用例故意改坏:

1
2
3
4
5
6
7
8
9
10
11
12
--- a/util/cache_test.cc
+++ b/util/cache_test.cc
@@ -69,7 +69,7 @@ TEST_F(CacheTest, HitAndMiss) {

Insert(100, 101);
ASSERT_EQ(101, Lookup(100));
- ASSERT_EQ(-1, Lookup(200));
+ ASSERT_EQ(101, Lookup(200));
ASSERT_EQ(-1, Lookup(300));

Insert(200, 201);
(END)

修改用例后,需要重新编译 leveldb_tests。因为前面编译的时候,配置了项目的编译选项,CMake 已经缓存了下来,所以下面命令自动用了前面的配置项,比如 -DCMAKE_BUILD_TYPE=Debug 等。

1
2
3
4
5
6
7
8
9
cmake --build . --target leveldb_tests

[ 2%] Built target gtest
[ 58%] Built target leveldb
[ 61%] Built target gtest_main
[ 64%] Built target gmock
[ 65%] Building CXX object CMakeFiles/leveldb_tests.dir/util/cache_test.cc.o
[ 67%] Linking CXX executable leveldb_tests
[100%] Built target leveldb_tests

注意上面的输出可以看到,这里只重新编译了改动的文件,生成了新的目标文件cache_test.cc.o,因此编译速度很快。重新运行后,就会看到测试用例不过了,如下:

测试用例不过

可以看到测试用例验证失败的具体原因。在阅读代码过程中,可以随时修改部分代码的用例,验证自己的理解是否正确。

总结

跟着本文,大家应该都能快速准备好 LevelDB 的开发环境了吧。配置好 IDE,编译好源码,跑完简单的读写示例以及测试用例,然后一起来阅读源码吧~