H2的MVStore
概述
MVStore 是一个持久化的、日志结构式的 KV 存储引擎。本计划旨在将其作为 H2 数据库的下一代存储子系统,但你也可以在不涉及 JDBC 或者 SQL 的应用中直接使用它。
- MVStore 代表多版本存储(Multi-Version Store)。
- 每一个 Store 包含大量的 Map,这些 Map 可以用
java.util.Map接口存取。 - 支持基于文件存储和基于内存的操作。
- 设计目标:更快、更简单易用、更小巧。
- 支持并发读写操作。
- 支持事务(包括并发事务与两阶段提交 2-Phase Commit)。
- 模块化设计:支持插拔式的数据类型定义、序列化实现;支持插拔式的存储载体(存到文件里、存到堆外内存);支持插拔式的映射实现(B-tree, R-tree, 当前默认使用 Concurrent B-tree);支持 BLOB 存储;提供文件系统层的抽象以支持文件加密与压缩。
示例代码
import org.h2.mvstore.*;
// open the store (in-memory if fileName is null)
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));
// close the store (this will persist changes)
s.close();下面的代码展示了如何使用这些工具构建 Store。
Store Builder
MVStore.Builder 提供了一个流畅优美的接口,可通过可选配置项构造 Store。
示例用法:
MVStore s = new MVStore.Builder().
fileName(fileName).
encryptionKey("007".toCharArray()).
compress().
open();可用选项列表如下:
autoCommitBufferSize: 写 buffer 的大小。autoCommitDisabled: 禁用自动 commit。backgroundExceptionHandler: 用于处理后台写入时产生的异常的处理器。cacheSize: 缓存大小,以 MB 为单位。compress: 是否采用 LZF 算法进行快速压缩。compressHigh: 是否采用 Deflate 算法进行慢速压缩。encryptionKey: 文件加密的 key。fileName: 基于文件存储时,用于存储的文件名。fileStore: 存储实现。pageSplitSize: Pages 的分割点。readOnly: 是否以只读形式打开存储文件。
R-Tree
MVRTreeMap 是一个用于快速空间查询的 R-Tree 实现,使用示例如下:
// create an in-memory store
MVStore s = MVStore.open(null);
// open an R-tree map
MVRTreeMap<String> r = s.openMap("data",
new MVRTreeMap.Builder<String>());
// add two key-value pairs
// the first value is the key id (to make the key unique)
// then the min x, max x, min y, max y
r.add(new SpatialKey(0, -3f, -2f, 2f, 3f), "left");
r.add(new SpatialKey(1, 3f, 4f, 4f, 5f), "right");
// iterate over the intersecting keys
Iterator<SpatialKey> it =
r.findIntersectingKeys(new SpatialKey(0, 0f, 9f, 3f, 6f));
for (SpatialKey k; it.hasNext();) {
k = it.next();
System.out.println(k + ": " + r.get(k));
}
s.close();默认维度是 2,可以通过 new MVRTreeMap.Builder<String>().dimensions(3) 设置不同的维度数。维度的取值最大值是 32,最小值是 1。
特性
Maps
每一个 Store 含有一组命名 Map。每个 Map 按 Key 存储,支持通用查找操作,比如查找第一个、查找最后一个、迭代部分或者全部的 Key 等。
也支持一些不太通用的操作:快速的按索引查找、高效的根据 Key 算出其索引(位置、index)。这意味着取中间的两个 Key 也是非常快的,也能快速统计某个范围内的 Key。迭代器支持快速跳过(fast skipping),这是因为在内部,每个 Map 都组织为计数 B+-tree(counted B+-tree)的形式。
在数据库侧,一个 Map 能像一张表一样使用:Map 的 Key 就是表的主键,Map 的值就是表的行。Map 也能代表索引:Map 的 Key 相当于索引的 Key,Map 的值相当于表的主键(针对那种非联合索引,Map 的 Key 需含有主键字段)。
版本
版本是指在指定时间的所有 Map 中所有数据的一个快照。创建快照的速度很快:仅仅复制上一个快照后发生改变的 Page。这种行为通常也叫作 COW(Copy On Write)。旧版本变成只读的,支持回滚到一个旧版本。
下面的示例代码展示了如何创建一个 Store,打开一个 Map,增加一些数据和存取当前的以及旧版本的数据:
// create/get the map named "data"
MVMap<Integer, String> map = s.openMap("data");
// add some data
map.put(1, "Hello");
map.put(2, "World");
// get the current version, for later use
long oldVersion = s.getCurrentVersion();
// from now on, the old version is read-only
s.commit();
// more changes, in the new version
// changes can be rolled back if required
// changes always go into "head" (the newest version)
map.put(1, "Hi");
map.remove(2);
// access the old data (before the commit)
MVMap<Integer, String> oldMap =
map.openVersion(oldVersion);
// print the old version (can be done
// concurrently with further modifications)
// this will print "Hello" and "World":
System.out.println(oldMap.get(1));
System.out.println(oldMap.get(2));
// print the newest version ("Hi")
System.out.println(map.get(1));事务
支持多路并发开启事务。TransactionStore 实现了事务功能,其支持 PostgreSQL 的带 Savepoints 的事务隔离级别得读提交(Read Committed)、两阶段提交,以及其他数据的一些经典特性。事务的大小没有限制(针对大的或者长时间运行的事务,其日志被写到磁盘上)。
基于内存形式的性能和用量
基于内存操作的性能约比 java.util.TreeMap 慢 50%。
对于大型 Map,其内存开销略优于常规 Map 实现,但每个 Map 的开销较高。对于条目少于约 25 个的 Map,常规 Map 实现需要的内存更少。
如果没有指定文件名,存储的操作将是纯内存形式的,这种模式下支持除持久化之外的所有操作(多版本,索引查找,R-Tree 等等)。如果自定义了文件名,在数据持久化之前的所有操作都发生在内存中。
正如所有的 Map 实现一样,所有的 Key 是不可变的,这意味着实体被加入 Map 之后就不允许改变 Key 对象了。如果指定了文件名,在实体加入 Map 之后其 Value 对象也是允许被修改的,因为 Value 或许已经被序列化了(当打开自动 commit 时序列化会随时发生)。
可插拔的数据类型
序列化方式是可插拔的。目前的默认序列化方式支持许多普通的数据类型,针对其他的对象类型使用了 Java 的序列化机制。下面这些类型是可以直接被支持的:Boolean, Byte, Short, Character, Integer, Long, Float, Double, BigInteger, BigDecimal, String, UUID, Date 和数组(基本类型数组和对象数组)。对于序列化对象,大小估计使用指数移动平均值进行调整。
支持泛型数据类型。
存储引擎自身没有任何长度限制,所以 Key、Value、Page 和 Chunk 可以很大,而且针对 Map 和 Chunk 的数量也没有固定的限制。因为使用了日志结构存储,所以针对大的 Key 和 Page 也无需特殊的处理。
BLOB 支持
支持大的二进制对象存储,方式是将其分隔成更小的块。这样就能存储内存里放不下的对象。支持对此类对象进行流式读取以及随机访问读取。此工具写在 Store 之上,仅使用 Map 接口。
R-Tree 和可插拔的 Map 实现
Map 的具体实现是可插拔的,目前默认实现是 MVMap,此处有一个用于空间操作的多版本 R-tree Map 实现。
并发操作和缓存
支持并发读写。所有的读操作可以并行发生。支持与从文件系统中并发读一样的从 Page Cache 中并发读。写操作首先将关联的 Page 从磁盘读取到内存(这个可以并发执行),然后再修改数据,内存部分的写操作是同步的。将变化写入文件和将变化写入快照一样都可以并发地修改数据。
在 Page 级别做了缓存,是一个并发的 LIRS 缓存(LIRS 可以减少扫描)。
为了实现完全可扩展的并发写操作(内存和磁盘),Map 可以被分割到不同 Store 中的多个 Map 中(分片 Sharding)。计划是在需要时稍后添加此类机制。
日志结构化存储
在内部,变更被缓存在内存。一旦变更累积到一定程度,这些变更将被一组连续的写操作写入磁盘。与传统的数据库存储引擎相比,这对不支持高性能随机写的文件系统和存储系统(像 SSD 一样)提升了写入性能。根据测试,普通 SSD 的写入吞吐量随写入块大小增加而增加,直到块大小为 2 MB,之后不再进一步增加。
默认情况下,当大量 Pages 被修改时,这些修改会被自动写入,一个后台线程每秒写一次。也可以通过调用 commit 方法直接触发写操作。
存储的时候,所有的变更将被序列化,LZF 压缩算法是可选的,然后顺序地写入到文件的空闲区域。每一次的变更集合被称之为 Chunk。 修改过的 B-tree 的所有父 Page 也是用 Chunk 存储,以使得每一个 Chunk 也含有每一个修改过的 Map 的 Root Page(指读取这个版本数据的入口点)。这里没有区分开索引:所有的数据被当做一个页列表存储。每次存储,有一个额外的包含了元数据的 Map(每个 Map 的 Root Page,和 Chunk 列表在哪里)。
针对每个 Chunk 通常有两次写操作:一次存储 Chunk 数据(Pages),另一个是更新文件 Header(它指向最近的 Chunk)。如果 Chunk 被合并到文件的末尾,文件 Header 仅会被写在 Chunk 的末尾。这里没有事务日志,没有 Undo 日志,也没有原地更新(in-place updates)(然而,未被使用的 Chunk 默认将被写覆盖)。
老的数据将被保持 45 秒(可以配置),以至于没有显式的需要同步操作去保证数据一致。在需要的时候也可以显式的同步操作。为了重新使用磁盘空间,具有最少活动数据量的 Chunk 将被压缩(Compacted)(活动数据被再一次存储在下一个 Chunk 中)。为了改善数据局部性(locality)和磁盘使用率,计划将消除数据碎片并压缩数据。
相对于传统的存储引擎(使用事务日志,Undo 日志和主存储区域),日志结构化存储是简单的,更灵活,而且每次修改需要更少的磁盘操作,因为数据仅仅被写一次,不像传统的存储引擎要写 2 次或者 3 次。再有,B-tree 页通常是紧凑的(他们相互挨着存储)所以很容易被压缩。但是,目前临时地,磁盘使用率实际会比常规的数据库高一点,磁盘空间不会立即被重复使用(因为没有 in-place updates)。
堆外存储和可插拔存储
存储是可插拔的。除了被用到的纯内存操作,默认的存储是一个文件。
目前有一个可用的堆外存储实现。这个存储将数据保存在堆外内存中,意味着脱离了正常的堆的垃圾收集能力。这样就可以允许在不增加 JVM 堆的不增加 GC 回收停顿的情况下使用大量的内存存储。使用了 ByteBuffer.allocateDirect 来分配内存。一次分配一个 Chunk,一个 Chunk 通常是数兆 MB 大小,以使得分配的成本很低。若使用堆外存储,调用:
OffHeapStore offHeap = new OffHeapStore();
MVStore s = new MVStore.Builder().
fileStore(offHeap).open();文件系统抽象,文件锁和在线备份
文件系统是可插拔的。同样的文件系统抽象被用在 H2 中。文件能使用加密的文件系统加密。其他的文件系统实现支持从压缩的 Zip 或者 Jar file 中读取。文件系统的抽象紧密匹配了 Java 7 文件系统操作的 API。
每一个 Store 在一个 JVM 中仅会被打开一次。当打开一个存储时,文件以排他形式被锁定,以至于文件仅能被一个进程修改。文件若以只读模式打开,那么共享锁将被使用。
被持久化的数据时刻会被备份,甚至在写操作的时候(在线备份)。为了这么做,磁盘空间自动重用将被禁用,以使得新的数据一致被拼接在文件的末尾。然后,文件将被拷贝。文件句柄对应用可用。推荐使用 FileChannelInputStream 做这事。针对加密数据库,加密的文件一样能被备份。
加密文件
文件加密确保仅通过正确的密码才能读取数据。数据能被以如下方式加密:
MVStore s = new MVStore.Builder().
fileName(fileName).
encryptionKey("007".toCharArray()).
open();下面的算法和设置将被使用:
- 密码字符数组在使用后将被清理,是为了减少被窃取的风险甚至被攻击后存取主内存。
- 密码使用 SHA-256 算法,使用 PBKDF2 标准 Hash 编码。
- Salt 的长度是 64 位,使得攻击者不能使用预计算密码 Hash 表的方式。它通过一个安全的随机数生成器生成。
- 为了提升在 Android 上打开加密存储的速度,PBKDF2 迭代数量是 10。这个值越高,对暴力密码攻击的保护越好,但是打开文件就越慢。
- 文件自身加密使用标准的磁盘加密形式 XTS-AES。每个块只需要多一点的一个 AES-128 轮次。
工具
有一个 MVStoreTool,用来 Dump 文件 contents。
异常处理
工具不会抛出受检异常。取而代之的是,若需要的话会抛出未受检异常。如下异常可能发生:
- IllegalStateException: 如果一个 Map 已经关闭或发生 IO 异常,例如文件被锁定、已关闭、无法打开或关闭、读写失败、文件损坏或工具内部错误。对于此类异常,会添加错误代码以便应用程序区分不同的错误情况。
- IllegalArgumentException: 如果方法被非法参数调用。
- UnsupportedOperationException: 如果调用了不支持的方法,例如尝试修改只读 Map。
- ConcurrentModificationException: 如果 Map 被并发修改。
H2 的存储引擎
H2 1.4 之后的版本(含 1.4)默认使用 MVStore 作为存储引擎(支持 SQL, JDBC, transactions, MVCC 等等)。针对老版本,将 ;MV_STORE=TRUE 拼接到 Database URL 后面。即使它可以与默认的表级锁定一起使用,但在使用 MVStore 时默认启用 MVCC 模式。
文件格式
数据被存储到文件里。文件有两个(出于安全起见)文件头和大量的 Chunk。每个文件头是一个 4096 bytes 的块。每个 Chunk 至少一个块,但是通常是 200 个或者更多个块。数据已日志结构存储的形式存储在 Chunk 中。每个版本都有一个 Chunk。
[ file header 1 ] [ file header 2 ] [ chunk ] [ chunk ] ... [ chunk ]每一个 Chunk 含有大量的 B-Tree Page,示例代码如下:
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(0, "Hi");
}
s.commit();
s.close();结果是两个 Chunks(不包含 metadata):
Chunk 1:
- Page 1: (root) node with 2 entries pointing to page 2 and 3
- Page 2: leaf with 140 entries (keys 0 - 139)
- Page 3: leaf with 260 entries (keys 140 - 399)
Chunk 2:
- Page 4: (root) node with 2 entries pointing to page 3 and 5
- Page 5: leaf with 140 entries (keys 0 - 139)
这意味着每个 Chunk 含有一个版本的变更:新版本的变更 Page 和它的父 Page,递归直至根 Page。后来的 Page 指向被早期的 Page 引用。
文件 Header
这里有两个文件头,通常含有相同的数据。但在某个文件头被更新的某一片刻,写操作可能部分失败。这就是为什么有第二个文件头的原因。文件头采用 in-place update 更新方式。文件头包含如下数据:
H:2,block:2,blockSize:1000,chunk:7,created:1441235ef73,format:1,version:7,fletcher:3044e6cc这些数据被以键值对的形式存储。其值都是以十六进制形式存储。
这些字段是:
H: H2 表示是 H2 数据库。block: 最新的 Chunk 的 Block 的数量(但不一定是最新的)。blockSize: 文件的块的大小;目前常用 0x1000=4096,与现代磁盘 Sector 的大小匹配。chunk: Chunk 的 ID,通常与版本相同,没有版本的时候是 0。created: 文件创建时间(从 1970 年到现在的毫秒数)。format: 文件格式,当前是 1。version: Chunk 的版本。fletcher: Header 的 Fletcher-32 形式 的 Check Sum 值。
打开文件时,读取文件头并校验其 Check Sum 值。如果两个头都是合法的,那么新版本的将被使用。最新版本的 Chunk 被找到,而且从这里读取剩余的 Metadata。如果 Chunk ID, Block 和 Version 没有存储在文件头中,那么从文件中最后一个 Chunk 开始查找最近的 Chunk。
Chunk 格式
这里针对单个版本的 Chunk。每个 Chunk 由 Header、这个版本中发生修改的 Pages 和一个 Footer 组成。Page 包含 Map 中实际的数据。Chunk 里的 Page 被存储在 Header 的后面的右侧,next to each other (unaligned)。Chunk 的大小是块大小的倍数。Footer 被存储在至少 128 字节的 Chunk 中。
[ header ] [ page ] [ page ] ... [ page ] [ footer ]Footer 允许用来验证这个 Chunk 是否完全写完成了(一个 Chunk 对应一次写操作),同时允许用来找到文件中最后一个 Chunk 的开始位置。Chunk 的 Header 和 Footer 包含如下数据:
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(multiply by the block size to get the position in the file)。len: Chunk 的 size,即 Block 的个数。map: 最新 Map 的 ID;当新 Map 创建时会增加。max: 所有的最大的 Page size 的和(see page format)。next: 为下一个 Chunk 预估的开始位置。pages: 一个 Chunk 中 Page 的个数。root: Metadata 根 Page 的位置(see page format)。time: 写 Chunk 的时间,从文件创建到写 Chunk 之间的隔的毫秒数。version: Chunk 体现的版本。fletcher: Footer 的 Check Sum。
Chunks 从不取代式更新。 每个 Chunk 含有相应版本的 Page(如上所说,一个 Chunk 对应一个版本),加上这些 Page 的所有父节点,递归向上,直到根 Page。如果有一个 Entry 在 Map 中发生了增加、删除或者修改,然后相应的 Page 将被拷贝、修改,并存储到下一个 Chunk 中,旧 Chunk 中活(live)Page 的数量将减少。 这个机制叫作复制后写(Copy On Write),与 Btrfs 文件系统工作原理相似。没有活(live)Page 的 Chunk 将被打上释放的标志,所以这个空间能被更多的最近的 Chunk 使用。因为不是所有 Chunk 的大小都相同,所以在某个 Chunk 前面可能会有一段时间存在许多空闲块(直到写入一个小 Chunk 或 Chunk 被压缩)。在空闲 Chunk 被覆盖之前,默认有 45 秒的延迟,以确保新版本先被持久化。
当打开一个 Store 时最新的 Chunk 是如何被定位到的:文件头含有一个近期的(a recent chunk)Chunk,但不总是最新的一个。这将减少文件 Header 更新的次数。在打开一个文件之后,文件头、大量的 Chunk 的脚(处于文件的尾端)被读取。从这些候选者中,最近的 Chunk 的 Header 被读取。它含有下一个指针(参见上面),这些 Chunk 的头和脚同样会被读取。如果事实证明它是一个更新的合法 Chunk,则重复此过程,直到找到最新的 Chunk。在写入 Chunk 之前,基于下一个 Chunk 将与当前 Chunk 大小相同的假设来预测下一个 Chunk 的位置。当写入下一个 Chunk 时,如果之前的预测结果不正确,文件头也会被更新。在任何情况下,如果下一个链变得长于 20 跳,文件头也会被更新。
Page 格式
每一个 Map 是一个 B-tree,Map 的数据被存储在 B-tree Pages:含有 Map 的 Key-Value Pairs 的叶子节点,那些仅含有 Key 和指向叶子的内部节点。树的根节点既是一个叶子也是一个内部节点。与文件头、Chunk 头脚不同的是,Page 的数据是人类不可读的,它是以字节数组形式存储,有 long (8 bytes), int (4 bytes), short (2 bytes), and variable size int and long (1 to 5 / 10 bytes) 几种类型。
Page 格式是:
length(int): Page 的长度(以 bytes 为单位)。checksum(short): Checksum 值(chunk id xor offset within the chunk xor page length)。mapId(variable size int): 这页所属 Map 的 ID。len(variable size int): 这个页中 Key 的数量。type(byte): 页的类型。0 表示左 Page, 1 表示内部节点;加 2 代表键值对采用了 LZF 算法压缩,加 6 代表键值对采用了 Deflate 算法压缩。children(array of long; internal nodes only): 子节点位置。childCounts(array of variable size long; internal nodes only): 已知子页的实体总数。keys(byte array): 所有的键,stored depending on the data type.values(byte array; leaf pages only): 所有值,stored depending on the data type.
即使这不是文件格式所要求的,页仍以如下顺序存储:
针对每一个 Map,Root Page 首先被存储,然后是内部节点(如果有的话),然后是左叶子。这样应该能加速读取的速度,因为顺序读的速度高于随机读。元数据的 Map 被存储在一个 Chunk 的尾端。指向页的指针被当做一个 long 型存储,使用了一个特殊的格式:26 位用于 Chunk ID,32 位用于在 Chunk 内的位移,5 位用于长度码,1 位用于页类型(叶子还是内部节点)。页类型被编码以至于当清除或移除一个 Map 时,叶子节点不必被读取(内部节点需要被读取以使得程序知道所有的页在哪里,而且在一个典型的 B-tree 结构中,绝大多数 Page 是叶子页)。绝对文件位置没有被包含以至于在不必改变页指针的情况下 Chunk 能在文件里被移除,仅有 Chunk 的元数据需要被修改。长度码是一个从 0 到 31 的数字,0 表示这个页的最大长度是 32bytes,1 代表 48bytes,2: 64, 3: 96, 4: 128, 5: 192, 以此类推,直至 31 代表 1MB。如此一来,读取一个页仅仅需要一个读操作(除非是很大的页)。所有页的最大长度的和被存储在 Chunk 元数据的 max 字段,并且当一个页被标记成“移除了”,活动页最大长度将被调整。这样不仅可以估算空闲页数的个数,还允许估算一个 Block 内的剩余空间。
子页中总实体的数量总保持在有效的允许范围内计数,通过索引查找和跳过一些操作。
子页中的条目总数被保留,以允许有效的范围计数、按索引查找和跳过操作。
这个页的形式是一个计数 B-tree。
数据压缩: Page 类型后的数据可以选择 LZF 压缩算法进行压缩。
Metadata Map
除用户 Map 之外,还有个元数据 Map,它含有用户 Map 的名字、位置及其 Chunk 元数据。Chunk 的最后一页含有元数据 Map 的 Root Page。Root Page 的精确位置被存储在 Chunk 的 Header 里。这个 Page(直接地或间接地)指向所有其他 Map 的 Root Page。一个 Store 的元数据 Map 有一个名字叫 data 的 Map,还有一个 Chunk,包含如下实体:
chunk.1: Chunk 1 的元数据。这是和 Chunk Header 相同的数据,活动的 Page 的数量,和最大的活动长度。map.1: Map 1 的元数据。这个实体是:名字、创建版本和类型。name.data: 名字为 data 的 Map ID。他的值是 1。root.1: Map1 的 Root 位置。setting.storeVersion: Store 的版本(一个用户定义的值)。
相似的项目以及和其他存储引擎的不同
与类似的存储引擎 LevelDB 和 Kyoto Cabinet 不同,MVStore 使用 Java 编写,能很容易嵌入 Java 或者 Android 程序中。
MVStore 与 Berkeley DB 的 Java 版本有点相似,因为它也是用 Java 编写的且是日志结构式的存储,但是 H2 的许可证更自由。
类似 SQLite3,MVStore 在一个文件上保存所有数据。与 SQLite 3 不同的是,MVStore 使用日志结构式存储。该计划使得 MVStore 比 SQLite3 更易用更快。在最近一个很简单的测试中,在 Android 上 MVStore 速度是 SQLite 3 的两倍。
MVStore 的 API 与 Jan Kotek 写的 MapDB 相似(以前称作 JDBM),部分代码在 MVStore 和 MapDB 中共享。然而,与 MapDB 不同的是,MVStore 使用日志结构式存储。MVStore 没有记录的大小限制。
目前状态
这个阶段的代码仍处于实验性阶段。API 与其行为也可能被部分修改。特性或将被添加或移除(即使主要特性将保留)。
需求
MVStore 被包含在最新的 H2 Jar 文件中。
对于使用它没有什么特别的需要。MVStore 也可以在 Android 的 JVM 上运行。
若需要仅仅构建 MVStore(不含有数据库引擎),运行:
./build.sh jarMVStore这将创建 h2mvstore-1.4.191.jar(大约 200 KB)。
说明: 本文档内容基于 H2 数据库早期版本(约 1.4 系列)的 MVStore 文档翻译整理。目前 H2 数据库(2.x 版本)已默认稳定使用 MVStore 引擎,但部分 API 细节或内部实现可能已随版本迭代有所调整,请以官方最新文档为准。
版权声明:本文为原创文章,版权归 戴老师的博客 所有,转载请联系博主获得授权。
本文地址:https://1diff.fun/archives/h2-de-mvstore.html
如果对本文有什么问题或疑问都可以在评论区留言,我看到后会尽量解答。