我们的缓存是inmemory 的,这样的实现存在两个问题,首先是缓存的容量受到内存的限制,其次是一旦服务重启,之前保存的键值对就会全部丢失。这样对于客户来说很不友好,功能上来看也不完备。
现在基本上所有的缓存服务都支持数据持久化,也就是说在服务器重启后,缓存的数据不会丢失。为了能做到这一点,我们在本章会用 RocksDB 来重新实现我们的缓存服务。
RocksDB简介
RocksDB 是一个完全用 C++写的库,提供字节流形式的键值对存储。它是Facebook 基于 LevelDB 开发的,使用日志结构的数据库引擎作为底层存储。它对于在闪存上的读写进行过特别的优化,延迟极低。它同时也提供了非常灵活的配置选项,可以在各种不同的生产环境下使用,包括纯内存、闪存、普通旋转磁盘或HDFS。
RocksDB特性包括但不限于:
- 可以在本地闪存或内存中存储多达几个太字节的数据;
- 可以为小或中等规模键值对提供快速的存储;
- 性能可随CPU 数量线性扩展。.
将 RocksDB 设置为项目的子模块,在源码根目录执行如下命令即可下载:
git submodule update --init
下载 RocksDB 源码后,还需要将其编译成静态链接库,让 Go程序能够链接编译命令如下:
cd rocksdb && make static lib
编译 RocksDB静态链接库需要用到 make和g+工具。另外,编译好的 RocksDB库还需要用到 libz和 libsnappy 这两个库。可以用apt-get 命令下载它们:
sudo apt-get install make g++ libz-dev libsnappy-dev
当我们的数据总量为 100MB 时,RocksDB 表现出非常优秀的性能写入操作的平均时间6.7us,吞吐量 148MB/s;数据量达到1GB 时,平均时间 7us吞吐量降到141MB/s;而当数据总量为 10GB 时,平均时间增加到了 9.2us,吞吐量降至108MB/s。
我们发现因为写入 RocksDB 数据总量的不同性能会展现出差异,这是为什么呢?
首先我们需要知道,向磁盘写入数据这一事件的性能会受到很多因素的影响有虚拟机操作系统的缓冲(buffer),有宿主机的写入虚拟磁盘文件的缓存,还有磁盘本身的硬件缓存。单从应用程序的角度看,不同的写入数据量就已经会表现出不同的磁盘性能。
RocksDB 写入操作本身还有额外开销:RocksDB 每写入一个单位的数据,需要实际读写磁盘的数据量大于一个单位,两者的比值叫作写放大系数(writeamplification,类似的还有读取数据时的读放大系数 read amplification 以及影响存储效率的空间放大系数 space amplification)。这个系数并不是一个固定的值,每一次RocksDB将内存表压缩进日志结构合并树(Log-structured merge-tree,LSM)时的输入输出数据都会决定本次压缩的写放大系数。当数据量较少时,期间经历的压缩次数也少,比如 100MB 时我们压缩了0次,那么总体的写放大系数就是 1;1GB时我们经历了 1次压缩,压缩比 0.7,该次压缩的写放大系数就是 1.7,总体的系数则是在1到1.7 之间。
所以,要想提升 RocksDB 的写入性能,我们可以从这两个方面入手:提高磁盘写入的速度和降低 RocksDB 写放大系数。
用cgo调用C++库函数
RocksDB是用 C++写的,而我们的缓存服务则是用 Go语言写的,要让我们的缓存服务能够调用 C++的库函数,我们需要从两个方向共同努力。首先,用 C++写的库需要提供 CAPI,提供给那些不能直接调用 C++的外部程序使用,这一点RocksDB 已经做到了。其次,Go语言需要提供一种机制,能够让我们写的 Go程序调用 C的API函数,这个机制叫做 cgo。接下来,就让我们通过一个简单的例子来了解一下 cgo的用法。
首先用 C++写一个简单的库函数 testtest.cpp
#include <iostream>
using namespace std;
extern "C" {
void test()
cout <<"this is a test" << endl;
}
}
test.cpp
是一个用 C++写的源代码文件,里面只有一个 test
函数用来在屏幕上打印一个字符串。这个 test函数必须被包括在 extern "C"
代码块内部,以向下兼容C的命名编码规范(name mangling)。
之后,让我们将这个 test.cpp 用gcc 编译成 test.o对象文件并打包成 libtest.a 库文件:
gcc test.cpp -c
ar rcs libtest.a test.o
接下来,让我们创建一个 test.h 头文件来告诉 cgo我们的C库函数的签名
void test();
最后,让我们写一个 Go程序来调用这个 C库。
package main
// #include "test.h"
// #cgo LDFLAGS: libtest.a -lstdc++
import "C"
func main() {
C.test()
}
cgo会从注释记号“//”后面获取必要的 C 语言信息,这些注释需要满足特定的需求。
#include 告诉 cgo声明 C库函数的h 头文件名,默认是在 Go语言源码的当前目录寻找这些头文件,我们也可以用#cgo CFLAGS
来设置编译选项,告诉编译器去哪个目录寻找头文件。我们需要包含的头文件 test.h 就在当前目录中。
#cgo LDFLAGS
可以告诉cgo 实现C库函数的a库文件名
以及该去哪个目录寻找它,也可以添加额外的链接器选项。我们需要链接的库文件 libtesta 就在当前目录,而且由于库函数用到了 C++标准库中的 std::cout 和 std::endl,所以还需要额外链接C++标准库。
import"C"
告诉 Go编译器调用 cgo。这一行必须直接写在注释行下面,当中不能有空行,否则 cgo 就会认不出上面那些特殊的注释行,它们就会被当成普通的Go注释看待。同样的原因,import"C"也必须被写成单独一行,不能跟其他import用小括号放在一起。
在 main 函数里,我们用C.test()的方式调用C库的 test函数。这也是cgo的需求之一:所有涉及 C库的函数或类型,在 Go语言中访问都需要额外加上“C.”来表示。
接下来,我们的 Go程序就可以编译运行了!
go run test.go
this is a test
go run 看上去似乎是直接运行的,其实 Go 编译器是先进行了编译,编译后的可执行程序被放在 个临时的地方,然后再执行它。
小结
在本章我们用 RocksDB重新实现了缓存服务。RocksDB 克服了 in memory的缺陷,不仅能够让缓存容量突破内存的限制且重启后内容也不会丢失。但是由于Go语言调用C++库会有一定的性能损耗,使用 RocksDB的缓存性能要低于使用inmemory的。
这里的性能损耗来自两个方面,一方面是 RocksDB 本身为了提供C API,需要在 C++的接口上额外封装一层C函数,这会导致一些额外的内存分配、复制和释放(为了C语言的 char*和 C++的std::string 之间的互转)。另一方面 Go语言通过 cgo 调用 C API又会有一些额外的内存分配、复制和释放(为了char*和Go语言 string/[ ]byte 之间的互转)。这两层性能损耗导致了我们每个请求的处理时间比inmemory的实现平均多花费30us。