H2 存储内核解析

2023-03-31 15:19:26 浏览数 (2)

概述

MVStore是“多版本存储”(Multi-Version Store)的缩写,是一种持久化的基于日志结构的键值存储。它是H2的默认存储引擎,支持SQL、JDBC、事务、MVCC等。但也可以直接在应用程序中使用,而不使用JDBC或SQL。

以下是MVStore的特点:

  1. 内部包含多个Map,可以使用Java中的java.util.Map接口访问。
  2. 支持基于文件的持久化和内存操作,旨在快速、简单易用且小巧。
  3. 支持并发读写操作和事务(包括并发事务和两阶段提交)。
  4. 支持插件式数据类型和序列化、插件式存储(文件、离堆内存)、插件式Map实现(B树、R树、并发B树)、BLOB存储和文件系统抽象以支持加密文件和zip文件。

例子

代码语言:java复制
        String fileName = "/Users/chenfei/temp/my_store.db";
        // 
        FileUtils.delete(fileName);

        MVStore.Builder builder = new MVStore.Builder();
        builder.fileName(fileName);
        builder.pageSplitSize(1000);
        MVStore store = builder.open();
        MVMap<Integer, String> m = store.openMap("data");
        int count = 2;
        for (int i = 0; i < count; i  ) {
            m.put(i, "hello "   i);
            System.out.println(m.get(i));
        }
        store.commit();
        store.close();

文件格式(File)

文件(File)包含两个文件头和若干个数据块(Chunk)。每个数据块(Chunk)至少为一个块(Block),但通常为200个块(Block)或更多。数据以日志结构存储的形式存储在数据块(Chunk)中。每个版本(Version)都有一个数据块(Chunk)。

代码语言:java复制
[ file header 1 ] [ file header 2 ] [ chunk ] [ chunk ] ... [ chunk ]

文件头 (File Header)

文件头 (File Header):为了安全起见,文件头有两个,通常包含完全相同的数据。以便在写入期间部分失败时不会破坏文件头。只有文件头才支持原地更新"in-place update"。

文件头包含以下信息:

代码语言:java复制
H:2,block:2,blockSize:1000,chunk:7,created:1441235ef73,format:1,version:7,fletcher:3044e6cc

H

“H:2”代表 H2 数据库

块(block)

最新(不必是最新的)数据块(chunks)的起始块(block)号

块大小(blockSize)

文件块的块大小;当前始终为十六进制1000,即十进制4096,以匹配现代硬盘的磁盘扇区长度。

数据块(chunk)

数据块id,通常与版本号相同;但是,数据块id可能会回滚到0,而版本不会。

created

创建文件时距1970年以来的毫秒数

format

文件格式版本(当前为1)

version

文件版本

fletcher

校验和

在打开文件时,会读取两个文件头并验证校验和。如果两个文件头都有效,则使用版本更新的文件头。然后检测具有最新版本的数据块(chunk),并从其中读取其余元数据。如果文件头中没有数据块 ID,块(block)和版本,则最新数据块(chunk)的查找将从文件中的最后一个数据块(chunk)开始。

数据块格式(Chunk Format)

每个版本都有一个数据块(chunk),每个数据块(chunk)由一个 header、在此版本中修改的页面(page)和 footer组成。页面(page)包含以 map 形式的实际数据。数据块(chunk)中的页面(page)在 header 后紧挨着存储(未对齐)。数据块(chunk)的大小是块(block)大小的倍数。footer 存储在数据块(chunk)的最后128字节中。

代码语言:java复制
[ header ] [ page ] [ page ] ... [ page ] [ footer ]

chunk foot 用于验证chunk是否完整写入,以及查找文件中最后一个chunk的起始位置。chunk中的页面 (page) 存储着 map 形式的实际数据。chunk中的页面 (page) 存储在 header 的后面,相邻存放。chunk 的大小是 block 大小的倍数。每个 chunk 只包含在该版本中被修改的页面 (page) ,以及这些页面 (page) 的所有父节点,递归到根页面 (page) 。如果map中的条目被更改、删除或添加,则会复制相应的页面 (page)并在下一个chunk中存储修改后的页面 (page)。没有活页面 (page)的chunk被标记为空,以便更近期的chunk可以重复使用它们的空间。

chunk header

代码语言:java复制
chunk:1,block:2,len:1,map:6,max:1c0,next:3,pages:2,root:4000004f8c,time:1fc,version:1

chunk footer

代码语言:java复制
chunk:1,block:2,version:1,fletcher:aed9a4f6

chunk

chunk的ID

block

chunk的第一个block的编号(乘以block大小可以得到文件中的位置)

len

chunk包含的页面 (page)数

map

最新map的ID,每次创建新map时递增

max

chunk中页面(page)的最大数量

pages

chunk中页面(page)的数量

next

下一个chunk的ID

root

元数据根页面(page)的位置

time

chunk被写入的时间,从文件创建后的毫秒数开始计算

version

chunk的版本号

每个版本都有一个 chunk,chunk 永远不会被原地更新。每个 chunk 包含了在该版本中更改的页面(page)(每个版本有一个 chunk),以及递归地包含了所有这些页面(page)的父节点,一直到根页面(page)。如果 map 中的条目被更改、删除或添加,则相应的页面(page)将被复制、修改并存储在下一个 chunk 中,旧 chunk 中的活动页面(page)数将减少。这种机制被称为写时复制,类似于 Btrfs 文件系统的工作方式。那些没有活动页面的 chunks 被标记为空闲状态,因此空间可以被最近的 chunks 重复使用。因为不是所有的 chunk 都是相同大小的,所以在某段时间内,一些 chunk 前面可能会有一些空闲 blocks(直到写入小块或压缩块)。默认情况下,在空闲 blocks 被覆盖之前会有45秒的延迟,以确保新版本首先被持久化.

如何在打开存储时找到最新的 chunk:文件头包含最近chunk的位置,但不总是最新的chunk。这是为了减少文件头更新的次数。打开文件后,读取文件头和最后一个chunk的块尾。从这些候选chunk中,读取最新chunk的头。如果它包含一个“next”指针,则也读取这些chunk的头和尾。如果它是一个新的有效chunk,则重复这个过程,直到找到最新的chunk。在写入chunk之前,基于下一个chunk与当前chunk相同的假设,预测下一个chunk的位置。当写入下一个chunk并且前一个预测是不正确的时,文件头也会更新。在任何情况下,如果下一个链的跳数超过20次,则文件头将被更新。

页面格式(Page Format)

每个Map都是一棵B树,Map数据存储在(B树)页面中。页面(page)分为叶子页面和内部节点页面。叶子页面包含Map的键值对,而内部节点只包含键和指向叶子页面的指针。树的根节点可以是叶子页面或内部节点。不同于文件头,数据块 header和 foot 的数据,页面数据是存储为字节数组的,其中包含长整型(8个字节)、整型(4个字节)、短整型(2个字节)和可变大小的整型和长整型(1到5/10个字节)。

页面格式的参数

length

页面的字节数

checksum

计算方法为 chunk id 异或 page 在 chunk 中的偏移量 offset 异或 page 大小。

mapId

该页面所属 map 的 ID

len

该页面中 key 的数量。

type (byte)

页面的类型。叶节点:0。内部节点:1。

children

子节点位置 (long 类型数组;仅仅是内部节点)

childCounts

Child 页面的数量

keys

(字节数组)数组存储了该节点的所有键,类型取决于数据类型

values

(字节数组)(仅适用于叶子节点)存储了该节点的所有值,类型取决于数据类型

尽管文件格式不要求这样做,但页面(page)按以下顺序存储:对于每个映射,首先存储根页面(root page),然后是内部节点(如果有的话),然后是叶子页面(page)。metadata map 存储在chunk的末尾。

在MVStore中,页面(page)的指针以一个长整型的特殊格式存储:26位用于chunk ID,32位用于chunk 内偏移量,5位用于长度编码,1位用于页面(page)类型(叶节点或内部节点)。页面(page)类型被编码,以便在清除或删除映射时,不必读取叶节点页面(page)(内部节点必须读取,以便知道所有页面(page)的位置;但在典型的B树中,绝大多数页面(page)都是叶节点页面)。绝对文件位置不包括在内,以便可以在文件中移动chunk而无需更改页指针;只需要更改chunk元数据。 长度代码是从0到31的数字,其中0表示页面(page)的最大长度为32个字节,1表示48个字节,2表示64,3表示96,4表示128,5表示192,以此类推,直到31表示超过1 MB。这样,只需要一个读取操作即可读取页面(page)(除非是非常大的页面)。所有页面(page)的最大长度之和存储在chunk元数据中(字段“max”),当将页面标记为已删除时,会调整实时最大长度。这允许估计 block 内的可用空间量,以及可用页面的数量。

Metadata Map

在MVStore中,除了用户 map 之外,还有一个元数据map (metadata map),其中包含用户map的名称和位置以及chunk元数据。chunk的最后一页包含该元数据chunk的根页。该根页的确切位置存储在chunk头中。该页(直接或间接)指向所有其他map的根页。元数据map有一个名为 "data" 的map,它包含如下信息:

chunk.1

chunk 1 的元数据。这是与chunk头相同的数据,加上活动页面的数量和最大活动页面长度。

map.1

map 1的元数据。条目包括名称,创建版本和类型。

name.data

名为“data”的map的map ID。值为“1”

root.1

map 1 的根位置

setting.storeVersion

store版本(用户定义的值)

可以通过调用getMetaMap()方法来获取metadata

代码解析

生成 MVStore 对象

fileName 不存在的时,执行 writeStoreHeader(),存在的时候读取 readStoreHeader();

代码语言:java复制
        String fileName = "/Users/chenfei/temp/my_store.db";

        MVStore.Builder builder = new MVStore.Builder();
        builder.fileName(fileName);
        builder.pageSplitSize(1000);
        MVStore store = builder.open();

MVStore 对象创建成功后,它就包括了如下属性:

获取 meta map

通过 MVStore 对象

代码语言:java复制
MVMap<String, String> metaMap = store.getMetaMap();

获取第一个 chunk

在 readStoreHeader 方法中的 readChunkHeaderAndFooter(block); 会通过 setLastChunk 方法设置 chunk。

代码语言:java复制
    /**
    * 获取最新的 chunk
    */
    public Chunk setLastChunk(Chunk last) {
        chunks.clear();
        lastChunk = last;
        if (last == null) {
            // no valid chunk
            lastMapId.set(0);
            currentVersion = 0;
            lastStoredVersion = MVMap.INITIAL_VERSION;
            meta.setRootPos(0, MVMap.INITIAL_VERSION);
        } else {
            lastMapId.set(last.mapId);
            currentVersion = last.version;
            chunks.put(last.id, last);
            lastStoredVersion = currentVersion - 1;
            meta.setRootPos(last.metaRootPos, lastStoredVersion);
        }
        return last;
    }

获取 chunk 的根 page

代码语言:java复制
        String name = "my_data_1";
        MVMap.MapBuilder mapBuilder = new MVMap.Builder();
        int id = store.getMapId(name);
        MVMap map = store.getMap(id);
        if (map == null) {
            String configAsString = store.getMetaMap().get(MVMap.getMapKey(id));
            HashMap<String, Object> config;
            if (configAsString != null) {
                config = new HashMap<String, Object>(DataUtils.parseMap(configAsString));
            } else {
                config = new HashMap<>();
            }
            config.put("id", id);
            map = mapBuilder.create(store, config);
            long root = store.getRootPos(store.getMetaMap(), id);
            // 该方法执行的就是从文件中提取出 page 
            map.setRootPos(root, MVMap.INITIAL_VERSION);
            store.maps.put(id, map);
            
            // 提取出 page
            Page p = map.getRootPage();
            System.out.println(p);
        }

Page 类是操作数据的核心类

输入 Page 和要查询的索引,通过 tree 来查询结果

代码语言:java复制
    /**
     * Get the value for the given key, or null if not found.
     * Search is done in the tree rooted at given page.
     *
     * @param key the key
     * @param p the root page
     * @return the value, or null if not found
     */
    public static Object get(Page p, Object key) {
        while (true) {
            int index = p.binarySearch(key);
            if (p.isLeaf()) {
                return index >= 0 ? p.getValue(index) : null;
            } else if (index   < 0) {
                index = -index;
            }
            p = p.getChildPage(index);
        }
    }

插入 key-value 到叶子节点

代码语言:java复制
        public void insertLeaf(int index, Object key, Object value) {
            int keyCount = getKeyCount();
            insertKey(index, key);

            if(values != null) {
                Object[] newValues = createValueStorage(keyCount   1);
                DataUtils.copyWithGap(values, newValues, keyCount, index);
                values = newValues;
                setValueInternal(index, value);
                if (isPersistent()) {
                    addMemory(MEMORY_POINTER   map.getValueType().getMemory(value));
                }
            }
        }

到这里,读者基本上就了解了 h2 的存储内核了,这个还是比较简单,容易掌握和扩展的。

说明一点:有些朋友有疑问,为什么 DawnSql 选择 h2 的存储内核,而不是去重新做一个?这里主要是为了高用性!h2 作为成熟的数据库存储内核,已经在实际的项目中应用了多年,它是经得起考验的。如果新做存储内核,可能会给使用者带来高可用性上面的顾虑,所以我们再三权衡后选择更稳定可用性更高的方案。当然随着 DawnSql 的发展和根据企业方的要求,我们也可以对其进行修改和重构!

0 人点赞