目录

  • 最简单的数据存储
  • Hash索引
  • Hash与文件offset
  • segment存储与合并
  • 一些重要问题
  • Append-only log
  • Hash索引的限制
  • 排序表和LSM树
  • 排序表
  • 构建和维护排序表
  • 排序表的问题
  • LSM树
  • B+树索引
  • 介绍
  • B+树可靠性

如今的软件开发其实大都是面向数据的开发,近些年,我们看到了数不胜数的各种存储,眼花缭乱。MySQL、Redis、Kafka、HBase、MongoDB、ClickHouse、Elasticsearch、Druid等等,甚至在计算引擎中也会有存储的出现。不禁感叹,组件千变万化!

是否疲于学习各种技术组件?

——够了!

听我一句劝,研究永恒的东西,才让我们立于不败之地。

不管任何的数据存储,它做的事情在最根本的角度,只有两个:

  1. 给它数据,就把数据存下来
  2. 随时可以把数据取出来

可能你会说,

那是不是我们只需要研究组件的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后)

clickhouse 与 hbase clickhouse与hbase区别?_数据结构

可以看出来,合并后,明显数据压缩了很多。

这种做法可以在很多引擎中看到它的身影。

一些重要问题

  1. 文件格式
    如果我们用纯文本(CSV)格式存储数据,它的效率并不是很高。而使用二进制方式存储会更快。
  2. 删除数据
    当要删除数据时,不能直接删除,因为直接删除会有大量的磁盘IO。而是设定一个删除标记,在进行segment 合并时,再删除。
  3. 容错
    如果数据库程序突然crash,那么保存在内存中的映射都将丢失。我们需要重新读取segment,构建出来Hash Mapping。但如果segment很大,将会需要很长时间的恢复动作,重启会让人很痛苦。
  4. 数据损坏
    当在写入数据时,数据库突然宕机。此时,数据才写了一半。需要对文件内容进行校验和,并忽略掉损坏的数据。
  5. 并发控制
    控制同时只有一个线程能够写入到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、因为数据是有序的,所以合并的效率特别高

clickhouse 与 hbase clickhouse与hbase区别?_红黑树_02

我把《算法导论》中归并排序的merge实现放在此处。有兴趣的朋友可以看下。

2、因为数据是有序的,可以不用将索引数据全部都存储在索引中。也就是存储一部分索引就可以了(稀疏索引)。

clickhouse 与 hbase clickhouse与hbase区别?_红黑树_03

示意图

大家可以看到,上面只存储了部分的键值。

如果要查询3,能查到吗?

有办法!

因为所有key都是有序存储的,虽然我们查询不到3,但可以找到3是处于1-4之间,我们只需要搜索偏移量200-400之间的数据就可以了。通过这种方式,一样可以很快地把数据查询出来。

这种方式可以很大程度上减少内存中的索引值。

3、基于第2点好处,对key进行寻址时,都需要去扫描一定范围的偏移量。那么可以对这个范围内的数据放到一个组中,然后对该组进行压缩。再让key对应的文件offset指向压缩后的组开始偏移量。这样可以大大节省磁盘开销、提升传输效率。

clickhouse 与 hbase clickhouse与hbase区别?_红黑树_04

组压缩效过更好!

构建和维护排序表

因为需要将key值在segment中以有序的方式存储,

但我们知道,如果每次插入一条数据都要去操作磁盘,这对于数据存储引擎是无法接受的。会对效率大打折扣!

写磁盘每次都是一次随机写入!

但有个更机智的玩法!

写内存!
在内存中完成所有有序写操作!

在内存中可以维护一个有序的数据结构,然后保证数据的写入。

我们马上想到了——跳表、红黑树、AVL树等等。

这些结构可以任意地插入元素,且始终保证结构是有序的。

写入排序表操作

假设内存中以红黑树实现,当写入到排序表时:

  1. 将新写入的元素新增到红黑树中。
  2. 当红黑树的内存大小达到某个阈值时,将红黑树中的数据刷入磁盘。——此时,数据都是顺序写入的

读取数据操作

  1. 先从内存中的红黑树中读取数据
  2. 如果没有找到,再从磁盘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树索引应用最为广泛。

clickhouse 与 hbase clickhouse与hbase区别?_红黑树_05

数据来源于DBEngines。

绝大多数关系型数据库、甚至一些非关系型数据库都在使用B树索引。

类似于我们前面介绍的排序表,B+树也是按照key保证有序。因为要支持范围查询嘛!

是不是觉得B树和之前的排序表很像啊?

错!B+树的设计理念和LSM树有着完全不同的设计理念!

之前,我们说探讨的日志结构的树是分解为可变大小的segment存储,一般一个segment至少几MB,而B+树将数据库分为固定大小的块(Block)或者页(Page),一般为4KB,优势会更大些,然后一次读取一页。

这种设计更贴近于操作系统底层,因为磁盘也是存储在固定大小的块中。

每个页都有自己唯一的地址,一个页可以引用其他页,就像指针一样。通过这种方式,可以构建出来一颗树。这棵树需要有一个页称为B+树的root。每个页都包含了几个key,和对子页的引用。

而检索某个key值,其实就是B树搜索的过程。

clickhouse 与 hbase clickhouse与hbase区别?_clickhouse 与 hbase_06

大家可以去学习下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树得在几种数据结构、以及不同层次的结构中扫描查询才行。