0. 存儲引擎基礎
存儲引擎的基本功能和數據結構
一個存儲引擎需要實現三個基本的功能:
- write(key, value) 二分查找並插入
- read(key) -> return value 二分查找並返回
- scan(begin, end) -> return values 求key在某區間內的所有元素。先兩次二分查找,確定begin和end的位置。兩位置之間的數據就是結果集 values
上述的存儲引擎和普通的哈希表不同。最大的區別就是存儲引擎內要求數據的存儲順序是按照key有序的。這比哈希表更節省空間,也容易實現scan()操作。
乍一看使用普通的有序數組好像就可以解決問題啦,但是普通的有序數組也有個問題:當一個新元素要write插入進來時,為保證數組有序,需要把后面的數據都移動一位,這樣開銷是很大的。
還有一種有序的結構叫做平衡二叉樹。如果把數據有序放入平衡二叉樹好像也不是不行。但是平衡二叉樹會占用很多的額外空間(用於存放節點指針),另外局部性很差,讀性能(read/scan)低。
(在OS的頁面置換這一節中我們學過工作集的概念,其實這個和局部性很像。硬件、操作系統等等系統,絕大部分時候,執行一次 操作流程會有額外的開銷(overhead)。因此很多部件、模塊都設計成:連續執行類似或相同 的操作、訪問空間相鄰的內容時,則將多次操作合並為一次,或多次之間共享上下文信息。這樣能極大提升性能。這種時間、空間上的連續性,叫做局部性。)
那用什么數據結構才好呢?可以考慮把數組和二叉樹結合一下,把平衡二叉樹的每個節點都改成一片數組,做成一個大葉節點樹。這樣一方面通過把拆分成若干小數組,減少了數組插入時的開銷(寫操作)。另一方面,擴大了二叉樹中每個節點的大小,增加了讀操作的局部性,改善了scan的性能。那具體每個節點的數組要多大才好呢?這就要根據需求進行trade off啦。
- 葉節點大:局部性高 ● 插入成本高,慢 ● 讀取性能高,快
- 葉節點小:局部性低 ● 插入成本低,快 ● 讀取性能低,慢
在實踐中很多存儲引擎會使用B+ Tree作為存儲結構(比如MYSQL):
- 在插入過程中動態保持有序
- 把數組拆成多個小段,把小段作 為葉節點用 B+Tree 組織起來,讓插入過程代價盡量小
- 每小段(也就是葉節點)是一個有序數組,插入數據時只需要移動插入點之后的數據,大大減少移動量
B+ Tree的靈魂基本就是上述的大葉節點樹。具體細節可參考https://blog.csdn.net/b_x_p/article/details/86434387
存儲引擎的持久化
為了保證比如關機重啟之后數據仍然可以繼續使用,我們需要把數據保存到硬盤上。但硬盤有以下幾個特點:
- 速度比內存慢啊......
- 連續寫入比隨機寫入快很多
因此硬盤上存儲引擎的設計和之前要截然不一樣。WAL(Write Ahead Log)就是一種成熟的解決方案。它是一種異構鏡像方案(也叫做semi-DB):
- 異構:磁盤與內存的數據結構不一樣。磁盤使用局部性高的結構,內存可以是任意結構
- 鏡像:邏輯上兩邊的數據等價
- 用戶進行寫操作時,內存和WAL都寫入。讀操作時從內存讀取。存儲引擎重啟時重新執行WAL里記錄的所有寫操作,恢復內存數據結構。
前面說的好抽象啊......其實WAL可以理解成是一個log文件,寫 WAL 都在末尾追加寫入,順序地記錄所有修改動作(類比數據庫系統的日志)。為了存盤數據的安全,避免進程非正常退出丟數據,WAL 一般每次寫完數據都執行 fsync 操作,否則數據可能還留在操作系統的 Page Cache 中沒有寫到盤上(不實時fsync會有丟失數據的風險,但fsync很占磁盤資源,可能成為性能瓶頸。因此數據庫系統會提供參數設置fsync的頻率)
WAL工作時其實就是[傻傻的]依次記錄每次的寫操作,但這樣效率也不高:1. WAL 中可能存在相同 key 的多次 Write 的多個版本的數據,占用了 額外空間,也降低重放性能。2. WAL中記錄的寫入操作太多時,整體效率也會降低。 為解決這些問題,我們可以設計一個機制,在某些特定的時刻將WAL記錄的所有操作做成一個快照(即相當於提前執行了到目前為止所有的WAL record,並將數據存盤)。這樣既提高了重啟時重放WAL的效率,也節省了空間。這個機制就叫做Compaction。compaction過程會占用一些IO資源,比如用戶只插入了k GB的數據,由於compaction的存在,硬盤總共會執行大於k GB的IO寫操作。這個問題就叫做寫放大。假如硬盤是SSD,寫放大太嚴重就會影響硬盤的壽命。compaction其實就是以寫放大作為代價,換取更好的讀取性能。
按照上面的方案讓WAL和內存中的B+ tree配合,看起來就很完美啦!但是別忘了內存空間是有限的,不可能所有的寫操作都能丟進內存。所以內存中就只能存放部分數據(相當於一個cache),硬盤中才存放所有數據。
另外,從硬盤向內存讀數據也是需要較好的局部性的(還記得連續寫入比隨機寫入快得多嘛?)。因此在實際操作時,我們在硬盤的WAL中,以B+tree中的葉節點大小作為單位存儲,為B+tree的每個葉節點都啟用WAL。內存中的B+tree在讀取時,遇到當前不在內存的葉節點時,就去硬盤加載(類似於虛擬內存中遇到缺頁中斷的處理機制)。如圖所示:
上圖的結構中,B+ tree的每個葉子節點都有一個WAL。當葉子節點很多的時候這樣也不大好....如果compaction的頻率很高,而且WAL做compaction時,數據可以從內存獲得,那么真正需要從WAL讀數據的機會就很少。這樣我們可以把一些葉子節點的WAL合並起來,以提高局部性。(具體實現暫時略)
B+ tree存儲引擎分析與改進
經過上面這一頓操作后,我們暫時就有了這樣一個存儲引擎:
這個模型就很好了咩?我們來分析一下:
- Write 很快:
- 查找寫入位置,性能為 O(Log2 n)
- Append 到 WAL,性能為 O(1)
- 更新到葉節點,性能可能略差但:○ 是內存操作 ○ 可以異步操作
- Scan / Read 很快:
- 在 B+Tree 中查找,性能接近 O(Log2 n)
- 如果數據所在的葉節點:
- 在內存,完成讀取
- 不在內存,加載相關葉節點,再從中查找。有磁盤 IO、磁盤讀放大(定義和寫放大類似,表示 [系統實際硬盤讀IO數量]大於[用戶在前台需要讀的數據總量])
另外,如果有奇怪的用戶在不同的key值域上隨機寫入(可能每個key值域上寫入量很小,但會寫很多不同的key值域),那么WriteCache就很難覆蓋所有用戶寫過的key值域。為了騰出writecache,葉節點必須在修改占比還很小的時候,就compact寫盤。在這種情況下會造成巨大的寫放大,還會造成寫盤次數相對於總寫入量過多(全是分散IO,寫入效率就比較低)。其根本原因是B+tree中,每個葉子節點覆蓋的key范圍太小啦。而且存量數據越大,葉節點的key覆蓋范圍越窄。
另外,B+ tree的葉節點是分散存儲在硬盤上的,也導致多次IO之間不存在連續性。
那么怎么辦捏?我們可以用另一種局部性好的有序結構,叫做LSM Tree。這也就是RocksDB所用的結構。
LSM Tree
LSM Tree長醬紫:
各個小有序數組的key覆蓋范圍是相互重疊的,它們合並起來可以看做一個大的虛擬有序數組。同時因為范圍是重疊的,因此某個key有可能會在多個小數組上都存在,因此不同數組設置了不同的優先級。
這樣設計既采納了B+Tree中將數組分散存儲以防止寫開銷太大的問題,又可以保證每個小數組都有局部性。
LSM Tree的Read操作:最簡單的思路是按優先級從高到低,二分查找每個小數組。但這樣會存在讀放大問題(找了好多次才找到對應的小數組)。為解決這一問題,我們可以在數組生成時,對每個小數組都做一個Bloom Filter(可以理解為一個高效率的hashset)來記錄當前小數組里都有哪些key。在讀操作時先查Bloom Filter,如果不存在就不需要二分查找這個小數組了。
注意如果要讀取暫存在硬盤上的小有序數組:因為這個數組還是比較大的,所以不能像B+Tree那樣直接全load到內存再二分查找。對於硬盤上的數組文件,可以把它分成多個小的block。維護一個Bloom Filter記錄每個key在哪個block,還有一個索引記錄每個block的范圍信息[begin, end]。讀取到內存時以block作為單位。
LSM Tree的Scan操作:找到所有覆蓋了begin、end范圍的小數組,然后進行多路合並(merge k sorted array)。對於重復的key,取優先級高的數組里的元素。
LSM Tree的Write操作:分為兩部分: 1. 純內存的LSM Tree:write只插入到最上層的有序結構(最上層使用其他的有序結構而不是小有序數組啦,來避免插入時要移動其他元素的問題)。當最上層過大時將最上層下移一層,然后生成一個新的最上層。(這樣一來,前面的優先級其實就成了根據寫入時間從新到舊排序啦) 2. 磁盤的LSM Tree的write:和內存的基本一致,只是為最上層的有序結構加一個WAL防止數據丟失。
LSM Tree的Compaction
前面說到write會不停產生新數組,而數組個數太多了會影響scan/read的性能,因此LSM Tree也需要Compaction操作,把若干個小數組合並成一個新的有序數組,從而控制數組的個數不能太多。
- 在Compaction的過程中,每個新寫下來、未經 Compact 的數組文件的大小是固定的(取決於 WriteBuffer 的大小),我們把它叫做 L0 文件。
- Compact 后的結果是 L0 的倍數。因此可以指定 L0 文件 Size = 1,為 L0 的 x 倍大小的文件則 Size = x
- 很容易推算出:優先 Compact 最小體積的數組可以最低成本地減少總數組個數。因此 Compation 總是從 L0 層開始,按文件體積從小到大地進行。
- 為了保證 Compact 后仍舊有明確的新舊排序,要求參與 Compaction 的數組是連續相鄰的。否則數組之間的寫入時間會產生重疊:導致讀取時,無法以優先級進行 key 排重
- LSM Tree的過程中也會產生寫放大。而且參與 Compaction 的輸入數據通常不在內存,需要從磁盤上讀起來,所以還會有IO讀和cpu消耗。
LSM Tree有很多種Compaction策略。最簡單的策略就是把相鄰T層的數組進行合並。由於Compaction的次數不同,就會形成相應的多層結構。如下圖(這里T=3)
(上面只是一個最簡單的Compaction策略,具體優化以及在RocksDB中的實現還涉及很多細節,暫時忽略)
RocksDB中的LSM Tree
上圖中,SST相當於之前提到的小有序數組,MemTable相當於LSM Tree的數據在內存中的Cache。
每層Level的意義相當於對數據按新舊順序進行了時域切割。如下圖:
LSM Tree解決了B+Tree中攢批不足帶來的寫放大(參考B+Tree那一段中 某個奇怪的用戶的操作) ,但帶來的代價就是層層Compaction帶來的新的寫放大。所以說一個復雜的系統需要大量的取舍和平衡叭
1. 簡介
RocksDB的項目起源於Facebook的一個實驗,希望能夠開發一個高效的數據庫實現能夠在快速存儲設備(特別是Flash)上存儲數據並服務服務器的負載,同時完全挖掘這類存儲設備的潛能。RocksDB是一個C++庫用於存儲kv數據並且支持原子讀寫。RocksDB實現了在配置上的較高的靈活性並且可以運行到各種生產環境中,包括純內存、Flash、HDD或者HDFS。RocksDB支持多種壓縮算法以及多種工具用於生產支持以及debug。RocksDB借用了許多LevelDB的代碼以及Apache HBase中的思想。最初是基於LevelDB1.5開發。
RocksDB是一個嵌入式的K-V(任意字節流)存儲。所有的數據在引擎中是有序存儲,可以支持Get(key)、Put(Key)、Delete(Key)和NewIterator()。RocksDB的基本組成是memtable、sstfile和logfile。
- memtable是一種內存數據結構,寫請求會先將數據寫到memtable中,然后可選地寫入logfile。
- logfile是一個順序寫的文件。當內存表溢出的時候,數據會flush到sstfile中,然后這個memtable對應的logfile也會安全地被刪除。
- sstfile中的數據也是有序存儲以方便查找。
RocksDB中的key和value完全是byte stream,key和value的大小沒有任何限制。Get接口提供用戶一種從DB中查詢key對應value的方法,MultiGet提供批量查詢功能。DB中的所有數據都是按照key有序存儲,其中key的compare方法可以用戶自定義。Iterator方法提供用戶RangeScan功能,首先seek到一個特定的key,然后從這個點開始遍歷。Iterator也可以實現RangeScan的逆序遍歷,當執行Iterator時,用戶看到的是一個時間點的一致性視圖。
Fault Torlerance
RocksDB通過checksum來檢測磁盤數據損壞。每個sst file的數據塊(4k-128k)都有相應的checksum值。寫入存儲的數據塊內容不允許被修改。
Multi-Threaded Compactions
當用戶重復寫入一個key時,在DB中會存在這個key的多個value,compaction操作就是來刪除這個key的冗余數據。當一個key被刪除時,compation也可以用來真正執行這個底層數據的刪除工作,如果用戶配置合適的話,compation操作可以多線程執行。DB的數據都存儲在sstfile中,當內存表的數據滿的時候,會將內存數據(去重、刪除無效數據后)寫入到L0 文件中。每隔一段時間小文件中的數據會重新merge到更大的文件中,這就是compation。LSM引擎的寫吞吐直接依賴於compation的性能,特別是數據存儲在SSD或者RAM的情況。
RocksDB也支持多線程並行compaction。后台的compaction線程用來將內存數據flush到存儲,當所有的后台線程都正在執行compaction時,瞬時大量寫操作會很快將內存表寫滿,這就會引起寫停頓。可以配置少一些的線程用於執行數據flush操作,
Block Cache -- Compressed and Uncompressed Data
RocksDB使用LRU cache提供block的讀服務。block cache partition為兩個獨立的cache,其中一塊可以cache未壓縮RAM數據,另一塊cache 壓縮RAM數據。如果壓縮cache配置打開的話,用戶一般會開啟direct io,以避免OS的也緩存重新cache相同的壓縮數據。
可用配置
不論是在option string還是option map中,option name是目標類中的變量名,這些包括:DBOptions, ColumnFamilyOptions, BlockBasedTableOptions, or PlainTableOptions。DBOptions and ColumnFamilyOptions中的變量名和變量描述信息可以在options.h中找到,BlockBasedTableOptions, and PlainTableOptions中的變量信息可以在table.h中找到。需要注意的是,盡管絕大部分的配置項都可以在option string和option map中支持,仍然有一些例外。RocksDB支持的所有配置項可以在db_options_type_info, cf_options_type_info and block_based_table_type_info中查閱,源文件是util/options_helper.h。
LSM-Tree
RocksDB 是基於 LSM-Tree 的,大概如下
sst文件是在硬盤上的。SST files按照key 排序,且每個文件的key range互相不重疊。為了check一個key可能存在於哪一個一個SST file中,RocksDB並沒有依次遍歷每一個SST file然后去檢查key是否在這個file的key range 內,而是執行二分搜索算法(FileMetaData.largest )去定位這個SST file。(更詳細可以參考https://yq.aliyun.com/articles/669316)
首先,任何的寫入都會先寫到 WAL,然后在寫入 Memory Table(Memtable)。當然為了性能,也可以不寫入 WAL,但這樣就可能面臨崩潰丟失數據的風險。Memory Table 通常是一個能支持並發寫入的 skiplist,但 RocksDB 同樣也支持多種不同的 skiplist,用戶可以根據實際的業務場景進行選擇。
當一個 Memtable 寫滿了之后,就會變成 immutable 的 Memtable,RocksDB 在后台會通過一個 flush 線程將這個 Memtable flush 到磁盤,生成一個 Sorted String Table(SST) 文件,放在 Level 0 層。當 Level 0 層的 SST 文件個數超過閾值之后,就會通過 Compaction 策略將其放到 Level 1 層,以此類推。
這里關鍵就是 Compaction,如果沒有 Compaction,那么寫入是非常快的,但會造成讀性能降低,同樣也會造成很嚴重的空間放大問題。為了平衡寫入,讀取,空間這些問題,RocksDB 會在后台執行 Compaction,將不同 Level 的 SST 進行合並。但 Compaction 並不是沒有開銷的,它也會占用 I/O,所以勢必會影響外面的寫入和讀取操作。
對於 RocksDB 來說,他有三種 Compaction 策略,一種就是默認的 Leveled Compaction,另一種就是 Universal Compaction,也就是常說的 Size-Tired Compaction,還有一種就是 FIFO Compaction。對於 FIFO 來說,它的策略非常的簡單,所有的 SST 都在 Level 0,如果超過了閾值,就從最老的 SST 開始刪除,其實可以看到,這套機制非常適合於存儲時序數據。
實際對於 RocksDB 來說,它其實用的是一種 Hybrid 的策略,在 Level 0 層,它其實是一個 Size-Tired 的,而在其他層就是 Leveled 的。
這里在聊聊幾個放大因子,對於 LSM 來說,我們需要考慮寫放大,讀放大和空間放大,讀放大可以認為是 RA = number of queries * disc reads,譬如用戶要讀取一個 page,但實際下面讀取了 3 個 pages,那么讀放大就是 3。而寫放大則是 WA = data writeen to disc / data written to database,譬如用戶寫入了 10 字節,但實際寫到磁盤的有 100 字節,那么寫放大就是 10。而對於空間放大來說,則是 SA = size of database files / size of databases used on disk,也就是數據庫可能是 100 MB,但實際占用了 200 MB 的空間,那么就空間放大就是 2。
2. compaction
LSM-Tree 能將離散的隨機寫請求都轉換成批量的順序寫請求(WAL + Compaction),以此提高寫性能。但也帶來了一些問題:
- 讀放大(Read Amplification)。LSM-Tree 的讀操作需要從新到舊(從上到下)一層一層查找,直到找到想要的數據。這個過程可能需要不止一次 I/O。特別是 range query 的情況,影響很明顯。
- 空間放大(Space Amplification)。因為所有的寫入都是順序寫(append-only)的,不是 in-place update ,所以過期數據不會馬上被清理掉。
RocksDB 和 LevelDB 通過后台的 compaction 來減少讀放大(減少 SST 文件數量)和空間放大(清理過期數據),但也因此帶來了寫放大(Write Amplification)的問題。
- 寫放大。實際寫入 HDD/SSD 的數據大小和程序要求寫入數據大小之比。正常情況下,HDD/SSD 觀察到的寫入數據多於上層程序寫入的數據。
在 HDD 作為主流存儲的時代,RocksDB 的 compaction 帶來的寫放大問題並沒有非常明顯。這是因為:
- HDD 順序讀寫性能遠遠優於隨機讀寫性能,足以抵消寫放大帶來的開銷。
- HDD 的寫入量基本不影響其使用壽命。
現在 SSD 逐漸成為主流存儲,compaction 帶來的寫放大問題顯得越來越嚴重:
- SSD 順序讀寫性能比隨機讀寫性能好一些,但是差距並沒有 HDD 那么大。所以,順序寫相比隨機寫帶來的好處,能不能抵消寫放大帶來的開銷,這是個問題。
- SSD 的使用壽命和其寫入量有關,寫放大太嚴重會大大縮短 SSD 的使用壽命。因為 SSD 不支持覆蓋寫,必須先擦除(erase)再寫入。而每個 SSD block(block 是 SSD 擦除操作的基本單位) 的平均擦除次數是有限的。
所以,在 SSD 上,LSM-Tree 的寫放大是一個非常值得關注的問題。而寫放大、讀放大、空間放大,三者就像 CAP 定理一樣,需要做好權衡和取舍。
Ref:https://cloud.tencent.com/developer/article/1352666
RocksDB 的寫放大分析:
+1 - redo log 的寫入
+1 - Immutable Memtable 寫入到 L0 文件
+2 - L0 和 L1 compaction(L0 SST 文件的 key 范圍是重疊的,出於性能考慮,一般盡量保持 L0 和 L1 的數據大小是一樣的,每次拿全量 L0 的數據和全量 L1 的數據進行 compaction)
+11 - Ln-1 和 Ln 合並的寫入(n >= 2,默認情況下,Ln 的數據大小是 Ln-1 的 10 倍,見max_bytes_for_level_multiplier )。
所以,總的寫放大是 4 + 11 * (n-1) = 11 * n - 7 倍。關鍵是 n 的取值。
假設 max_bytes_for_level_multiplier 取默認值 10,則 n 的取值受 L1 的大小和 LSM-Tree 的大小影響。
L1 的大小由 max_bytes_for_level_base 決定,默認是 256 MB。
默認情況下 L0 的大小和 L1 一樣大,也是 256 MB。不過 L0 比較特殊,當 L0 的 SST 文件數量達到 level0_file_num_compaction_trigger 時,觸發 L0 -> L1 的 comapction。所以 L0 的最大大小為 write_buffer_size * min_write_buffer_number_to_merge * level0_file_num_compaction_trigger。
write_buffer_size 默認 64 MB
min_write_buffer_number_to_merge 默認 1
level0_file_num_compaction_trigger 默認 4
所以 L0 默認最大為 64 MB * 1 * 4 = 256 MB
因此,RocksDB 每一層的默認大小為 :
L0 - 256 MB
L1 - 256 MB
L2 - 2.5 GB
L3 - 25 GB
L4 - 250 GB
L5 - 2500 GB
Tiered Compaction vs Leveled Compaction
大家應該都知道,對於 LSM 來說,它會將寫入先放到一個 memtable 里面,然后在后台 flush 到磁盤,形成一個 SST 文件,這個對寫入其實是比較友好的,但讀取的時候,很可能會遍歷所有的 SST 文件,這個開銷就很大了。同時,LSM 是多版本機制,一個 key 可能會被頻繁的更新,那么它就會有多個版本留在 LSM 里面,占用空間。
為了解決這兩個問題,LSM 會在后台進行 compaction,也就是將 SST 文件重新整理,提升讀取的性能,釋放掉無用版本的空間,通常,LSM 有兩種 Compaction 方式,一個就是 Tiered,而另一個則是 Leveled。
上圖是兩種 compaction 的區別,當 Level 0 刷到 Level 1,讓 Level 1 的 SST 文件達到設定的閾值,就需要進行 compaction。對於 Tiered 來說,我們會將所有的 Level 1 的文件 merge 成一個 Level 2 SST 放在 Level 2。也就是說,對於 Tiered 來說,compaction 其實就是將上層的所有小的 SST merge 成下層一個更大的 SST 的過程。
而對於 Leveled 來說,不同 Level 里面的 SST 大小都是一致的,Level 1 里面的 SST 會跟 Level 2 一起進行 merge 操作,最終在 Level 2 形成一個有序的 SST,而各個 SST 不會重疊。
上面僅僅是一個簡單的介紹,大家可以參考 ScyllaDB 的兩篇文章 Write Amplification in Leveled Compaction,Space Amplification in Size-Tiered Compaction,里面詳細的說明了這兩種 compaction 的區別。
3. Block Cache
Block Cache是RocksDB把數據緩存在內存中以提高讀性能的一種方法。開發者可以創建一個cache對象並指明cache capacity,然后傳入引擎中。cache對象可以在同一個進程中供多個DB Instance使用,這樣開發者就可以通過配置控制所有的cache使用。Block cache存儲的是非壓縮的數據塊內容。用戶也可以設置另外一個block cache來存儲壓縮數據塊。讀數據時首先從非壓縮數據塊cache中讀數據、然后讀壓縮數據塊cache。當Direct-IO打開的話,壓縮數據庫可以作為系統頁緩存的替代。RocksDB中有兩種cache的實現方式,分別為LRUCache和CLockCache。這兩種cache都會被分片,來降低鎖壓力。用戶設置的容量平均分配給每個shard。默認情況下,每個cache都會被分片為64塊,每塊大小不小於512K字節。
LRU Cache
默認情況,RocksDB使用LRU Cache,默認大小為8M。cache的每個分片都有自己的LRU list和hash表來查找使用。每個shard都有個mutex來控制數據並發訪問。不管是數據查找還是數據寫入,線程都要獲取cache分片的鎖。開發中也可以調用NewLRUCache()來創建一個LRU cache。這個函數提供了幾個有用的配置項來設置cache:
Capacity cache的總大小
num_shard_bits 去cache key的多少字節來選擇shard_id。cache將會被分片為2^num_shard_bits
strict_capacity_limit 很少會出現block cache的size超過容量的情況,這種情況發生在持續不斷的read or iteration 訪問block cache,pinned blocks的總大小會超過容量。如果有更多的讀請求將block數據寫入block cache時,且strict_capacity_limit=false(default),cache服務會不遵循容量限制並允許寫入。如果host沒有足夠內存的話,就會導致DB instance OOM。如果將這個配置設置為true,就可以拒絕將更多的數據寫入cache,fail掉那些read or iterator。這個參數配置是以shard為控制單元的,所以會出現某一個shard在capcity滿時拒絕繼續寫入cache,而另一個shard仍然有extra unpinned space。
high_pri_pool_ratio 為高優先級block預留的capacity 比例
Clock Cache
ClockCache實現了CLOCK算法。CLOCK CACHE的每個shard都有一個cache entry的圓環list。算法會遍歷圓環的所有entry尋找unspined entry來回收,但是如果上次scan操作這個entry被使用的話,也會有繼續留在cache中的機會。尋找並回收entry使用tbb::concurrent_hash_map。
使用LRUCache的一個好處是有一把細粒度的鎖。在LRUCache中,即使是查找操作也需要獲取分片鎖,因為有可能會更改LRU-list。在CLock cache中查找並不需要獲取分片鎖,只需要查找當前hash_map就可以了,只有在insert時需要獲取分片鎖。使用clock cache,相比於LRU cache,寫吞吐有一定提升。
當創建clock cache時,也有一些可以配置的信息。
Capacity same as LRUCache
num_shard_bits same as LRUCache
strict_capacity_limit same as LRUCache
Simulated Cache
SimCache是當cache capacity或者shard num發生改變時預測cache hit的方法。SimCache封裝了真正的Cache 對象,運行一個shadow LRU cache模仿具有同樣capacity和shard num的cache服務,檢測cache hit和miss。這個工具在下面這種情況很有用,比如:開發者打開了一個DB 實例,配置了4G的cache size,現在想知道如果將cache size調整到64G時的cache hit。
SimCache的基本思想是根據要模擬的容量封裝正常的block cache,但是這個封裝后的block cache只有key,沒有value。當插入數據時,把key插入到兩個cache中,但是value只插入到normal cache。value的size會在兩種cache中都計算進去,但是SimCache中因為只有key,所以並沒有占用那么多的內存,但是以此卻可以模擬block cache的一些行為。
4. MemTable
MemTable是一種在內存中保存數據的數據結構,然后再在合適的時機,MemTable中的數據會flush到SST file中。MemTable既可以支持讀服務也可以支持寫服務,寫操作會首先將數據寫入Memtable,讀操作在query SST files之前會首先從MemTable中query數據(因為MemTable中的數據一直是最新的)。
一旦MemTable滿了,就會轉換為只讀的不可改變的,然后會創建一個新的MemTable來提供新的寫操作。后台線程負責將MemTable中的數據flush到SST file,然后這個MemTable就會被銷毀。
重要的配置:
memtable_factory:memtable的工廠對象。通過這個工廠對象,用戶可以改變memtable的底層實現並提供個性化的實現配置。
write_buff_size :單個內存表的大小限制
db_write_buff_size: 所有列族的內存表總大小。這個配置可以管理內存表的總內存占用。
write_buffer_manager : 這個配置不是管理所有memtable的總內存占用,而是,提供用戶自定義的write buffer manager來管理整體的內存表內存使用。這個配置會覆蓋db_write_buffer_size。
max_write_buffer_number:內存表的最大個數
memtable的默認實現是skiplist。除了默認memtable實現外,用戶也可以使用其他類型的實現方法比如 HashLinkList、HashSkipList or Vector 來提高查詢性能。
Skiplist MemTable
基於Skiplist的memtable在支持讀、寫、隨機訪問和順序scan時提供了較好的性能。此外,還支持了一些其他實現不能支持的feature比如concurrent insert和 insert with hint。
HashSkiplist MemTable
如其名,HashSkipList是在hash table中組織數據,hash table中的每個bucket都是一個skip list,HashLinkList也是在hash table中組織數據,但是每一個bucket是一個有序的單鏈表。這兩種結構實現目的都是在執行query操作時可以減少比較次數。一種使用場景就是把這種memtable和PlainTable SST格式結合在一起,然后將數據保存在RAMFS中。當執行檢索或者插入一個key時,key的前綴可以通過Options.prefix_extractor來檢索,之后就找到了相應的hash bucket。進入到 hash bucket內部后,使用全部的key數據來進行比較操作。使用hash實現的memtable的最大限制是:當在多個key前綴上執行scan操作需要執行copy和sort操作,非常慢且很耗內存。
flush
在以下三種情況下,內存表的flush操作會被觸發:
- 內存表大小超過了write_buffer_size
- 全部列族的所有內存表大小超過了db_write_buffer_size,或者wrtie_buffer_manager發出了flush的指令。這種情況下,最大的內存表會被選擇進行flush操作。
- 全部的WAL文件大小超過max_total_wal_size。在這種場景下,內存中數據最老的內存表會被選擇執行flush操作,然后這個內存表對應的WAL file會被回收。
所以,內存表也可以在未滿時執行flush操作。這也是產生的SST file比對應的內存表小的一個原因,壓縮是是另一個原因(內存表總的數據是沒有壓縮的,SST file是壓縮過的)。
Concurrent Insert
如果不支持concurrent insert to memtable的話,來自多個線程的concurrent 寫會順序地寫入memtable。默認是打開concurrent insert to memtable,也可以通過設置allow_concurrent_memtable_write來關閉。
5. Write Ahead Log
對RocksDB的每一次update都會寫入兩個位置:1) 內存表(內存數據結構,后續會flush到SST file) 2)磁盤中的write ahead log(WAL)。在故障發生時,WAL可以用來恢復內存表中的數據。默認情況下,RocksDB通過在每次用戶寫時調用fflush WAL文件來保證一致性。
6. Write Buffer Manager
Write buffer mnager幫助開發者管理列族或者DB instance的內存表的內存使用。
- 管理內存表的內存占用在閾值內
- 內存表的內存占用轉移到block cache
Write buffer manager與rate_limiter和sst_file_manager類似。用戶創建一個write buffer manager對象,傳入 column family或者DBs的配置中。可以參考write_buffer_manager.h的注釋部分來學習如何使用。
Limit total memory of memtables
在創建write buffer manager對象時,內存限制的閾值就已經確定好了。RocksDB會按照這個閾值去管理整體的內存占用。
在5.6或者更高版本中,如果整體內存表使用超過了閾值的90%,就會觸發正在寫入的某一個column family的數據執行flush動作。如果DB instance實際內存占用超過了閾值,即使全部的內存表占用低於90%,那也會觸發更加激進的flush動作。在5.6版本以前,只有在內存表內存占用的total超過閾值時才會觸發flush。
在5.6版本及更新版本中,內存是按照arena分配的total內存計數的,即使這些內存不是被內存表使用。在5.6之前版本中,內存使用是按照內存表實際使用的內存
Cost memory used in memtable to block cache
從5.6版本之后,用戶可以將內存表的內存使用的占用轉移到block cache。不管是否打開內存表的內存占用,都可以這樣操作。
大部分情況下,block cache中實際使用的blocks遠比block cache中的數據少很多,所以如果用戶打開了這個feature后,block cache的容量會覆蓋掉block cache和內存表的內存占用。如果用戶打開了cache_index_and_filter_blocks的話,這三種內存占用都在block cache中。
具體實現如下,針對內存表分配的每一個1M內存,WriteBufferManager都會在block cache中put一個dummy 1M的entry,這樣block cache就可以正確的計算內部占用,而且可以在需要時淘汰掉一些block以便騰出內存空間。如果內存表的內存占用降低了,WriteBufferManager也不會立馬三除掉dummmy blocks,而是在后續慢慢地釋放掉。這是因為內存表空間占用的up and down太正常不過了,RocksDB不需要對此太過敏感。
- 把使用的block cache傳遞給WriteBufferManager
- 把WriteBufferManager的參數傳入RocksDB內存表占用的最大內存
- 把block cache的容量設置為 data blocks和memtables的內存占用總和
7. ycsb
YCSB, 英文全稱:Yahoo! Cloud Serving Benchmark (YCSB) 。是 Yahoo 公司的一個用來對雲服務進行基礎測試的工具, 目標是促進新一代雲數據服務系統的性能比較。由於它集成了大多數常用的數據庫的測試代碼,所以,它也是數據庫測試的一大利器.
1. 核心YCSB屬性
所有工作量文件可以指定以下屬性:
workload:要使用的工作量類(例如com.yahoo.ycsb.workloads.CoreWorkload)
db:要使用的數據庫類。可選地,這在命令行可以指定(默認:com.yahoo.ycsb.BasicDB)
exporter:要是用的測量結果的輸出類(默認:com.yahoo.ycsb.measurements.exporter.TextMeasurementsExporter)
exportfile:用於替代stdout的輸出文件路徑(默認:未定義/輸出到stdout)
threadcount:YCSB客戶端的線程數。可選地,這可以在命令行指定(默認:1)
measurementtype:支持的測量結果類型有直方圖和時間序列(默認:直方圖)
2. 核心工作量包屬性
和核心工作量構造器一起使用的屬性文件可以指定以下屬性的值:
fieldcount:一條記錄中的字段數(默認:10) (字段的意義類似於關系數據庫中表的每一列)
fieldlength:每個字段的大小(默認:100)
readallfields:是否應該讀取所有字段(true)或者只有一個字段(false)(默認:true)
readproportion:讀操作的比例(默認:0.95)
updateproportion:更新操作的比例(默認:0.05)
insertproportion:插入操作的比例(默認:0)
scanproportion:遍歷操作的比例(默認:0)
readmodifywriteproportion:讀-修改-寫一條記錄的操作的比例(默認:0)
requestdistribution:選擇要操作的記錄的分布——均勻分布(uniform)、Zipfian分布(zipfian)或者最近分布(latest)(默認:uniform)
maxscanlength:對於遍歷操作,最大的遍歷記錄數(默認:1000)
scanlengthdistribution:對於遍歷操作,要遍歷的記錄數的分布,在1到maxscanlength之間(默認:uniform)
insertorder:記錄是否應該有序插入(ordered),或者是哈希順序(hashed)(默認:hashed)
operationcount:要進行的操作數數量
maxexecutiontime:最大的執行時間(單位為秒)。當操作數達到規定值或者執行時間達到規定最大值時基准測試會停止。
table:表的名稱(默認:usertable)
recordcount:裝載進數據庫的初始記錄數(默認:0)
3. 測量結果屬性
這些屬性被應用於每一個測量結果類型:
直方圖
histogram.buckets:直方圖輸出的區間數(默認:1000)
時間序列
timeseries.granularity:時間序列輸出的粒度(默認:1000)
另外還有兩個重要的option:
delayed_write_rate:參考https://github.com/facebook/rocksdb/wiki/Write-Stalls。
RocksDB has extensive system to slow down writes when flush or compaction can't keep up with the incoming write rate. Without such a system, if users keep writing more than the hardware can handle, the database will:
- Increase space amplification, which could lead to running out of disk space;
- Increase read amplification, significantly degrading read performance.
The idea is to slow down incoming writes to the speed that the database can handle.
Whenever stall conditions are triggered, RocksDB will reduce write rate to delayed_write_rate
, and could possiblely reduce write rate to even lower than delayed_write_rate
if estimated pending compaction bytes accumulates. One thing worth to note is that slowdown/stop triggers and pending compaction bytes limit are per-column family, and write stalls apply to the whole DB, which means if one column family triggers write stall, the whole DB will be stalled.
對於全是寫的workload,delayed_write_rate肯定是越大越好。對於全是讀/讀寫混合的workload,應該是設置為某個值比較好(因為有read amplification)
target_file_size_base:這個是在Level Style Compaction中會用到的。target_file_size_base and target_file_size_multiplier -- Files in level 1 will have target_file_size_base bytes. Each next level's file size will be target_file_size_multiplier bigger than previous one. However, by default target_file_size_multiplier is 1, so files in all L1..Lmax levels are equal. Increasing target_file_size_base will reduce total number of database files, which is generally a good thing. We recommend setting target_file_size_base to be max_bytes_for_level_base / 10, so that there are 10 files in level 1.
Ref:
Tuning RocksDB – Options https://www.jianshu.com/p/8e0018b6a8b6
https://www.jianshu.com/u/aa9cae571502
https://www.jianshu.com/p/9b7437b5ea5b
https://zhuanlan.zhihu.com/p/37193700
https://github.com/facebook/rocksdb/wiki/RocksDB-Tuning-Guide