H2的存储引擎MVStore剖析(1)——MVStore初始化

2,864 阅读5分钟

前言:

​ 偶然机会下接触到H2数据库,了解到其支持内存、文件模式,能够运行在内嵌模式,服务器模式和集群模式。H2数据库麻雀虽小但五脏俱全,支持事务,MVCC等。回忆起自己读书的时候一直想了解一个数据库具体的运行原理,想了解它是怎么把一个sql从解释到执行的过程。无奈时间有限水平不够便搁置了,直到看到这个H2便决定从它开始入手,这篇文章记录的是关于H2数据库的存储引擎部分MVStore的一点介绍(内容可参考官网,结合了我自己的一点理解)顺便给自己整理下相关知识。水平不够,欢迎各位指正。

一、测试方法用例

​ Oracle的数据存储有表空间、段、区、块、数据文件;MySQL InnoDB的存储管理也类似,但是MySQL增加了一个共享表空间和独立表空间的概念。

跟Mysql,Oracle等相似,H2的储存结构其实也是分了层次的,分别为:file,chunk,page,存储的基本单位是block(一般是4k个字节)。flie中包含了多个chunk,每次有新版本的写入都会有一个chunk,一个chunk由多个block组成。h2用的是一种copy on write btree的方法来,每次修改btree都会得到一个新的root page写在这个chunk中,每个事务只要保存好自己的root page就可以。

150104113477241.png

​ Mysql InnoDB存储结构

​ 在阅读H2源码的时候,自上而下和自下而上的方法都有用到。刚开始看到源码的时候无从下手,于是便先整体了解下它是怎么启动,运行,执行sql的。有了大概了解之后在debug进去一部分一部分代码自下而上地了解各模块是怎么组织运作的。另外,UT是个好东西。H2源码中包含了很多单元测试代码。测试文件里面有很多单元测试,每个测试方法粒度都比较细,从方法名就知道是测了什么功能。我就从其中一个叫做testExample()的方法入手,逐步梳理MVStore的逻辑。有兴趣阅读的伙伴建议也可以从这个测试方法入手,减少走弯路。

image-20210818232517731.png

H2源码中的单元测试

image-20210818232936104.png

H2源码中的单元测试

testExample()方法

private void testExample() {
    String fileName = getBaseDir() + "/" + getTestName();
    FileUtils.delete(fileName);

    // open the store (in-memory if fileName is null)
    try (MVStore s = MVStore.open(fileName)) {

        // create/get the map named "data"
        MVMap<Integer, String> map = s.openMap("data");

        // add and read some data
        map.put(1, "Hello World");
        // System.out.println(map.get(1));
    }
    try (MVStore s = MVStore.open(fileName)) {
        MVMap<Integer, String> map = s.openMap("data");
        assertEquals("Hello World", map.get(1));
    }
}

二 、存储文件形式介绍

​ H2数据库的数据都存储在一个文件中。该文件为了安全包含了两个文件头还有一系列的chunks。每一个文件头都占用了一个block,一个block是4096字节。每一个chunk至少一个block,但通常是200个blocks或者更多。数据以log structured storage的形式存储在chunks中。每一个版本(version)都会有一个chunk

H2存储文件的形式会是这样:

[ file header 1 ] [ file header 2 ] [ chunk ] [ chunk ] ... [ chunk ]

每一个chunk包含了若干个B+树的page。比如下面的例子,就会有两个chunks。

MVStore s = MVStore.open(fileName);
MVMap<Integer, String> map = s.openMap("data");
for (int i = 0; i < 400; i++) {
    map.put(i, "Hello");
}
s.commit();
for (int i = 0; i < 100; i++) {
    map.put(i, "Hi");
}
s.commit();
s.close();
Chunk 1:

-Page 1 : 根节点,指向两个子节点page 2和page 3

-Page 2: 叶结点,包含了140个元素(键0 ~键139)

-Page 3: 叶结点,包含了260个元素(键140~键399)

Chunk 2:

-Page 4: 根节点,指向两个子节点page 5 和 page 3

-Page 5: 叶结点,包含了140个元素(键0~键139)

这说明了每一个chunk包含了每个版本的变化:新版本的那一页还有它的根节点。像这里的例子,Page2被改变了就会有新的一个Page,然后它的父节点一直到根节点的那些页也会有新版本的Page

image-20210819232721151.png

​ 第一次commit

image-20210819234009093.png

​ 第二次commit

三、文件头

存储文件中有两个文件头,两个文件头内容都一样。当文件头被更新的时候,会出现“部分失败”的情况,这样的话其中一个文件头就会被破坏。所以第二个文件头的作用就是当两个文件头都被成功更新(被称为“原地更新”)才算成功更新。文件头的形式如下:

H:2,block:2,blockSize:1000,chunk:7,created:1441235ef73,format:1,version:7,fletcher:3044e6cc

数据是一种“键值对”的形式,其中“值”是十六进制形式的数字。形式如下:

  • H: "H:2"表示的是H2数据库

  • block: 其中一块最新的chunks开始的block位置(不需要是最新的chuncks) ----没太搞懂这个意思

  • blockSize:文件的大小(以字节为单位),当前是十六进制的1000, 亦即十进制的4096。正好和磁盘扇区的大小一致。

  • chunk:chunk id。通常和版本号一致。但是chunk id有可能回滚为0,但是版本号不会。

  • created:文件创建时间(从1970到当前的毫秒数)

  • format:文件格式号,当前为1

  • version:chunk的版本号

  • fletcher:弗莱切检验和

当文件被打开的时候,两个文件头会被读入并且检验和要用来验证。如果两个文件头都符合,那么有着更新版本号的文件头会被使用。最新版本号的chunk就能够被知道(具体是怎么知道的下面会讲),剩下的元数据(metadata)能够从chunk中知道。如果头文件中没有存储chunk id,block,和 版本号。那么最新的chunk 会从文件中的最后一个chunk开始找

四、chunk 格式

每个版本都会有chunk。每一个chunk包含了一个头,在这个版本被修改的页(page),还有一个尾。页(page)包含了表(map)的实际数据。在一个chunk中的页存储在header的后面(right after)。一个chunk的大小是一个block的大小的倍数。尾部存储在chunk的最后128字节中。

[ header ] [ page ] [ page ] ... [ page ] [ footer ]

尾(footer)能被用来校验chunk是否被完整地写完。每一个写操作都会有一个chunk,并且能够找到文件中的最后一个chunk的起始位置。chunk的头部和尾部包含下面的数据:

chunk:1,block:2,len:1,map:6,max:1c0,next:3,pages:2,root:4000004f8c,time:1fc,version:1
chunk:1,block:2,version:1,fletcher:aed9a4f6
  • chunk:chunk id
  • block:chunk中的第一个block位置(要乘以block的大小来得到在文件中的位置)
  • len:chunk的大小,用block的数量来表示
  • map:最新的map的id,当一个map被创建的时候这个id就会加一
  • max:所有最大页的大小之和(没搞懂)
  • next:下一个chunk的预测起始位置
  • pages:chunk中的页的数量
  • root:元数据(metadata)的根页的位置。
  • time:chunk被写入时的时间。以文件被写入后的毫秒来算
  • version:这个chunk表示的版本号
  • fletcher:校验和

Chunks永远不会被原地更新,每一个chunk包含了在那个版本中被修改的页(每一个版本都有一个页),还有这些页的父亲节点页,直至到根页。如果一个map中的元素被修改,删除或添加,那么相应的页就会被复制,修改和存储在下一个chunk中,并且旧的chunk中的活页(live pages)将会减少。这种机制被称为copy-on-write,与Btrfs文件系统的工作方式相似。没有live pages的Chunks会被标记为free, 因此chunk能够被重复利用。因为不是所有chunk都是相同的大小,所以会有一些空闲(free)的chunks在某个chunk的前面。在一个空闲的(stackoverflow.com/questions/1…

最新那个chunk在打开h2的持久化文件的时候确定下来的方法:文件头中包含了一个最近的chunk的位置,这个chunk不一定是最新的chunk。这样子是为了减少文件头更新的次数。当打开文件的时候,文件头还有最新一个chunk的尾将会被读入。如果第一次读入的那个chunk有一个next的指针,next指针指向的chunk也会被读入。直到最新的那个chunk被读入。

这段话有点费解,可以直接看源码中的实现,稍后通过源码看是如何读入最新版本的chunk的。其中一个文件头长这样

image-20210825232045447.png

正如前面所说,前面两个block是用来放存储文件的header的

image-20210831215238857.png

打开文件头的时候,首先会去读取chunk的头和尾。其中参数中的block和chunkId都是在文件头上有。

image-20210827233415016.png

image-20210827234431016.png

image-20210831215809482.png

读取Chunk的尾

image-20210831223711142.png

chunk的头和尾作校验

image-20210831223924158.png

下面讲下打开一个MVStore的过程如下

MVStore s = MVStore.open(fileName);
  1. 先去读取文件头。
  2. 读到第一个文件头后根据文件头中block字段去文件中的对应位置(block * bolck_size)。读取对应的Chunk头

下面是文件头和chunk头的例子

image-20210901223022039.png ​ file头

image-20210901222921709.png

​ chunk

// 参数block由file文件头中的block字段传入。参数expected同样也是文件头中的chunk字段
private Chunk readChunkHeaderAndFooter(long block, int expectedId) {
    Chunk header = readChunkHeaderOptionally(block, expectedId);
    if (header != null) {
        Chunk footer = readChunkFooter(block + header.len);
        if (footer == null || footer.id != expectedId || footer.block != header.block) {
            return null;
        }
    }
    return header;
}

//
private Chunk readChunkHeaderOptionally(long block, int expectedId) {
        Chunk chunk = readChunkHeaderOptionally(block);
        return chunk == null || chunk.id != expectedId ? null : chunk;
    }
    
    
    private Chunk readChunkHeaderOptionally(long block) {
        try {
            Chunk chunk = readChunkHeader(block);
            return chunk.block != block ? null : chunk;
        } catch (Exception ignore) {
            return null;
        }
    }
    
     private Chunk readChunkHeader(long block) {
        long p = block * BLOCK_SIZE;
        ByteBuffer buff = fileStore.readFully(p, Chunk.MAX_HEADER_LENGTH);
        return Chunk.readChunkHeader(buff, p);
    }
    
    
      static Chunk readChunkHeader(ByteBuffer buff, long start) {
        int pos = buff.position();
        byte[] data = new byte[Math.min(buff.remaining(), MAX_HEADER_LENGTH)];
        buff.get(data);
        try {
            for (int i = 0; i < data.length; i++) {
                if (data[i] == '\n') {
                    // set the position to the start of the first page
                    buff.position(pos + i + 1);
                    String s = new String(data, 0, i, StandardCharsets.ISO_8859_1).trim();
                    return fromString(s);
                }
            }
        } catch (Exception e) {
            // there could be various reasons
            throw DataUtils.newMVStoreException(
                    DataUtils.ERROR_FILE_CORRUPT,
                    "File corrupt reading chunk at position {0}", start, e);
        }
        throw DataUtils.newMVStoreException(
                DataUtils.ERROR_FILE_CORRUPT,
                "File corrupt reading chunk at position {0}", start);
    }
    
    
  1. 读取chunk的尾做检验

    private Chunk readChunkHeaderAndFooter(long block, int expectedId) {
            Chunk header = readChunkHeaderOptionally(block, expectedId);
            if (header != null) {
                Chunk footer = readChunkFooter(block + header.len);
                if (footer == null || footer.id != expectedId || footer.block != header.block) {
                    return null;
                }
            }
            return header;
        }
        
    
  2. 读取第二个文件头。如果其中的version字段大于第一个文件头读取出来的chunk的version,则继续像上面步骤一样去读取chunk

回顾前面说的

打开文件的时候,文件头还有最新一个chunk的尾将会被读入。如果第一次读入的那个chunk有一个next的指针,next指针指向的chunk也会被读入。直到最新的那个chunk被读入。

看下面,在while循环里,会把当前的chunk(next指向的下一个chunk读入),如果下一个chunk的版本小于等于当前版本就结束。由此newest就指向了最新版本的那个chunk。

image-20210902221302944.png

  1. 上一部分确定了最新的chunk (newest)之后,就到了下面这里。
if (assumeCleanShutdown) {
    // quickly check latest 20 chunks referenced in meta table
    Queue<Chunk> chunksToVerify = new PriorityQueue<>(20, Collections.reverseOrder(chunkComparator));
    try {
        setLastChunk(newest);
        // load the chunk metadata: although meta's root page resides in the lastChunk,
        // traversing meta map might recursively load another chunk(s)
        Cursor<String, String> cursor = layout.cursor(DataUtils.META_CHUNK);
        while (cursor.hasNext() && cursor.next().startsWith(DataUtils.META_CHUNK)) {
            Chunk c = Chunk.fromString(cursor.getValue());
            assert c.version <= currentVersion;
            // might be there already, due to meta traversal
            // see readPage() ... getChunkIfFound()
            chunks.putIfAbsent(c.id, c);
            chunksToVerify.offer(c);
            if (chunksToVerify.size() == 20) {
                chunksToVerify.poll();
            }
        }
        Chunk c;
        while (assumeCleanShutdown && (c = chunksToVerify.poll()) != null) {
            Chunk test = readChunkHeaderAndFooter(c.block, c.id);
            assumeCleanShutdown = test != null;
            if (assumeCleanShutdown) {
                validChunksByLocation.put(test.block, test);
            }
        }
    } catch(MVStoreException ignored) {
        assumeCleanShutdown = false;
    }
}

看注释:quickly check latest 20 chunks referenced in meta table

可以看出来是为了读取最新的20个chunk。其中用到了一个优先队列来对chunk排序。但是这里是按照变量chunkComparator的倒序来排序的。chunkComparator的定义如下:

Comparator<Chunk> chunkComparator = (one, two) -> {
    int result = Long.compare(two.version, one.version);
    if (result == 0) {
        // out of two copies of the same chunk we prefer the one
        // close to the beginning of file (presumably later version)
        result = Long.compare(one.block, two.block);
    }
    return result;
};

首先比较两个chunk的版本,谁的版本大就排在前面。如果版本相同,则比较block的大小,谁的block小就排在前面。于是乎,它的倒叙就是谁的版本小就先排在前面,谁的block大就排在前面。

注意到方法:

 setLastChunk(newest);

由上一步确定出来的最新的chunk会用来初始化layout这个MVMap。具体细节这里先不介绍,等后面讲了Page的结构会容易理解一点

image-20210908222603207.png

经过setLastChunk之后,layout这个MVMap将会有类似于下面的这种结构

image-20210908223159745.png

先看下面这一步:

Cursor<String, String> cursor = layout.cursor(DataUtils.META_CHUNK);
while (cursor.hasNext() && cursor.next().startsWith(DataUtils.META_CHUNK)) {
    Chunk c = Chunk.fromString(cursor.getValue());
    assert c.version <= currentVersion;
    // might be there already, due to meta traversal
    // see readPage() ... getChunkIfFound()
    chunks.putIfAbsent(c.id, c);
    chunksToVerify.offer(c);
    if (chunksToVerify.size() == 20) {
        chunksToVerify.poll();
    }
}

DataUtils.META_CHUNK是字符串常量”chunk.“ 。由此可以猜出来,这里的while循环是为了把layout的"chunk."开头的键都读出来,放到chunks这个map和优先队列chunksToVerify中。并且让优先队列数量保存在最多19个。

接着是下面的循环:

依次从优先队列中读取出chunk,放到validChunksByLocation这个map中

while (assumeCleanShutdown && (c = chunksToVerify.poll()) != null) {
    Chunk test = readChunkHeaderAndFooter(c.block, c.id);
    assumeCleanShutdown = test != null;
    if (assumeCleanShutdown) {
        validChunksByLocation.put(test.block, test);
    }
}

6.最后一步,是一些清理性的工作

fileStore.clear();
// build the free space list
for (Chunk c : chunks.values()) {
    if (c.isSaved()) {
        long start = c.block * BLOCK_SIZE;
        int length = c.len * BLOCK_SIZE;
        fileStore.markUsed(start, length);
    }
    if (!c.isLive()) {
        deadChunks.offer(c);
    }
}