分布式系统

LevelDB:一个快速轻量级的key-value存储库(译)

2011年8月17日 阅读(1,194)

作者:Jeff Dean, Sanjay Ghemawat

原文:http://leveldb.googlecode.com/svn/trunk/doc/index.html

译者:phylips@bmy 2011-8-16

译文:http://duanple.blog.163.com/blog/static/70971767201171705113636/

LevelDB库提供了一种永久性的key value存储。Key和value都是任意的字节序列。在这个key value存储系统中,key按照用户声明的比较函数有序排列。

打开一个数据库

一个LevelDB数据库有一个文件系统目录名称与之关联。该数据库的所有内容都存储在该目录下。下面的例子展示了如何打开一个数据库,或者如何在必要的时候创建一个:

#include <assert>

  #include "leveldb/db.h"

 

  leveldb::DB* db;

  leveldb::Options options;

  options.create_if_missing = true;

  leveldb::Status status = leveldb::DB::Open(options, "/tmp/testdb", &db);

  assert(status.ok());

  …

如果你想在数据库已经存在的情况下,让上面的代码产生一个错误,需要再Open调用之前加入如下一行:

options.error_if_exists = true;
状态(status)

你可能注意到了上面的leveldb::Status数据类型。在LevelDB中绝大多数可能出错的函数调用都会返回这种类型的值。也可以在出错的情况下,打印出一些错误提示信息

   leveldb::Status s = …;   if (!s.ok()) cerr << s.ToString() << endl;
关闭数据库

在完成一个数据库的处理之后,直接删除该数据库对象即可。

… open the db as described above …  … do something with db …  delete db;
读操作与写操作

数据库提供了Put, Delete, 和 Get方法来对数据库进行修改/查询。比如下面的代码会将存储在key1下面的值存到key2下面。

  std::string value;  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);
原子性更新
需要注意的是。在上面的操作中,如果在Put key2之后,delete key1之前,进程死掉了,那么相同的value值就可能会存储在多个key值下面。可以通过使用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还可以通过将多个更新放到同一个batch里,在存在大量更新操作时,加速它们的执行。
同步性的写操作
默认情况下,对于leveldb的写操作是异步的:在将写操作从进程交给操作系统之后它就会返回。从操作系统内存空间到底层永久性存储设备之间的传输是异步进行的。可以通过打开sync flag来,使得一个写操作要等到数据真正的写入到永久性存储后才返回。(在Posix系统中,这是通过在写操作返回之前调用fsync(…) 或者 fdatasync(…) 或者是 msync(…, MS_SYNC))来实现的)  leveldb::WriteOptions write_options;  write_options.sync = true;  db->Put(write_options, …);异步性的写操作通常要比同步性的快1000倍。它的缺点是,当机器crash掉的时候,可能会丢失最后的一些更新。需要注意的是,如果仅仅是写操作进程的crash(比如,不是reboot)并不会引起任何的丢失,因为即使sync=false,一个更新操作在完成之前必须要把数据从应用程序空间传到系统空间。 异步性的写操作通常都可以安全的使用。比如,在将大量数据加载到数据库中时,你可以通过在crash之后重新加载一遍来处理那些丢失的更新。也可以采用一种混合模式,比如每隔N个写操作,进行一次同步,这样在crash发生时,只需要从最后一次同步的地方开始重新执行即可(只需要让同步性的写操作更新一个重启时从何处开始的标记即可)。 WriteBatch也可以为异步性的写操作的提供一个改进。多个更新操作可以放到同一个WriteBatch,然后使用一个同步性的写操作(比如令write_options.sync=true)。这样额外的同步开销就可以分摊到多个写操作中。
并发
同一时刻只能有一个进程打开数据库。为了防止误用,LevelDB实现会从操作系统申请一把锁。在一个进程内部,同一个

leveldb::DB

对象可以安全地被多个并发线程共享。比如,多个线程可以在同一个数据库中写数据,获取迭代器,执行Get调用而不需要额外的同步(LevelDB实现会自动的完成所需的同步)。然而其他对象(比如迭代器和WriteBatch)可能需要额外的同步。如果两个线程共享相同的此类对象,它们必须使用自己的锁机制来保护对此类对象的访问。
迭代
下面的例子用来说明如何打印出数据库中的所有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;

下面的例子说明如何只处理那些给定key值边界[start,limit)内的数据:

  for (it->Seek(start);       it->Valid() && it->key().ToString() < limit;       it->Next()) {    …  }

也可以以逆序方式进行处理 (可能比顺序处理慢些)

  for (it->SeekToLast(); it->Valid(); it->Prev()) {    …  }


Snapshots

Snapshots提供了关于整个key-value存储状态的一致性的只读视图。可以通过设置ReadOptions::snapshot为非空值,来指定从数据库的某个特殊的版本中读取。如果它的值为空,则默认读取当前状态。Snapshots通常通过DB::GetSnapshot()方法创建:

  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);需要注意,在一个snapshot不再需要的时候,必须通过DB::ReleaseSnapshot接口来显示的进行释放。这样就可以让底层实现丢弃掉那些为支持对该snapshot的读取操作所维护的一些状态数据。一个写操作也可以返回一个应用了一系列特殊的更新操作集合后的数据库状态的snapshot:   leveldb::Snapshot* snapshot;  leveldb::WriteOptions write_options;  write_options.post_write_snapshot = &snapshot;  leveldb::Status status = db->Write(write_options, …);  … perform other mutations to db …   leveldb::ReadOptions read_options;  read_options.snapshot = snapshot;  leveldb::Iterator* iter = db->NewIterator(read_options);  … read as of the state just after the Write call returned …  delete iter;  db->ReleaseSnapshot(snapshot);
Slice

上面it->key()和 it->value()调用的返回值都是leveldb::Slice类型。Slice是一个包含了长度及指向外部字节数组的指针的简单结构。返回Slice比返回一个std::string类型开销要低,因为这种我们就不必对那些大的key和value值进行拷贝。另外,LevelDB方法也不能返回以null结尾的C风格字符串,因为它的key和value都允许包含’\0’。 C++ string和以null结尾的C风格字符串可以简单的转化为一个Slice类型:   leveldb::Slice s1 = "hello";   std::string str("world");   leveldb::Slice s2 = str;一个Slice类型也可以简单的转化为一个C++ string类型:   std::string str = s1.ToString();   assert(str == std::string("hello"));在使用Slices需要格外小心,因为它依赖于调用者去保证Slice所指向的外部字节数组的有效性。比如下面代码的就是有问题的:   leveldb::Slice slice;   if (…) {     std::string str = …;     slice = str;   }   Use(slice);因为if语句的作用域已经结束,str将会被析构,这样slice指向的空间就不存在了。
比较器前面的那些例子为key使用了默认的排序函数,会按字典序进行排序。你可以在打开数据库的时候提供一个自定义的比较器。比如假设数据库的key是由两个数组成,我们首先按照第一个数进行排序,如果相等再比较第二个。首先需要定义一个满足如下规则的leveldb::Comparator的子类:  class TwoPartComparator : public leveldb::Comparator {   public:    // Three-way comparison function:    //   if a < b: negative result    //   if a > b: positive result    //   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);  …


向后兼容(Backwards compatibility)

比较器的Name方法的返回结果会在数据库创建时与之绑定,同时在后面每次打开数据库时都会进行检查。如果返回名称发生了变化,那么leveldb::DB::Open调用会失败。因此,当且仅当新的key格式及比较函数与现有数据库不兼容的时候才需要去改变它,同时此时丢弃掉所有现存数据库的内容也应是可以接受的。

 你也可以有计划的逐步改变key的格式。比如,可以在每个key的后面存储一个版本号(对于大多数的情况一个字节就足够了)。当切换到一种新的key格式(比如,为TwoPartComparator处理的key增加一个可选的第三块内容),(a)保持相同的比较器名称(b)新的key增加版本号(c)改变比较器函数,使得它可以通过key里面的版本号来决定如何解释它们。


性能

可以通过改变include/leveldb/options.h里的默认值来对性能进行调整优化。

块大小

LevelDB会将相邻的key值组合在一块放到同一个block中,这样的一个block是与永久性存储设备进行传输的基本单元。默认的块大小大概是4096个未压缩字节。那些经常需要扫描整个数据库内容的应用可能希望增大这个值。那些读取大量小的value值的应用,如果可以得到性能上的改进,可能倾向于将这个值设得更小。如果这个值过大或过小,也不会有太多好处,比如小于1KB或者大于数MB的情况下。需要指出的是,对于那些大的block大小,压缩可能会更有效。

压缩

每个块被写入永久性存储设备之前会被单独压缩。由于默认的压缩方法速度很快,同时对于那些很难压缩的数据它会自动的关闭,因此默认情况下压缩就是开启的。只有在很特殊的情况下,应用才会去关闭压缩,但是只有当通过benchmarks能看到性能提升时才应该这样做。

  leveldb::Options options;  options.compression = leveldb::kNoCompression;  … leveldb::DB::Open(options, name, …) ….
缓存

数据库内容被存储在文件系统的一系列文件中,每个文件保存了一系列的压缩的blocks。如果options.cache值是非NULL的,这时那些已经用过的未压缩的块内容会被缓存。

  #include "leveldb/cache.h"  leveldb::Options options;  options.cache = leveldb::NewLRUCache(100 * 1048576);  // 100MB cache  leveldb::DB* db;  leveldb::DB::Open(options, name, &db);  … use the db …  delete db  delete options.cache;

需要说明的是,缓存里存放的是未压缩数据,因此它的大小是应用级别的数据大小,而不是压缩后的。(对于已压缩的blocks的缓存交给操作系统buffer去缓存,或者是由客户端提供自己的Env实现来完成)。在执行大的读操作的时候,应用程序可能希望不使用缓存,这样就可以避免这些大数据的读操作消耗掉大量的缓存。一个针对迭代器的option参数可以实现这个目的:

  leveldb::ReadOptions options;  options.fill_cache = false;  leveldb::Iterator* it = db->NewIterator(options);  for (it->SeekToFirst(); it->Valid(); it->Next()) {    …  }
Key的layout

需要注意的是磁盘传输和缓存的单位是block。相邻的key(根据数据库排序结果)通常会被放到同一个block里。因此应用程序可以通过将那些相邻近的key值放到一块进行访问,将那些很少被使用的key值放到一个独立的key值空间内。 

比如,假设在LevelDB之上实现一个简单的文件系统。通常需要存储的记录数据如下:

   filename -> permission-bits, length, list of file_block_ids   file_block_id -> data

我们可以为filename添加个前缀(比如’/’),为file_block_id加上另一个(比如’0′),这样对于文件元数据的扫描就不不需要去获取及缓存大量的文件内容数据。


Checksums

LevelDB会为它存储在文件系统中的所有数据生成相应的checksums。通常有两种方式来控制对checksums进行何种程度的验证:ReadOptions::verify_checksums 设为true,会强制对从文件系统中读出的所有数据都进行checksum验证。默认情况下,不进行验证。
Options::paranoid_checks 可以在打开数据库之前将其设为true,这会使得 数据库底层实现只要检测到内部数据错误时就会产生一个error。Error产生的时机取决于数据库的哪个部分出了问题,比如可能在数据库打开时或者是在后面执行某个数据库操作时。默认情况下,paranoid checking是关闭的,这样即使数据库的某些永久性存储出了问题,它仍然也是可以使用的。 如果数据库被破坏了(可能是因为打开了paranoid checking而导致它无法打开),leveldb::RepairDB函数可以用来尽量地对数据进行恢复。


Approximate Sizes

GetApproximateSizes
方法可以被用来得到一个或多个key range所占用的文件系统空间的近似大小。

   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);上面的调用,会设置sizes[0]的值为range [a..c)所占用的文件系统空间的近似大小,sizes[1] 的值为range [x..z)所占用的文件系统空间的近似大小。
Environment

所有由LevelDB实现产生的文件操作(及其他的操作系统调用)都是通过leveldb::Env对象统一管理。为了进行更好的控制,某些客户端可能希望提供自己的Env实现。比如应用程序可能希望在文件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, …);
移植性(Porting)

通过提供一个leveldb/port/port.h中的types/methods/functions的平台描述实现,就可以将LevelDB移植到一个新平台上。具体细节可以参考:leveldb/port/port_example.h


性能(Performance)

下面是运行db_bench程序得到的性能测试报告。结果有些杂乱,但是足以说明问题。

测试环境

使用一个具有百万条记录的数据库,每条记录有一个16字节的key及100字节的value。对于values值压缩后可能大概只有原始大小的一半。

   LevelDB:    version 1.1

   Date:       Sun May  1 12:11:26 2011

   CPU:        4 x Intel(R) Core(TM)2 Quad CPU    Q6600  @ 2.40GHz

   CPUCache:   4096 KB

   Keys:       16 bytes each

   Values:     100 bytes each (50 bytes after compression)

   Entries:    1000000

   Raw Size:   110.6 MB (estimated)

   File Size:  62.9 MB (estimated)

 写性能

"fill" benchmarks会以顺序地或者随机的方式创建一个全新的数据库。"fillsync" benchmark 的每次操作都会将数据从操作系统flush到磁盘;其他的写操作会允许数据在操作系统buffer中停留一段时间。"overwrite" benchmark 会进行随机的写入以更新数据库中现有的key值。

   fillseq      :       1.765 micros/op;   62.7 MB/s    

   fillsync     :     268.409 micros/op;    0.4 MB/s (10000 ops)

   fillrandom   :       2.460 micros/op;   45.0 MB/s    

   overwrite    :       2.380 micros/op;   46.5 MB/s    

上面的一次”op”意味着对于单个key/value对的一次写人。比如随机写benchmark每秒大概可以近似达到400,000写操作。

每个"fillsync"操作花费(0.3ms)远小于一次磁盘seek操作(通常需要10ms)。我们怀疑这是因为硬盘本身会将这些更新缓存到它的memory里,在数据真正写入到扇区之前就做出了响应。这种情况下的安全性取决于硬盘在电力供应出问题时是否有足够的电力去保存它的memory中的数据。

读性能

我们列出了正向及反向顺序读的性能,以及随机查找的性能。需要注意的是,由benchmark创建的数据库是很小的。因此这个报告只是刻画了工作集可以载入到内存时的LevelDB的性能。对于那些未命中操作系统缓存的单片数据读取操作来说,开销主要是由为从磁盘获取数据所需进行的一次或两次磁盘seek操作造成的。而写操作性能几乎不受工作集能否载入到内存的影响。

   readrandom   :      16.677 micros/op;  (每秒大概60,000 reads)

   readseq      :       0.476 micros/op;  232.3 MB/s   

   readreverse  :       0.724 micros/op;  152.9 MB/s   

LevelDB为提高读性能会在后台压缩它的底层存储数据。上面的测试是在进行过大量的随机写操作之后立即进行的。在进行过compactions(通常是自动触发的)再进行测试结果会更好些。

   readrandom   :      11.602 micros/op;  (每秒大概85,000 reads)  

   readseq      :       0.423 micros/op;  261.8 MB/s   

   readreverse  :       0.663 micros/op;  166.9 MB/s   

某些读操作的高花费是由于从硬盘读取出的blocks的重复解压导致的。如果我们可以为LevelDB提供足够的缓存使得它可以将所有的未压缩块放入内存,那么读性能会有更大地改善:

   readrandom   :       9.775 micros/op;  (每秒大概 100,000 reads before compaction)

   readrandom   :       5.215 micros/op;  (每秒大概190,000 reads after compaction)

其他信息

实现相关

Immutable table文件格式

Log文件格式

 

译考文献

http://leveldb.googlecode.com/svn/trunk/doc/index.html

http://code.google.com/p/leveldb/

http://www.infoq.com/news/2011/07/LevelDB

 

You Might Also Like