ClickHouse/TiDB/HBase/.../你学得完吗?其实每个开发应该都应该去理解这些
如今的软件开发其实大都是面向数据的开发,近些年,我们看到了数不胜数的各种存储,眼花缭乱。MySQL、Redis、Kafka、HBase、MongoDB、ClickHouse、Elasticsearch、Druid等等,甚至在计算引擎中也会有存储的出现。不禁感叹,组件千变万化!
是否疲于学习各种技术组件?
——够了!
听我一句劝,研究永恒的东西,才让我们立于不败之地。
不管任何的数据存储,它做的事情在最根本的角度,只有两个:
- 给它数据,就把数据存下来
- 随时可以把数据取出来
可能你会说,
那是不是我们只需要研究组件的API,能够把数据存下来、取出来就可以了?
No!!!!
虽然,大多数的开发人员不会自己去实现一个存储引擎,但当今的存储引擎像万花筒一样,RDB、NoSQL、全文检索、缓存....。到底该选择哪一种存储引擎呢?所以,我们很有必要去了解。
最简单的数据存储
下面我用Java编写一个最最简单的数据存储。
这里,我使用commons-cli以及commons-io来实现。
/**
* 最简单的数据库
*/
public class SimplestDB {
// 数据库文件名
private static final String DB_FILE_NAME = "./db/db.dat";
public static void main(String[] args) throws ParseException, IOException {
Options options = new Options();
options.addOption("set", true, "插入数据,id和value使用|分隔");
options.addOption("get", true, "获取数据");
options.addOption("help", false, "帮助");
CommandLineParser parser = new BasicParser();
CommandLine cmd = parser.parse(options, args);
if(cmd.hasOption("get")) {
String id = cmd.getOptionValue("get");
System.out.println(getData(id));
}
else if(cmd.hasOption("set")) {
String idAndValue = cmd.getOptionValue("set");
String[] split = idAndValue.split("\\|");
setData(split[0], split[1]);
}
else if(cmd.hasOption("help")) {
showHelp(options);
}
else {
showHelp(options);
}
}
private static void showHelp(Options options) {
HelpFormatter formatter = new HelpFormatter();
formatter.printHelp( "simple_db", options );
}
private static String getData(String id) throws IOException {
String content = FileUtils.readFileToString(new File(DB_FILE_NAME), "utf-8");
String[] lines = content.split("\n");
return Arrays.stream(lines)
.map(line -> line.split("\\|"))
.filter(pair -> pair[0].equals(id))
.map(pair -> String.format("id=%s,value=%s", pair[0], pair[1]))
.collect(Collectors.joining())
;
}
private static void setData(String id, String value) throws IOException {
FileUtils.writeStringToFile(new File(DB_FILE_NAME)
, String.format("%s|%s\n", id, value)
, StandardCharsets.UTF_8
, true
);
}
}
大家是不是觉得这很可笑?
但确实,它能够将数据存储下来,也可以根据id把数据取出来。
在数据量很少的情况下,它是能够胜任工作的。
但如果数据量很大,getData的查询性能是很低下的。因为它每次都要将文件读取出来,然后逐行扫描。它的时间复杂度是:O(n)。
要让查询效率,数据存储通常会使用索引技术来优化查询。
索引,是通过保留一些额外的元数据,通过这些元数据来快速帮助我们定位数据。
那是不是索引越完善越好呢?
不然!
维护索引需要有额外的开销,每次写入操作,都需要更新索引。所以,它会让写入效率下降。
所以,存储系统需要权衡,需要精心选择、设计索引。而不是把所有的内容都做索引。
Hash索引
Hash与文件offset
我们前面说实现的数据存储,其实是一种基于key-value的数据存储。每次存储或者查询时,都需要提供一个key(上述是id)。看起来,它非常像哈希表。我们在Java开发中,也经常使用HashMap,而我们所经常使用的HashMap中的数据都是在内存中存储着。
数据既然能在内存中存储,是不是也可以在磁盘上存储呢?
答案是肯定的,内存是一种存储介质,磁盘也是一种存储介质,为何不可呢。
我们所存储的文本文件,都有偏移量的概念(其实,我们在文件操作的时候,很少会关注它)。
写入数据时,将key与文件偏移量的映射保存下来。
读取数据时,可以将每个key通过hash,然后映射到文件的偏移量。然后直接从偏移量位置,把数据快速读取出来。
为了提升效率,将key与文件偏移量的映射加载到内存中(In-memory)。
segment存储与合并
我们之前的实现,会不断地追加到一个文件中。针对同一个key,可以会存储多次。
1|this is a test
1|test
1|test123
...
随着写入的数据量越来越大,这个文件也将变得巨大无比。
我们需要想办法来节省一些空间。
比较好的做法时,当文件大小达到一定大小时,或者写入的数据条数超过一定大小时,可以新生成一个segment文件。并定期将segment文件进行合并处理。例如,针对上述例子,我们可以只存储一份数据。
1|test123
看一下图例:
假如以下是第一个数据文件(segment 1)
1:hadoop | 2:yarn | 3:hdfs | 2:spark | 2:hadoop |
---|---|---|---|---|
3:flink | 3:kylin | 4:hudi | 4:iceberg | 1:hive |
以下是第二个数据文件(segment 2)
2:hadoop | 2:hdfs | 2:spark | 2:flink | 3:hadoop |
---|---|---|---|---|
3:hive | 3:postgres | 4:datalake | 4:presto | 1:parquet |
合并(Compaction - Merg后)
可以看出来,合并后,明显数据压缩了很多。
这种做法可以在很多引擎中看到它的身影。
一些重要问题
-
文件格式
如果我们用纯文本(CSV)格式存储数据,它的效率并不是很高。而使用二进制方式存储会更快。
-
删除数据
当要删除数据时,不能直接删除,因为直接删除会有大量的磁盘IO。而是设定一个删除标记,在进行segment 合并时,再删除。
-
容错
如果数据库程序突然crash,那么保存在内存中的映射都将丢失。我们需要重新读取segment,构建出来Hash Mapping。但如果segment很大,将会需要很长时间的恢复动作,重启会让人很痛苦。
-
数据损坏
当在写入数据时,数据库突然宕机。此时,数据才写了一半。需要对文件内容进行校验和,并忽略掉损坏的数据。
-
并发控制
控制同时只有一个线程能够写入到segment。但可以由多个线程并行读取。
Append-only log
这种设计其实就是不断地往segment中追加内容,上述的操作IO都是顺序执行的。如果需要更新数据,那么就会涉及到随机写入。而顺序写入要比随机写入快得很多。
另外,如果log是追加的,错误恢复起来也会容易很多。
Hash索引的限制
Hash映射需要存储在内存中,而且Hash映射的数据量不能太大。
那如果数据量真的很大怎么办呢?
将映射存储在磁盘上呗!但考虑以下,如果每次都从磁盘读取,这会有大量的磁盘随机IO,降低系统效率。
基于范围的查询,效率低下。例如:我们想要查询从1-10的数据。这种操作,必须要将整个Hash Mapping遍历一遍。因为数据并没有顺序存储下来。
排序表和LSM树
排序表
之前在讲解Hash索引时,我们提到了用segment存储数据,这种方式是基于日志的存储方式,是以Append-only方式存储的。
而且,数据必须都是以key-value形式存储的。
注意!
这些数据都是以出现的顺序存储的。
谁先到,就先写入谁。
因为Hash Index是通过key的hash值来映射文件的offset,
所以,在实际数据存储时,谁先存储,谁后存储。
无所谓!
SSTable是Sorted String Table的缩写,我们这里就把它称为排序表吧!
相比于之前segment存储,它需要确保所有存入到segment文件的key都有字符串有序的。
慢着!
如果要确保key有序,那岂不是随机存储吗?性能不是会大打折扣呢?
问得很好!这个我们在下个小节来聊!
先来看看SSTable的好处,这样会让我们更有动力去研究排序表。
1、因为数据是有序的,所以合并的效率特别高
我把《算法导论》中归并排序的merge实现放在此处。有兴趣的朋友可以看下。 |
2、因为数据是有序的,可以不用将索引数据全部都存储在索引中。也就是存储一部分索引就可以了(稀疏索引)。
示意图 |
大家可以看到,上面只存储了部分的键值。
如果要查询3,能查到吗?
有办法!
因为所有key都是有序存储的,虽然我们查询不到3,但可以找到3是处于1-4之间,我们只需要搜索偏移量200-400之间的数据就可以了。通过这种方式,一样可以很快地把数据查询出来。
这种方式可以很大程度上减少内存中的索引值。
3、基于第2点好处,对key进行寻址时,都需要去扫描一定范围的偏移量。那么可以对这个范围内的数据放到一个组中,然后对该组进行压缩。再让key对应的文件offset指向压缩后的组开始偏移量。这样可以大大节省磁盘开销、提升传输效率。
组压缩效过更好! |
构建和维护排序表
因为需要将key值在segment中以有序的方式存储,
但我们知道,如果每次插入一条数据都要去操作磁盘,这对于数据存储引擎是无法接受的。会对效率大打折扣!
写磁盘每次都是一次随机写入!
但有个更机智的玩法!
写内存!
在内存中完成所有有序写操作!
在内存中可以维护一个有序的数据结构,然后保证数据的写入。
我们马上想到了——跳表、红黑树、AVL树等等。
这些结构可以任意地插入元素,且始终保证结构是有序的。
写入排序表操作
假设内存中以红黑树实现,当写入到排序表时:
- 将新写入的元素新增到红黑树中。
- 当红黑树的内存大小达到某个阈值时,将红黑树中的数据刷入磁盘。——此时,数据都是顺序写入的
读取数据操作
- 先从内存中的红黑树中读取数据
- 如果没有找到,再从磁盘segment中检索数据
合并操作
为了提升磁盘利用效率,在后台运行线程不断合并segment。
排序表的问题
上面的排序表有效地解决了Hash Index的问题,
是不是一切都OK了呢?
NO!
如果数据存储引擎崩溃,存储在内存中的红黑树就会彻底丢失!!!!
如何解决这个问题?
LSM树
为了解决排序表丢失数据的问题,必须要保证在红黑树内存中的数据要进行持久化!
也就是写磁盘!
灵魂发问时间!!!
什么时候写磁盘?在写内存之前,还是之后?
=> 当然是之前了!必须在写内存之前,把数据持久化才不会丢!
写磁盘不就速度慢了吗?
=> 确实会比直接写内存慢。但想想写的磁盘是顺序写还是随机写?
呃...嗯....因为红黑树要保证key有序,当然是随机写了?
=> 错!再问你个问题,当前写日志的目的是什么?
呃...嗯...是解决排序表数据丢失问题?
=> 对!再问你,处理数据丢失需要确保数据有序吗?
呃...嗯...好像不需要....
=> 哈哈!没错,因为只是出现故障时,将数据红黑树恢复出来。所以,我们只需要从崩溃的那一刻,回放容错日志(预写日志)就可以了!
这就是LSM树!
了解一些存储引擎的朋友,一定对LSM树不会感到陌生!
HBase、Cassandra、RocksDB、LevelDB其实都是基于LSM树的存储引擎。
Elasticsearch和Solr底层都是基于Luence来存储数据,而Lucene的词条字典也是采用的类似的方法存储数据。
LSM树的缩写为Log-Structured Merge-Tree。而基于LSM树结构的存储引擎通常称为LSM引擎。
B+树索引
介绍
其实,目前的数据存储引擎,B树索引应用最为广泛。
数据来源于DBEngines。 |
绝大多数关系型数据库、甚至一些非关系型数据库都在使用B树索引。
类似于我们前面介绍的排序表,B+树也是按照key保证有序。因为要支持范围查询嘛!
是不是觉得B树和之前的排序表很像啊?
错!B+树的设计理念和LSM树有着完全不同的设计理念!
之前,我们说探讨的日志结构的树是分解为可变大小的segment存储,一般一个segment至少几MB,而B+树将数据库分为固定大小的块(Block)或者页(Page),一般为4KB,优势会更大些,然后一次读取一页。
这种设计更贴近于操作系统底层,因为磁盘也是存储在固定大小的块中。
每个页都有自己唯一的地址,一个页可以引用其他页,就像指针一样。通过这种方式,可以构建出来一颗树。这棵树需要有一个页称为B+树的root。每个页都包含了几个key,和对子页的引用。
而检索某个key值,其实就是B树搜索的过程。
大家可以去学习下B+树的检索过程。 |
更新操作
搜索key,并找到其叶子节点所在的页,修改叶子节点的值,并将该页写回磁盘。
此时,所有应用该页的数据都将立刻生效。
添加操作
搜索key,找到key对应范围的页,并添加。如果页的空间超过阈值,则拆分成两个页,继续写入。
为了保证搜索效率,B+树不能太高,一般是3-4的深度。
而每个页面说引用的页面可以是100个以上。
B+树可靠性
与LSM树不一样的是,B+树索引是以较小的页存储的。所以每次写入,都会用新的数据覆盖之前的页。它可以确保数据是完整的,也就是其他应用该页都可以更新。而LSM索引是Append-Only。
这个操作是有成本的!
因为每次覆盖都需要将磁盘的磁头移动到对应的位置。
而如果一个页写满之后,还需要将一个页分割为两个页,然后再更新父页。
如果这期间出现故障,将会导致索引数据丢失或者损坏!
那损坏后如何恢复呢?
预写日志啊!
很聪明!
学习过LSM树,我们已经有经验了!
数据库一般称之为redo log。
每次B+树的修改,都需要写redo log,这样数据库崩溃之后,还可以通过redo log将数据恢复出来。
是不是以为这就完了?
并没有!
还需要考虑并发的问题。
如果多个线程要同时写入B+树,需要确保数据是一致的!
所以,我们常听说的锁就出现了!
最后,对比下LSM树和B+树索引。
LSM其特性决定了,它写入的速度是很快的,因为它都是直接写入的内存结构,而无需刷盘。但读取数据通常就不如B+树了,因为LSM树得在几种数据结构、以及不同层次的结构中扫描查询才行。