添加链接
link之家
链接快照平台
  • 输入网页链接,自动生成快照
  • 标签化管理网页链接
相关文章推荐
淡定的可乐  ·  Excel VBA转置表格 - ...·  1 年前    · 
憨厚的企鹅  ·  pytorch OSError: ...·  1 年前    · 
酷酷的茴香  ·  android android11 ...·  1 年前    · 
没读研的便当  ·  vue3 buffer is not ...·  2 年前    · 

​ 偶然机会下接触到 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就可以。

​ Mysql InnoDB存储结构

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

H2源码中的单元测试

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

​ 第一次commit

​ 第二次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的。其中一个文件头长这样

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

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

    读取Chunk的尾

    chunk的头和尾作校验

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

    MVStore s = MVStore.open(fileName);
    
  • 先去读取文件头。
  • 读到第一个文件头后根据文件头中block字段去文件中的对应位置(block * bolck_size)。读取对应的Chunk头
  • 下面是文件头和chunk头的例子

    ​ file头

    ​ 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);
    

    读取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;
    

    读取第二个文件头。如果其中的version字段大于第一个文件头读取出来的chunk的version,则继续像上面步骤一样去读取chunk

    回顾前面说的

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

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

  • 上一部分确定了最新的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的结构会容易理解一点

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

    先看下面这一步:

    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);
    复制代码
  • 私信
  •