翻譯自http://www.h2database.com/html/mvstore.html
轉載請著名出處,及譯者信息。
第一次翻譯,諸多不妥請諒解,謝謝。
概述
MVStore是一個持久化的、日志結構式的kv存儲。本計划用它作為H2的下一代存儲子系統,但你也可以在一個不涉及JDBC或者SQL的應用中直接使用它。
- MVStore代表多版本存儲。
- 每一個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 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。The iterator supports fast skipping. This is possible because internally, each map is organized in the form of a 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%。
The memory overhead for large maps is slightly better than for the regular map implementations, but there is a higher overhead per map. For maps with less than about 25 entries, the regular map implementations need less memory.
如果沒有指定文件名,存儲的操作將是純內存形式的,這種模式下支持除持久化之外的所有操作(多版本,索引查找,R-Tree等等)。如果自定義了文件名,在數據持久化之前的所有操作都發生在內存中。
正如所有的map實現一樣,所有的key是不可變的,這意味着實體被加入map之后就不允許改變key對象了。如果指定了文件名,在實體加入map之后其value對象也是允許被修改的,因為value或許已經被序列化了(當打開自動commit時序列化會隨時發生)。
可插拔的數據類型
序列化方式是可插拔的。目前的默認的序列化方式支持許多普通的數據類型,針對其他的對象類型使用了java的序列化機制。下面這些類型是可以直接被支持的:Boolean, Byte, Short, Character, Integer, Long, Float, Double, BigInteger, BigDecimal, String, UUID, Date和數組(基本類型數組和對象數組)。For serialized objects, the size estimate is adjusted using an exponential moving average.
支持泛型數據類型。
存儲引擎自身沒有任何長度限制,所以key,value,page和chunk可以很大很大,而且針對map和chunk的數量也沒有固定的限制。因為使用了日志結構存儲,所以針對大的key和page也無需特殊的處理。
BLOB支持
支持大的二進制對象存儲,方式是將其分隔成更小的塊。這樣就能存儲內存里放不下的對象。Streaming as well as random access reads on such objects are supported. This tool is written on top of the store, using only the map interface.
R-Tree和可插拔的map實現
map的具體實現是可插拔的,目前默認實現是MVMap,here is a multi-version R-tree map implementation for spatial operations.
並發操作和緩存
支持並發讀寫。所有的讀操作可以並行發生。支持與從文件系統中並發讀一樣的從page cache中 並發讀。寫操作首先將關聯的page從磁盤讀取到內存(這個可以並發執行),然后再修改數據,內存部分的寫操作是同步的。將變化寫入文件和將變化寫入快照一樣都可以並發的修改數據。
在page級別做了緩存,是一個並發的LIRS 緩存(LIRS 可以減少掃描)
For fully scalable concurrent write operations to a map (in-memory and to disk), the map could be split into multiple maps in different stores ('sharding'). The plan is to add such a mechanism later when needed.
日志結構化存儲
在內部,變更被緩存在內存。一旦變更累積到一定程度,這些變更將被一組連續的寫操作寫入磁盤。與傳統的數據庫存儲引擎相比,這對不支持高性能隨機寫的文件系統和存儲系統像SSD一樣提升了寫入性能According to a test, write throughput of a common SSD increases with write block size, until a block size of 2 MB, and then does not further increase.)默認情況下,當大量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默認將被寫覆蓋)。
老的數據將被保持45s(可以配置),以至於沒有顯式的需要同步操作去保證數據一致。在需要的時候也可以顯式的同步操作。為了重新使用磁盤空間,具有最少活動數據量的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中讀取。文件系統的抽象緊密匹配了Java7文件系統操作的API。
每一個存儲在一個JVM中僅會被打開一次。當打開一個存儲時,文件以排他形式被鎖定,以至於文件僅能被一個進程修改。文件若以只讀模式打開,那么共享鎖將被使用。
被持久化的數據時刻會被備份,甚至在寫操作的時候(在線備份)。為了這么做,磁盤空間自動重用將被禁用,以使得新的數據一致被拼接在文件的末尾。然后,文件將被拷貝。文件句柄對應用可用。 推薦使用FileChannelInputStream 做這事。針對加密數據庫,加密的文件一樣能被備份。
Encrypted Files
文件加密確保僅通過正確的密碼才能讀取數據。數據能被以如下方式加密:
MVStore s = new MVStore.Builder(). fileName(fileName). encryptionKey("007".toCharArray()). open();
下面的算法和設置將被使用:
密碼字符數組在使用后將被清理,是為了減少被竊取的風險甚至被攻擊后存取主內存。
密碼使用SHA-256 算法 使用PBKDF2標准hash編碼。
salt 的長度是64位,使得攻擊者不能使用預計算密碼hash表的方式。他通過一個安全的隨機數生成器生成。
為了提升在android上打開加密存儲的速度,PBKDF2 迭代數量是10.這個值越高,對暴力密碼攻擊的保護越好,但是打開文件就越慢。
文件自身加密使用標准的磁盤加密形式XTS-AES。 Only little more than one AES-128 round per block is needed.
Tools工具
有一個MVStoreTool,用來dump 文件contents。
異常處理
工具不會拋出受檢異常。取而代之的是,若需要的話會跑出未受檢異常。如下異常可能發生:
IllegalStateException if a map was already closed or an IO exception occurred, for example if the file was locked, is already closed, could not be opened or closed, if reading or writing failed, if the file is corrupt, or if there is an internal error in the tool. For such exceptions, an error code is added so that the application can distinguish between different error cases.
IllegalArgumentException if a method was called with an illegal argument.
UnsupportedOperationException if a method was called that is not supported, for example trying to modify a read-only map.
ConcurrentModificationException if a map is modified concurrently.
H2的存儲引擎
H2 1.4之后的版本(含1.4)默認使用MVStore作為存儲引擎 (支持 SQL, JDBC, transactions, MVCC等等).針對老版本, 將;MV_STORE=TRUE拼接到database URL后面. Even though it can be used with the default table level locking, by default the MVCC mode is enabled when using the MVStore.
文件格式
數據被存儲到文件里. 文件有兩個(出於安全起見)文件頭和大量的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: H:2表示是H2數據庫
block: 最新的chunk的block的數量 (but not necessarily the newest???).
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 and 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對應一個版本), plus all the parent nodes of those pages, recursively, up to the root page. 如果有一個entry在map中發生了增加、刪除或者修改,然后相應的page將被拷貝、修改,並存儲到下一個chunk中, 舊chunk中活(live)page的數量將減少. 這個機制叫作復制后寫, 與Btrfs文件系統工作原理相似. 沒有活(live)page的chunk將被打上釋放的標志,所以這個空間能被更多的最近的chunk使用. Because not all chunks are of the same size, there can be a number of free blocks in front of a chunk for some time (until a small chunk is written or the chunks are compacted). There is a delay of 45 seconds (by default) before a free chunk is overwritten, to ensure new versions are persisted first.
當打開一個store時最新的chunk是如何被定位到的: 文件頭含有一個近期的(a recent chunk)chunk,但不總是最新的一個。這將減少文件header更新的次數。在打開一個文件之后, 文件頭, 大量的chunk的腳 (處於文件的尾端) 被讀取. From those candidates, 最近的chunk的header被讀取。它含有下一個指針(參見上面),這些chunk的頭和腳同樣會被讀取。 those chunk's header and footer are read as well. If it turned out to be a newer valid chunk, this is repeated, until the newest chunk was found. Before writing a chunk, the position of the next chunk is predicted based on the assumption that the next chunk will be of the same size as the current one. When the next chunk is written, and the previous prediction turned out to be incorrect, the file header is updated as well. In any case, the file header is updated if the next chain gets longer than 20 hops.
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內的剩余空間。
子頁中總實體的數量總保持在有效的允許范圍內計數,通過索引查找和跳過一些操作。??
The total number of entries in child pages are kept to allow efficient range counting, lookup by index, and skip operations.
這個頁的形式是一個技數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的許可證更自由.
類似SQLite3MVStore在一個文件上保存所有數據。與SQLite 3不同的是, MVStore 使用日志結構式存儲。 該計划使得MVStore比SQLite3更易用更快。在最近一個很簡單的測試中, 在android上MVStore速度是SQLite 3的兩倍。
MVStore的api與Jan Kotek寫的MapDB 相似 (以前稱作JDBM) , 部分代碼在 MVStore 和 MapDB中共享. 然而, 與MapDB不同的是, MVStore 使用日志結構式存儲。 MVStore沒有記錄的大小限制。
目前狀態
這個階段的代碼仍處於實驗性階段。API與其行為也可能被部分修改。 特性或將被添加或移除(即使主要特性將保留).
Requirements需求
MVStore被包含在最新的H2 jar文件中.
對於使用它沒有什么特別的需要。MVStore 也可以在android的JVM上運行。
若需要僅僅構建MVStore (不含有數據庫引擎), 運行:
./build.sh jarMVStore
這將創建h2mvstore-1.4.191.jar (大約200 KB).