本节咱们将全面了解一下 LevelDB 的各类特性。LevelDB 的开发语言是 C++,考虑到会使用 C++ 语言的同窗不是不少,在本节咱们将使用 Java 语言来描述 LevelDB 的特性。其它语言栈的同窗也没必要担忧,由于不一样语言操纵 LevelDB 的接口 API 都是同样的,使用起来大同小异。java
LevelDB 的数据存储在一个特定的目录中,里面有不少数据文件、日志文件等。使用 LevelDB API 来打开这个目录,就获得了 db 的引用。后续咱们就使用这个 db 引用来执行读写操做。下面的代码是 Java 语言描述的伪代码。算法
class LevelDB {
public static LevelDB open(String dbDir, Options options);
void close(); // 关闭数据库
}
复制代码
打开数据库有不少选项能够配置,好比设置块缓存大小、压缩等数据库
LevelDB 用起来就像 HashMap,可是比 HashMap 要稍微弱一些,由于 put 方法不能返回旧值,delete 操做也不知道对应的 key 是否真的存在。缓存
class LevelDB {
byte[] get(byte[] key)
void put(byte[] key, byte[] value) void delete(byte[] key) ... } 复制代码
对于多个连续的写操做若是由于宕机有可能致使这多个连续的写操做只完成了一部分。为此 LevelDB 提供了批处理功能,批处理操做就比如事务,LevelDB 确保这一些列写操做的原子性执行,要么所有生效要么彻底不生效。安全
class WriteBatch {
void put(byte[] key, byte[] value);
void delete(byte[] key);
}
class LevelDB {
...
void write(WriteBatch wb);
}
复制代码
当咱们调用 LevelDB 的 put 方法往库里写数据时,它会先将数据记录到内存中,延后再经过某种特殊的策略持久化到磁盘。这就存在一个问题,若是突发宕机,这些来不及写到磁盘的数据就丢失了。因此 LevelDB 也采用了和 Redis AOF 日志相似的策略,先讲修改操做的日志写到磁盘文件中,再进行实际的写操做流程处理。bash
如此即便宕机发生了,数据库启动时还能够经过日志文件来恢复。多线程
了解 Redis 的同窗都知道它的 AOF 写策略有多种配置,取决于日志文件同步磁盘的频率。频率越高,遇到宕机时丢失的数据就越少。操做系统要将内核中文件的脏数据同步到磁盘须要进行磁盘 IO,这会影响访问性能,因此一般都不会同步的太频繁。并发
LevelDB 也是相似的,若是使用前面的非安全写,虽然 API 调用成功了,可是遇到宕机问题,有可能对应的操做日志会丢失。因此它提供了安全写操做,代价就是性能会变差。app
class LevelDB {
...
void putSync(byte[] key, byte[] value);
void deleteSync(byte[] key);
void writeSync(WriteBatch wb);
}
复制代码
在安全和性能之间每每须要折中,因此一般咱们会定时若干毫秒或者每隔若干写操做使用一次同步写。这样能够在兼顾写性能的同时尽可能少丢失数据。函数
LevelDB 的磁盘文件会放在一个文件目录中,里面有不少相关的数据和日志文件。它不支持多进程同时打开这个目录来使用 LevelDB API 进行读写访问。可是对于同一个进程 LevelDB API 是支持多线程安全读写的。LevelDB 内部会使用特殊的锁来控制并发操做。
LevelDB 中的 Key 都是有序的,按照字典序从小到大整齐排列。LevelDB 提供了遍历 API 能够逐个顺序访问全部的键值对,能够指定从中间开始遍历。
class LevelDB {
...
Iterator<KV> scan(byte[] startKey, byte[] endKey, int limit);
}
复制代码
LevelDB 支持多线程并发读写,这意味着连续的两个一样 key 的读操做读到的数据可能不同,由于两个读操做中间数据可能被其它线程修改了。这在数据库理论中称为「重复读」。LevelDB 提供了快照隔离机制,在同一个快照范围内保证连续的读写操做不受其它线程修改操做的影响。
class Snapshot {
byte[] get(byte[] key)
void put(byte[] key, byte[] value) void delete(byte[] key) void write(WriteBatch wb);
...
void close(); // 关闭快照
}
class LevelDB {
...
Snapshot getSnapshot();
}
复制代码
快照虽然很神奇,可是实际上它的原理很是简单,这个咱们后文再深刻讲解。
LevelDB 的 key 默认使用字典序,不过它也提供了自定义排序规则。你能够自定义一个排序函数注册进去,好比按数字排序。必须尽量确保排序规则在整个数据库生命周期内保持不变,由于排序会影响到磁盘键值对的存储顺序,磁盘存储顺序是没法动态改变的。
Options options = new Options();
options.comparator = new CustomComparator();
db = LevelDB.open("/tmp/ldb", options);
复制代码
自定义比较器很危险,谨慎使用。比较算法设置不当,会严重影响到存储效率。若是确实必需要改变排序规则,那就须要提早规划,这里会有一个特别的小技巧,理解它须要了解磁盘存储的细节,因此咱们后续再仔细探讨。
LevelDB 的磁盘数据是以数据库块的形式存储的,默认的块大小是 4k。适当提高块大小将有益于批量大规模遍历操做的效率,若是随机读比较频繁,这时候块小点性能又会稍好,这就要求咱们本身去折中选择。
Options options = new Options();
options.blockSize = 8092;
db = LevelDB.open("/tmp/ldb", options);
复制代码
块不宜太小低于 1k,也不宜过大设置成了好几 M,这样过激的设置并不会给性能带来多大的提高,反而会大幅增长数据库在不一样的读写场合的性能波动。咱们要选择中庸之道,在默认块大小周边浮动。块大小一经初始化就不可再次更改。
LevelDB 的磁盘存储默认是开启压缩的,是业界经常使用的 Snappy 算法,压缩效率很是高,因此无需担忧性能损耗问题。若是你不想使用压缩,也能够动态关闭。关闭压缩开关一般不会带来明显的性能提高,因此咱们尽量不要去动它。
Options options = new Options();
options.compression = CompressionType.kSnappyCompression;
// options.compression = CompressionType.kNoCompression; // 关闭压缩
db = LevelDB.open("/tmp/ldb", options);
复制代码
LevelDB 的内存中存储了一笔最近读写的热数据,若是请求的数据在热数据中查不到就须要去磁盘文件中去查找,效率就会大幅下降。LevelDB 为了下降磁盘文件的搜寻次数,增长了块缓存,缓存了近期频繁使用的数据块解压缩以后的内容。
Options options = new Options();
options.blockCache = LevelDB.NewLRUCache(100 * 1024 * 1024); // 100M
db = LevelDB.open("/tmp/ldb", options);
复制代码
默认块缓存不开启,打开数据库时能够手动设置选项。块缓存会占据一部份内存,不过这一般不须要设置太大,100M 左右就差很少了,再大一些效率提高的也不明显了。
还须要注意遍历操做对缓存的影响,为了不遍历操做将不少冷门数据刷到块缓存中,能够在遍历的时候设置一个选项 fill_cache,它用来控制磁盘遍历的数据块是否须要同步到缓存。
内存读 miss 致使磁盘搜寻是一个比较耗时的操做,LevelDB 为了进一步减小磁盘读的次数,在每一个磁盘文件上又加了一层布隆过滤器,它须要消耗必定的磁盘空间,可是在效果上能够直接将磁盘读次数大幅减小。布隆过滤器的数据存储在磁盘文件中数据块的后面。
LevelDB 的磁盘文件是分层存储的,它会先去 Level 0 查找,若是找不到继续去 Level 1 去找,一直递归到最底层。因此若是你去找一个不存在的 key,就须要不少次磁盘文件读操做,会很是耗费时间。而布隆过滤器能够帮你省去95%以上的磁盘文件搜寻的时间。
布隆过滤器相似于一个内存 Set 结构,它里面存储了指定磁盘文件必定范围内全部 Key 的指纹信息。当它发现某个 key 的指纹在 Set 集合里找不到,它就能够判定这个 key 确定不存在。
若是对应的指纹能够在集合里找到,这并不能肯定它就必定存在。由于不一样的 Key 可能会生成一样的指纹,这就是布隆过滤器的误判率。误判率越低须要的 Key 指纹信息越多,对应消耗的内存空间也就越大。
若是布隆过滤器能准确知道某个 Key 是否存在,那就不存在误判了,这时候也就不会存在白白浪费的磁盘读操做。这样的极限形式的布隆过滤器就是 HashSet —— 内存里存储了全部的 Key,固然内存空间天然是没法接受的。
Options options = new Options();
// 每一个 key 的指纹大小是 10bit
options.filterPolicy = LevelDB.NewBloomFilterPolicy(10);
db = LevelDB.open("/tmp/ldb", options);
复制代码
在使用布隆过滤器时,咱们须要在内存消耗和性能之间作一个折中选择。若是你想深刻理解布隆过滤器的原理,能够去看《Redis 深度历险》,里面有一个单独的章节专门讲解布隆过滤器的内部原理。
默认布隆过滤器没有打开,须要在打开数据库的时候设置 filter_policy 参数才能够生效。布隆过滤器是减小磁盘读操做的最后一层堡垒。布隆过滤器内部的位图数据会存储在磁盘文件中,可是使用是会缓存在内存里面。
LevelDB 有严格的数据校验机制,它将校验的单位精确到了 4K 字节的数据块。校验和会浪费一点存储空间和计算时间,可是在遇到数据块损坏时能够较为精确地恢复健康的数据。
class LevelDB {
...
public void static repairDB(String dbDir, Options options);
}
复制代码
打开数据库时默认没有开启强制校验选项,若是开启了,在遇到校验错误时就会报错。若是数据真的出现了问题,LevelDB 还提供了修复数据的方法 repairDB() 能够帮咱们恢复尽量多的数据。
通过了这一节的学习,同窗们应该能够在脑海中造成下面这样一张概念图。图中的「热数据」是指最近被修改的键值对,这里面的键值对读取速度是最为快速的。若是热数据中读取不到,就会去块缓存中读取。若是还读不到,就分两种状况,一种是真的不存在,另外一个种是存在于磁盘上。若是存在于磁盘上,通过有限层次读取就读取到了,一般越冷的数据越在底层。若是真的不存在就要通过布隆过滤器来大幅减小磁盘搜寻 IO,布隆过滤器的数据和键值对数据共同放在分层的数据文件中。
下一节咱们使用真实的代码来亲自实践一下 LevelDB。