leveldb 日常使用
Table of Contents
1 leveldb的作用
leveldb是个由Google开发key-value数据库,具有很高的写性能,但是读比较慢。现实世界大多数应用都是写多读少的,所以有人用leveldb作为数据库的存储引擎。直接使用leveldb 的项目比较少见,最常见的是使用 rocksdb,rocksdb是facebook基于leveldb的项目,做了一些优化,性能有些提升,同时提供了更多功能。
2 编译链接
首先下载源代码编译生成 libleveldb.a:
git clone git@github.com:google/leveldb.git cd leveldb mkdir build && cd build cmake -DCMAKE_BUILD_TYPE=Release .. make -j
使用leveldb 的程序需要连接 libleveldb.a,并在包含文件目录中加入 leveldb 项目代码里的 include 目录。由于leveldb使用了libpthread,编译时也要链接pthread库。
例如,建立一个文件 test.cc ,文件内容如下:
#include <assert.h> #include <leveldb/db.h> #include <iostream> #include <string> using namespace std; int main(void) { leveldb::DB *db; leveldb::Options options; options.create_if_missing = true; // open leveldb::Status status = leveldb::DB::Open(options, "/tmp/testdb", &db); assert(status.ok()); string key = "keytest"; string value = "this is the value of keytest"; // write status = db->Put(leveldb::WriteOptions(), key, value); assert(status.ok()); // read status = db->Get(leveldb::ReadOptions(), key, &value); assert(status.ok()); cout << "value of " << key << " is " << value << endl; // delete status = db->Delete(leveldb::WriteOptions(), key); assert(status.ok()); status = db->Get(leveldb::ReadOptions(), key, &value); assert(!status.ok()); // close delete db; return 0; }
上述例子里,leveltest建立了 /tmp/testdb 目录,这就是数据库存放位置。随后向 /tmp/testdb 数据库存入一个键值对并查询。之后删除键,再查询其值。最后关闭数据库。
使用如下命令编译链接并运行程序:
g++ -c test.cc -Ipath/to/leveldb/include g++ path/to/leveldb/build/libleveldb.a -lpthread -o test ./test
下面这个图片形象地解释了编译链接leveldb库这个无聊的流程:
实际项目当然不是像这样手动编译的,我顺手写了个例子,用cmake自动下载leveldb并编译例子程序,原理和上面说的一样,可以 在Github上查看 。
3 打开和关闭数据库
数据库是一个文件系统目录,打开数据库时需要指定这个目录。下面的例子打开 /tmp/testdb这个数据库,如果数据库不存在,则在/tmp/testdb目录创建一个。
leveldb::DB* db; leveldb::Options options; options.create_if_missing = true; leveldb::Status status = leveldb::DB::Open(options, "/tmp/testdb", &db); assert(status.ok());
例子里给options.create_if_missing 赋值为 true,意思是如果指定数据库不存在,则创建一个。options参数还有很多,比如block大小,文件最大长度等。leveldb 的优化是个与实际用途和硬件环境关系密切的工作,默认参数一般是足够的,如果非要优化,需要根据自己的运行环境和用途建立数学模型,计算并实验以确定实际参数值。Leveldb::Options 的定义以及每个参数的用途在 include/leveldb/options.h 中能找到。
要关闭数据库,只需要调用 leveldb::DB 的析构函数即可:
delete db;
Leveldb::DB的析构函数不会把数据库删除,只是清理程序中的数据结构,关闭文件,释放内存,下次再打开这个数据库,存储的数据还在。
4 错误返回值
Leveldb 接口中大多数函数的返回值都是Status对象,表示函数调用是否有错,具体定义在 include/leveldb/status.h 中。
leveldb::Status s = ...; if (!s.ok()) cerr << s.ToString() << endl;
S.ok()表示执行成功,s.IsNotFound()表示未找到,s.IsCorruption()表示数据出错, s.ToString() 返回错误信息。
Google 不抛出异常,返回Status对象表示是否成功的方式显然是个好的异常处理方法,与 linux 中的 errno 类似,只是Status更加简洁明了。
5 读写数据
有关数据库的创建,删除,读写操作都在 include/leveldb/db.h 中。leveldb提供了 Put, Delete,Get方法来写入,删除,查询数据。
std::string value, key1, key2; leveldb::Status s = db->Get(leveldb::ReadOptions(), key1, &value); if (s.ok()) s = db->Put(leveldb::WriteOptions(), key2, value); if (s.ok()) s = db->Delete(leveldb::WriteOptions(), key1);
在头文件里,参数定义key和value都是slice类型,而slice类定义了从std::string的类型转换,所以参数是std::string类型也是可以的。
同一个key被Put两次以上,那么Get到的就是最近一次Put的数据,也就是说Put可以插入,如果已经有这个key,就更新。
6 原子操作
使用 WriteBatch 方法可以将一批更新操作合并为一个操作,如果失败,一次WriteBatch的数据都丢失,如果成功,一次WriteBatch的数据都更新成功。这个原子性在很多时候是必要的。
#include "leveldb/write_batch.h" ... std::string value; leveldb::Status s = db->Get(leveldb::ReadOptions(), key1, &value); if (s.ok()) { leveldb::WriteBatch batch; batch.Delete(key1); batch.Put(key2, value); s = db->Write(leveldb::WriteOptions(), &batch); }
除了原子性这个特性,WriteBatch还可以提升写入效率,多个更新操作合并到WriteBatch会快一些。
7 同步写
Leveldb 的写操作默认是异步的,写函数在将数据提交给操作系统之后返回,数据从内存到磁盘的过程是异步的,由操作系统管理。如果系统崩溃或者断电了,最近的几次写入会丢失。在异步写的情况下,仅仅是程序崩溃,数据是不会丢失的。
为了避免系统崩溃前夕写入函数返回成功但实际数据丢失,同时也为了把写入操作变慢上千倍,leveldb允许同步写入,具体实现是在调用 fsync(), fdatasync(), msync()之后返回。写入时设定WriteOptions的sync成员为真即可。sync是目前WriteOptions唯一的作用。
leveldb::WriteOptions write_options; write_options.sync = true; db->Put(write_options, ...);
一般情况下,异步写是可以满足需求的。如果非要保证数据不丢失,例如,加载较大的数据,可以每隔N个异步写操作加入一个同步写操作,同步写操作写入一个标志,表示这之前的数据都完整写入了,如果系统崩溃了,就从这个标志之后的位置开始导入。将多个写入操作合并到一个WriteBatch上,一次同步完成,也是个常见办法。
8 多线程
同一个leveldb数据库,同一时间只能被一个程序打开。leveldb使用操作系统的锁来保证不会同时访问同一个数据库。Leveldb::DB对象是线程安全的,里面所有方法都不需要额外加锁,但其他的对象,比如WriteBatch,就不是线程安全的,需要外部同步。
9 迭代器
leveldb存储是有序的,可以使用迭代器依次访问数据。下面的例子演示了如何打印一个数据库中的所有key value对。
leveldb::Iterator* it = db->NewIterator(leveldb::ReadOptions()); for (it->SeekToFirst(); it->Valid(); it->Next()) { cout << it->key().ToString() << ": " << it->value().ToString() << endl; } assert(it->status().ok()); // Check for any errors found during the scan delete it;
还可以从访问一个范围内的数据:
for (it->Seek(start);
it->Valid() && it->key().ToString() < limit;
it->Next()) {
...
}
逆序访问也是允许的,只是会慢一些:
for (it->SeekToLast(); it->Valid(); it->Prev()) {
...
}
10 快照
快照是一个leveldb数据库某个时间的状态,是只读的,你也从一个快照中读取这个时间点存储的所有数据。读取的时候要指定ReadOptions::snapshot为获得的快照。
leveldb::ReadOptions options; options.snapshot = db->GetSnapshot(); ... apply some updates to db ... leveldb::Iterator* iter = db->NewIterator(options); ... read using iter to view the state when the snapshot was created ... delete iter; db->ReleaseSnapshot(options.snapshot);
快照用完了记得调用 DB::ReleaseSnapshot 释放。
11 切片(Slice)
切片是对一段连续内存的引用,其定义在include/leveldb/slice.h。leveldb很多接口多使用了leveldb::Slice类型,在函数之间传递Slice要比传递std::string高效得多。需要注意的是,Slice仅仅是一个对真正数据的引用,在一个Slice对象还有用途之前,不能将真正的数据删除。
leveldb::Slice slice; if (...) { std::string str = ...; slice = str; } Use(slice);
上面的例子是有问题的,str指向的内存在 if 结束后就释放了,谁用谁崩溃。
12 比较器(Comparators)
Leveldb 的 key 默认是字典序的,他有个默认比较器,这个比较器认为字典序大的就是大,字典序小的就是小。比较器是可以自定义的,可以指定key的顺序遵守某个规则。例如,key 是个指向一个数据结构的slice,数据结构有两个整数,你可以指定按第一个整数排序,如果第一个整数相等,按第二个整数排序,这个比较器就可以这样定义:
class TwoPartComparator : public leveldb::Comparator { public: // Three-way comparison function: // if a < b: negative result // if a > b: positive resul // else: zero result int Compare(const leveldb::Slice& a, const leveldb::Slice& b) const { int a1, a2, b1, b2; ParseKey(a, &a1, &a2); ParseKey(b, &b1, &b2); if (a1 < b1) return -1; if (a1 > b1) return +1; if (a2 < b2) return -1; if (a2 > b2) return +1; return 0; } // Ignore the following methods for now: const char* Name() const { return "TwoPartComparator"; } void FindShortestSeparator(std::string*, const leveldb::Slice&) const {} void FindShortSuccessor(std::string*) const {} };
现在可以这样创建一个数据库:
TwoPartComparator cmp; leveldb::DB* db; leveldb::Options options; options.create_if_missing = true; options.comparator = &cmp; leveldb::Status status = leveldb::DB::Open(options, "/tmp/testdb", &db); ...
一个数据库的Comparator必须始终如一,创建的时候使用了什么Comparator,以后的所有读写操作都只能用这个Comparator。Comparator 的名字会存在数据库里,如果不一样, leveldb::DB::Open会失败。如果预感到将来要改变Comparator,可以在插入时,每个key里面加入一个版本字段,编写一个根据版本字段选择如何比较的Comparator,将来要改变时,就添加一个版本,修改Comparator比较逻辑,但不能改变名字。
13 性能调优
性能相关的参数,都在 include/leveldb/options.h 里定义。
13.1 块大小
leveldb把数据组合成块,key相邻的数据会放到相同的块里。块是持久化存储的最小单位,每次从文件读,或者写入文件,都是以块为单位读写的。默认的块大小是4096,这是没压缩过的大小,如果使用压缩算法,实际存储的块会比这个小。经常批量扫描多个数据的应用可能更喜欢较大的块,经常随机访问一个或一小段数据应用可能会喜欢更小的块,压缩算法喜欢较大的块。不管数学有多好,块大小的具体值也要根据实际测试结果确定,因为实际情况往往比模型复杂。小于1k字节的块,或者数兆以上字节的块,是没有意义的。
13.2 压缩算法
每个块是单独压缩的,压缩算法执行在块写入持久化存储之前。因为默认的压缩算法很快, leveldb默认开启压缩。如果数据不可压缩,leveldb会选择不压缩,熵高的数据一般不可压缩。少数应用可能不想要压缩,如果基准测试证明应该关闭压缩,就可以关闭:
leveldb::Options options; options.compression = leveldb::kNoCompression; ... leveldb::DB::Open(options, name, ...) ....
13.3 缓存
数据是存在数个文件中的,每个文件存储了一系列压缩过的块,为了加速读取,一般需要一个块缓存。
#include "leveldb/cache.h" leveldb::Options options; options.block_cache = leveldb::NewLRUCache(100 * 1048576); // 100MB cache leveldb::DB* db; leveldb::DB::Open(options, name, &db); ... use the db ... delete db delete options.block_cache;
Cache 的所有操作在 include/leveldb/cache.h 中能找到,一般也不需要操作。需要注意, Cache 存储的是整个块的压缩之前的数据。操作系统会缓存文件内容在内存中,如果你非要在操作系统和cache之间添加一层缓存,可以自己实现个Env类。
缓存是LRU的,在批量顺序读取的时候你可能不希望频繁更新缓存,因为这次缓存更新之后,接下来的读取会马上将他挤出,这种多余的更新缓存是毫无意义的。这种情况下, ReadOptions提供了一个选项,可以指定在读取的时候不更新缓存:
leveldb::ReadOptions options; options.fill_cache = false; leveldb::Iterator* it = db->NewIterator(options); for (it->SeekToFirst(); it->Valid(); it->Next()) { ... }
13.4 Key 组织
Leveldb 把大小(由Comparator指定的大小顺序)相邻的key存放在一起,一般相邻的key就一起放在相同的块中。所以,在编写应用程序时,可以将经常一起访问的数据用相邻的key保存,不一起访问的key用距离大的key保存。例如,要在leveldb上实现一个文件系统,可以把文件名,路径等key存放在一起(加个相同的前缀),把文件内容放在另一个地方(另一个前缀),这样,在路径中浏览时就不需要加载并缓存文件内容了。
13.5 过滤器
leveldb数据的组织方式利于写入,不利于读取,一次Get可能需要使用好几次文件访问才能完成。leveldb使用过滤器减少文件读取操作,读文件之前先使用过滤器检查数据是否可能存在于这个文件中,如果不存在,就不需要再读取这个文件其他内容了。leveldb提供了 BloomFilterPolicy作为过滤器:
leveldb::Options options; options.filter_policy = NewBloomFilterPolicy(10); leveldb::DB* db; leveldb::DB::Open(options, "/tmp/testdb", &db); ... use the database ... delete db; delete options.filter_policy;
在这个例子中给,使用了BloomFilterPolicy,指定每存在一个key,就增加10个比特位用于过滤,这个值大约将读取操作减少100倍。更大的值能更多减少文件读操作,也会消耗更多存储空间。虽然过滤器有一定存储和计算消耗,但仍然建议经常随机读取,且数据大的应用设置一个过滤器。
如果使用了自定义的比较器,就要保证过滤器和比较器兼容。比如,比较器中忽略了key中最后几个字节,过滤器也要忽略这几个字节,这就需要自定义过滤器:
class CustomFilterPolicy : public leveldb::FilterPolicy { private: FilterPolicy* builtin_policy_; public: CustomFilterPolicy() : builtin_policy_(NewBloomFilterPolicy(10)) {} ~CustomFilterPolicy() { delete builtin_policy_; } const char* Name() const { return "IgnoreTrailingSpacesFilter"; } void CreateFilter(const Slice* keys, int n, std::string* dst) const { // Use builtin bloom filter code after removing trailing spaces std::vector<Slice> trimmed(n); for (int i = 0; i < n; i++) { trimmed[i] = RemoveTrailingSpaces(keys[i]); } return builtin_policy_->CreateFilter(&trimmed[i], n, dst); } };
有些情况下,不用 bloom filter 会更准确高效,例如,你将能被1007整除的存在一起,能被1007除余1的放在一起,能被1007除余2的放在一起…,这就可以自定义过滤器,判断key 被1007整除余几,也是很高效的。总之不用bloom filter的情况我还没遇到过。
13.6 校验和
leveldb存储数据时会带校验和,但通常不需要,因为可以通过文件系统,RAID等方式验证完整性。意外总会发生,leveldb提供了两种校验和验证方式。
一种方式是在读取操作时指定 ReadOptions::verify_checksums,这个参数为真的话,这一次读取操作里,所有这次从文件中读取的数据都被校验,这个功能默认是关的。第二种操作是在数据库打开时指定 Options::paranoid_checks,这个参数为真的话,当leveldb在操作中发现任何数据损坏,就会返回错误(Google 不抛出异常),这个功能默认也是关的。
当部分数据损坏,程序仍然能够读取没有损坏的数据,如果是在打不开,可以会调用 leveldb::RepairDB 恢复尽可能多的数据。
14 获得数据占用的空间
使用 GetApproximateSizes 函数可以获得一段数据大约占用了多少存储空间。
leveldb::Range ranges[2]; ranges[0] = leveldb::Range("a", "c"); ranges[1] = leveldb::Range("x", "z"); uint64_t sizes[2]; leveldb::Status s = db->GetApproximateSizes(ranges, 2, sizes);
上述例子中,\(size[0]\) 表示 \([a..c)\) 占用的空间,\(size[1]\) 表示 \([x..z)\) 占用的空间。这个数字不准,因为leveldb不光存了key-value数据,还有一些校验,索引,过滤器等,这些内部使用的数据常常是作用于一批key的,不能精确地说某个key占用了多少这些数据所以这个大小是大致数值。其实也挺精确的。
15 底层依赖
所有文件操作,以及其他系统调用都封装到 leveldb::Env 里面了。见多识广的专家可能希望自己实现一个Env来优化这些调用。例如,有人嫌直接调用系统接口太快了,占用太多IO,想实现个慢点操作IO的leveldb,就会这么做:
class SlowEnv : public leveldb::Env { ... implementation of the Env interface ... }; SlowEnv env; leveldb::Options options; options.env = &env; Status s = leveldb::DB::Open(options, ...);
16 移植到其他平台
移植其他平台需要实现 leveldb/port/port.h 中声明的结构和函数,参考 leveldb/port/port_example.h 。除此之外,还需要实现这个平台的 leveldb::Env。这个事我没干过,毕竟厂里的存储需求用Linux都能满足。