LevelDB是google开发的,一个速度很是快的KeyValue持久化存储库,key和value能够是任意的byte数组,而且这种映射关系按key排序。git
计数器功能,更新很是频繁,且数据不可丢失github
测试库共100w行记录,每条记录16字节的key,100字节的value,压缩后的value大概50字节算法
上述性能都是在没有打开“压缩”功能下的结果,若是打开“压缩”选项,性能会有所提高,例如随机读性能会提高至11.602微秒,即8.5w次每秒数据库
Google的leveldb是个很优秀的存储引擎,但仍是有一些不尽人意的地方,好比leveldb不支持多线程合并,对key范围查找的支持还很简单,未作优化措施,等等。数组
而Facebook的RocksDB是个更彪悍的引擎,其实是在LevelDB之上作的改进,在用法上与LevelDB很是的类似。缓存
现代开源市场上有不少数据库都在使用 RocksDB 做为底层存储引擎,好比TiDB。性能优化
在讲述LevelDB的实现时,我想从数据存储和检索开始论述,讨论不一样结构的存储方式的演进与区别。bash
在最基础的层面,一个数据库应该能作两件事:微信
咱们尝试实现一个最简单的KeyValue持久化数据库,支持如下功能:数据结构
新建项目SimpleDB,构建一个Server,支持
@GetMapping(path = "set")
public String set(@RequestParam("key") String key, @RequestParam("value") String value) {
if (StringUtils.isEmpty(key) || StringUtils.isEmpty(value)) return "FALSE";
String recordLine = buildRecordLine(key, value);
FileUtil.writeString(recordLine, PATH, UTF_8, true);
return "OK";
}
复制代码
@GetMapping(path = "get")
public String get(String key) {
List<String> recordLines = FileUtil.readLines(PATH);
List<Record> targetRecords = recordLines.stream()
.map(line -> JsonUtil.decodeJson(line, Record.class))
.filter(record -> key.equals(record.getKey()))
.collect(Collectors.toList());
return CollectionUtils.isEmpty(targetRecords) ? null : targetRecords.get(targetRecords.size() - 1).getValue();
}
复制代码
执行如下set请求
http://localhost:8080/set?key=name&value=yuming
http://localhost:8080/set?key=age&value=24
http://localhost:8080/set?key=name&value=yuming2
复制代码
持久化文件内容
{"key":"name","value":"yuming"}
{"key":"age","value":"24"}
{"key":"name","value":"yuming2"}
复制代码
执行如下get请求
http://localhost:8080/get?key=name
http://localhost:8080/get?key=age
复制代码
响应为
yuming2
24
复制代码
set操做很是简单,收到请求追加到持久化文件中,由于是顺序写因此效率会很高。
相似的不少内部基于日志的数据库也是将数据追加到文件中,固然真正的数据库有更多的问题须要处理(如并发控制,回收磁盘空间以免日志无限增加,处理错误与部分写入的记录),但基本原理是同样的。
现有的set方法并不支持,不过只须要简单改造一下,保证明际上的写线程只有一个便可。
private static ExecutorService executorService = Executors.newSingleThreadExecutor();
/**
* set接口将收到的Key和Value拼接成KeyValue结构的JSON字符串追加到指定持久化文件中
*
* @param key
* @param value
* @return
*/
@GetMapping(path = "set")
public String set(@RequestParam("key") String key, @RequestParam("value") String value) {
if (StringUtils.isEmpty(key) || StringUtils.isEmpty(value)) return "FALSE";
String recordLine = buildRecordLine(key, value);
Future<?> future = executorService.submit(() -> FileUtil.writeString(recordLine, PATH, UTF_8, true));
try {
future.get();
return "OK";
} catch (Exception e) {
LOGGER.error("writeString error", e);
return "FALSE";
}
}
复制代码
再来看Get操做,因为每次查询都须要遍历整个日志文件(O(n)),当日志文件愈来愈大时,Get操做的性能不容乐观。
第一个思路是能不能加缓存,可是随着数据量愈来愈多内存明显是不够用的。
若是热数据比较多,大多数key很快被覆盖,甚至后续咱们提供删除方法(追加value为null的行,后续压缩线程处理)实际上的KeyValue对数没那么多,是否是就能使用缓存了呢?
对于这种状况首先是须要处理缓存和文件内容的数据一致性问题。
第二点就是既然都是热数据,实际的有效数据根本没那么多,咱们为何要使用缓存,而不是把数据存放在内存中呢?
Redis直接将数据保存在内存中,为了实现持久化功能也会将数据追加到日志文件中,日志文件只在启动时恢复数据使用(顺序读,且只执行一次)。
这样Redis就能保证:
因为数据存储在内存中,Redis也可以实现如下功能:
不是全部的场景都可以将数据全存储在内存中,那么咱们仍是须要探索一下有没有其余方案。
为了高效查找数据库中特定键的值,咱们须要一个数据结构:索引(index)。
索引背后的大体思想是,保存一些额外的元数据做为路标,帮助你找到想要的数据。若是你想在同一份数据中以几种不一样的方式进行搜索,那么你也许须要不一样的索引,建在数据的不一样部分上。
索引是从主数据衍生的附加结构,这种结构必然不是简单的追加日志。许多数据库容许添加与删除索引,这不会影响数据的内容,它只影响查询和插入的性能。维护额外的结构会产生开销,特别是在写入时。
写入性能很难超过简单地追加写入文件,由于追加写入是最简单的写入操做。任何类型的索引一般都会减慢写入速度,由于每次写入数据时都须要更新索引。
这是存储系统中一个重要的权衡:精心选择的索引加快了读查询的速度,可是每一个索引都会拖慢写入速度。由于这个缘由,数据库默认并不会索引全部的内容,而须要你经过对应用查询模式的了解来手动选择索引。你能够选择能为应用带来最大收益,同时又不会引入超出必要开销的索引。
咱们继续优化咱们的SimpleDB,以前有讨论对读操做加缓存,咱们提到对全部数据都加缓存内存是没法承受的。
为了减小缓存的大小,咱们能够不全量缓存,只缓存部分热数据,使用LRU淘汰不经常使用的缓存。
使用这种方案在缓存未命中的时候须要遍历整个日志文件,数据量很大的时候查找时间没法接受,而且查询的响应时间区别太大。
既然不能部分缓存,那么咱们可否压缩一下咱们的缓存。
咱们以前的缓存构建的是Map<key, value>结构,value的值是最耗费空间的部分,若是咱们将value由实际的值换成实际值在文件中的位置可以节省不少的空间。并且查询时因为知道位置因此能够很快查到对应的数据。
咱们构建的这样一个结构其实就是哈希索引。
这样的存储引擎很是适合每一个键的值常常更新的状况。例如,键多是视频的URL,值多是它播放的次数(每次有人点击播放按钮时递增)。
在这种类型的工做负载中,有不少写操做,可是没有太多不一样的键——每一个键有不少的写操做,可是将全部键保存在内存中是可行的。
直到如今,咱们只是追加写入一个文件 —— 因此如何避免最终用完磁盘空间?一种好的解决方案是,将日志分为特定大小的段,当日志增加到特定尺寸时关闭当前段文件,并开始写入一个新的段文件。
而后,咱们就能够对这些段进行压缩,以下图所示。压缩意味着在日志中丢弃重复的键,只保留每一个键的最近更新。
并且,因为压缩常常会使得段变得很小(假设在一个段内键被平均重写了好几回),咱们也能够在执行压缩的同时将多个段合并在一块儿,以下图所示。段被写入后永远不会被修改,因此合并的段被写入一个新的文件。
冻结段的合并和压缩能够在后台线程中完成,在进行时,咱们仍然能够继续使用旧的段文件来正常提供读写请求。合并过程完成后,咱们将读取请求转换为使用新的合并段而不是旧段 —— 而后能够简单地删除旧的段文件。
使用哈希索引当数据量不少时咱们没法将全部索引数据都放入内存,那么咱们可否只存储部分索引呢?基于以前的文件结构显然是没法支持的。若是咱们的文件内容是有序排列的,是否是就能够只存储部分索引呢?
咱们能够对段文件的格式作一个简单的改变:咱们要求键值对的序列按键排序。乍一看,这个要求彷佛打破了咱们使用顺序写入的能力。
咱们把这个格式称为排序字符串表(Sorted String Table),简称SSTable。咱们还要求每一个键只在每一个合并的段文件中出现一次(压缩过程已经保证)。与使用散列索引的日志段相比,SSTable有几个很大的优点:
若是在几个输入段中出现相同的键,该怎么办?请记住,每一个段都包含在一段时间内写入数据库的全部值。这意味着一个输入段中的全部值必须比另外一个段中的全部值更新(假设咱们老是合并相邻的段)。
当多个段包含相同的键时,咱们能够保留最近段的值,并丢弃旧段中的值。 2. 为了在文件中找到一个特定的键,你再也不须要保存内存中全部键的索引。如下图为例:假设你正在内存中寻找键 handiwork,可是你不知道段文件中该关键字的确切偏移量。
然而,你知道 handbag 和 handsome 的偏移,并且因为排序特性,你知道 handiwork 必须出如今这二者之间。这意味着您能够跳到 handbag 的偏移位置并从那里扫描,直到您找到 handiwork(或没找到)。
您仍然须要一个内存中索引来告诉您一些键的偏移量,但它可能很稀疏:每几千字节的段文件就有一个键就足够了,由于几千字节能够很快被扫描。 3. 因为读取请求不管如何都须要扫描所请求范围内的多个键值对,所以能够将这些记录分组到块中,并在将其写入磁盘以前对其进行压缩(如上图中的阴影区域所示)。
稀疏内存中索引的每一个条目都指向压缩块的开始处。除了节省磁盘空间以外,压缩还能够减小IO带宽的使用。
到目前为止,可是如何让你的数据首先被按键排序呢?咱们的传入写入能够以任何顺序发生。
如今咱们可使咱们的存储引擎工做以下:
写入时,先将其添加到内存中的平衡树数据结构(例如,红黑树)。这个内存树有时被称为内存表(memtable)。
当内存表大于某个阈值时,将其做为SSTable文件写入磁盘。这能够高效地完成,由于树已经维护了按键排序的键值对。新的SSTable文件成为数据库的最新部分。当SSTable被写入磁盘时,写入能够继续到一个新的内存表实例。
为了提供读取请求,首先尝试在内存表中找到关键字,而后在最近的磁盘段中,而后在下一个较旧的段中找到该关键字。
在后台会运行合并和压缩过程以组合段文件并丢弃覆盖或删除的值。
这个方案效果很好。它只会遇到一个问题:若是数据库崩溃,则最近的写入(在内存表中,但还没有写入磁盘)将丢失。为了不这个问题,咱们能够在磁盘上保存一个单独的日志,每一个写入都会当即被附加到磁盘上。
该日志不是按排序顺序,但这并不重要,由于它的惟一目的是在崩溃后恢复内存表。每当内存表写出到SSTable时,相应的日志均可以被丢弃。
从 LevelDB 中读取数据其实并不复杂,memtable 和 imm 更像是两级缓存,它们在内存中提供了更快的访问速度,若是能直接从内存中的这两处直接获取到响应的值,那么它们必定是最新的数据。
LevelDB 总会将新的键值对写在最前面,并在数据压缩时删除历史数据。
数据的读取是按照 MemTable、Immutable MemTable 以及不一样层级的 SSTable 的顺序进行的,前二者都是在内存中,后面不一样层级的 SSTable 都是以 *.ldb 文件的形式持久存储在磁盘上,而正是由于有着不一样层级的 SSTable,因此咱们的数据库的名字叫作 LevelDB。
当 LevelDB 在内存中没有找到对应的数据时,它才会到磁盘中多个层级的 SSTable 中进行查找,这个过程就稍微有一点复杂了,LevelDB 会在多个层级中逐级进行查找,而且不会跳过其中的任何层级.
在查找的过程就涉及到一个很是重要的数据结构 FileMetaData:
FileMetaData 中包含了整个文件的所有信息,其中包括键的最大值和最小值、容许查找的次数、文件被引用的次数、文件的大小以及文件号,由于全部的 SSTable 都是以固定的形式存储在同一目录下的,因此咱们能够经过文件号轻松查找到对应的文件。
查找的顺序就是从低到高了,LevelDB 首先会在 Level0 中查找对应的键。可是,与其余层级不一样,Level0 中多个 SSTable 的键的范围有重合部分的,在查找对应值的过程当中,会依次查找 Level0 中固定的 4 个 SSTable。
可是当涉及到更高层级的 SSTable 时,由于同一层级的 SSTable 都是没有重叠部分的,因此咱们在查找时能够利用已知的 SSTable 中的极值信息 smallest/largest 快速查找到对应的 SSTable,
再判断当前的 SSTable 是否包含查询的 key,若是不存在,就继续查找下一个层级直到最后的一个层级 kNumLevels(默认为 7 级)或者查询到了对应的值。
这里描述的算法本质上是LevelDB和RocksDB中使用的关键值存储引擎库,被设计嵌入到其余应用程序中。除此以外,LevelDB能够在Riak中用做Bitcask的替代品。在Cassandra和HBase中使用了相似的存储引擎,这两种引擎都受到了Google的Bigtable文档(引入了SSTable和memtable)的启发。
最初这种索引结构是由Patrick O'Neil等人描述的。在日志结构合并树(或LSM树)的基础上,创建在之前的工做上日志结构的文件系统。基于这种合并和压缩排序文件原理的存储引擎一般被称为LSM存储引擎。
Lucene是Elasticsearch和Solr使用的一种全文搜索的索引引擎,它使用相似的方法来存储它的词典。全文索引比键值索引复杂得多,可是基于相似的想法:在搜索查询中给出一个单词,找到说起单词的全部文档(网页,产品描述等)。这是经过键值结构实现的,其中键是单词(关键词(term)),值是包含单词(文章列表)的全部文档的ID的列表。在Lucene中,从术语到发布列表的这种映射保存在SSTable类的有序文件中,根据须要在后台合并。
与往常同样,大量的细节使得存储引擎在实践中表现良好。例如,当查找数据库中不存在的键时,LSM树算法可能会很慢:您必须检查内存表,而后将这些段一直回到最老的(可能必须从磁盘读取每个),而后才能肯定键不存在。
为了优化这种访问,存储引擎一般使用额外的Bloom过滤器。 (布隆过滤器是用于近似集合内容的内存高效数据结构,它能够告诉您数据库中是否出现键,从而为不存在的键节省许多没必要要的磁盘读取操做。
还有不一样的策略来肯定SSTables如何被压缩和合并的顺序和时间。最多见的选择是大小分层压实。 LevelDB和RocksDB使用平坦压缩(LevelDB所以得名),HBase使用大小分层,Cassandra同时支持。
在规模级别的调整中,更新和更小的SSTables前后被合并到更老的和更大的SSTable中。在水平压实中,关键范围被拆分红更小的SSTables,而较旧的数据被移动到单独的“水平”,这使得压缩可以更加递增地进行,而且使用更少的磁盘空间。
即便有许多微妙的东西,LSM树的基本思想 —— 保存一系列在后台合并的SSTables —— 简单而有效。即便数据集比可用内存大得多,它仍能继续正常工做。
因为数据按排序顺序存储,所以能够高效地执行范围查询(扫描全部高于某些最小值和最高值的全部键),而且由于磁盘写入是连续的,因此LSM树能够支持很是高的写入吞吐量。
刚才讨论的日志结构索引正处在逐渐被接受的阶段,但它们并非最多见的索引类型。使用最普遍的索引结构在1970年被引入,不到10年后变得“无处不在”,B树经受了时间的考验。在几乎全部的关系数据库中,它们是标准的索引实现。
像SSTables同样,B树保持按键排序的键值对,这容许高效的键值查找和范围查询。但这就是类似之处的结尾:B树有着很是不一样的设计理念。
咱们前面看到的日志结构索引将数据库分解为可变大小的段,一般是几兆字节或更大的大小,而且老是按顺序编写段。
相比之下,B树将数据库分解成固定大小的块或页面,传统上大小为4KB(有时会更大),而且一次只能读取或写入一个页面。这种设计更接近于底层硬件,由于磁盘也被安排在固定大小的块中。
每一个页面均可以使用地址或位置来标识,这容许一个页面引用另外一个页面 —— 相似于指针,但在磁盘而不是在内存中。咱们可使用这些页面引用来构建一个页面树,如图所示。
若是要更新B树中现有键的值,则搜索包含该键的页,更改该页中的值,并将该页写回到磁盘(对该页的任何引用保持有效)。
若是你想添加一个新的键,你须要找到其范围包含新键的页面,并将其添加到该页面。若是页面中没有足够的可用空间容纳新键,则将其分红两个半满页面,并更新父页面以解释键范围的新分区,如图所示。
该算法确保树保持平衡:具备 n 个键的B树老是具备 O(log n) 的深度。大多数数据库能够放入一个三到四层的B树,因此你不须要遵追踪多页面引用来找到你正在查找的页面。(分支因子为 500 的 4KB 页面的四级树能够存储多达 256TB 。)
B树的基本底层写操做是用新数据覆盖磁盘上的页面。假定覆盖不改变页面的位置;即,当页面被覆盖时,对该页面的全部引用保持完整。这与日志结构索引(如LSM树)造成鲜明对比,后者只附加到文件(并最终删除过期的文件),但从不修改文件。
为了使数据库对崩溃具备韧性,B树实现一般会带有一个额外的磁盘数据结构:预写式日志(WAL, write-ahead-log)(也称为重作日志(redo log))。这是一个仅追加的文件,每一个B树修改均可以应用到树自己的页面上。当数据库在崩溃后恢复时,这个日志被用来使B树恢复到一致的状态。
更新页面的一个额外的复杂状况是,若是多个线程要同时访问B树,则须要仔细的并发控制 —— 不然线程可能会看到树处于不一致的状态。这一般经过使用锁存器(latches)(轻量级锁)保护树的数据结构来完成。日志结构化的方法在这方面更简单,由于它们在后台进行全部的合并,而不会干扰传入的查询,而且不时地将旧的分段原子交换为新的分段。
尽管B树实现一般比LSM树实现更成熟,但LSM树因为其性能特色也很是有趣。根据经验,一般LSM树的写入速度更快,而B树的读取速度更快。 LSM树上的读取一般比较慢,由于它们必须在压缩的不一样阶段检查几个不一样的数据结构和SSTables。
B树索引必须至少两次写入每一段数据:一次写入预先写入日志,一次写入树页面自己(也许再次分页)。即便在该页面中只有几个字节发生了变化,也须要一次编写整个页面的开销。有些存储引擎甚至会覆盖同一个页面两次,以避免在电源故障的状况下致使页面部分更新。
LSM树能够被压缩得更好,所以常常比B树在磁盘上产生更小的文件。 B树存储引擎会因为分割而留下一些未使用的磁盘空间:当页面被拆分或某行不能放入现有页面时,页面中的某些空间仍未被使用。因为LSM树不是面向页面的,而且按期重写SSTables以去除碎片,因此它们具备较低的存储开销,特别是当使用平坦压缩时。
日志结构存储的缺点是压缩过程有时会干扰正在进行的读写操做。尽管存储引擎尝试逐步执行压缩而不影响并发访问,可是磁盘资源有限,因此很容易发生请求须要等待而磁盘完成昂贵的压缩操做。而B树的行为则相对更具可预测性。
压缩的另外一个问题出如今高写入吞吐量:磁盘的有限写入带宽须要在初始写入(记录和刷新内存表到磁盘)和在后台运行的压缩线程之间共享。写入空数据库时,可使用全磁盘带宽进行初始写入,但数据库越大,压缩所需的磁盘带宽就越多。
若是写入吞吐量很高,而且压缩没有仔细配置,压缩跟不上写入速率。在这种状况下,磁盘上未合并段的数量不断增长,直到磁盘空间用完,读取速度也会减慢,由于它们须要检查更多段文件。一般状况下,即便压缩没法跟上,基于SSTable的存储引擎也不会限制传入写入的速率,因此须要进行明确的监控来检测这种状况。
B树的一个优势是每一个键只存在于索引中的一个位置,而日志结构化的存储引擎可能在不一样的段中有相同键的多个副本。
这个方面使得B树在想要提供强大的事务语义的数据库中颇有吸引力:在许多关系数据库中,事务隔离是经过在键范围上使用锁来实现的,在B树索引中,这些锁能够直接链接到树。
B树在数据库体系结构中是很是根深蒂固的,为许多工做负载提供始终如一的良好性能,因此它们不可能很快就会消失。
在新的数据存储中,日志结构化索引变得愈来愈流行。没有快速和容易的规则来肯定哪一种类型的存储引擎对你的场景更好,因此值得进行一些经验上的测试。
阿里巴巴业务平台事业部招聘Java开发:
社招:两年以上开发经验,熟悉经常使用框架和中间件,对负责的业务有本身的思考
校招:2021年毕业的同窗,计算机相关专业
联系方式:微信 fkx0703