OceanBase 0.4的UpdateServer存儲引擎使用了一棵可以多線程並發修改的BTree,讀寫不沖突,由顏然 開發。線上運行穩定。
UpdateServer存儲引擎采用類leveldb的方式,最近的更新操作都寫入內存中的一個活躍memtable,當活躍memtable占用內存達到某個閾值時,即凍結,dump到磁盤上形成sstable,從而釋放內存。然后會開啟一個新的memtable供隨后的寫入。與leveldb不同的是,leveldb采用skiplist做為核心存儲數據結構,而UpdateServer采用B+Tree(實際上,和B+ Tree不一樣,葉子節點沒有通過鏈表連起來)。
從需求上看,數據結構不需要支持物理刪除,只需要在上層支持標記刪除。翻看leveldb的skiplist,它也不支持刪除,在不支持刪除的情況下,skiplist很容易做到讀寫不沖突,只需要利用一些memory barrier。寫寫沖突還是有,需要外部進行同步。
同理,BTree也不支持刪除, 支持插入更新,查詢,節點可以分裂,不能合並。寫操作基於讀寫鎖+Copy On Write,讀操作不用加鎖,Copy On Write產生的老節點不立即釋放,以保證讀操作繼續能讀到老節點。
寫操作流程
1. 從樹根節點開始到達葉子節點,對所有的節點加上共享鎖
2. 將葉子節點的共享鎖升級為排他鎖(鎖結構用qlock表示),只有一個寫線程可以升級成功,升級失敗的寫線程將搜索路徑上所有節點的共享鎖釋放。
3. 升級成功的寫線程檢查葉子節點是否滿,分兩條路徑:
3.1 如果不滿:
3.1.1 將葉子節點A拷貝一份出來,新葉子節點記作B,然后往B中寫入Key/Value對
3.1.2 修改父親節點指針指向新的葉子節點B(有可能別的寫線程由於分裂這個父親節點的其他葉子節點導致需要分裂這個父親節點,為了防止這種並發修改,分裂節點前需要加排他鎖,修改指針需要加共享鎖)
3.2 如果葉子節點滿:
3.2.1 分配兩個葉子節點記作C和D,將老葉子節點A的左半放入Copy到C,右半Copy到D中,同時插入輸入Key/Value
3.2.2 升級父親節點的共享鎖為排他鎖(如果兩個寫線程同時發現父親節點需要分裂,那么只有一個寫線程能夠升級排他鎖成功,升級失敗的寫線程會將自己已持有的
排他鎖降級為共享鎖,然后將所有的持有的共享鎖都釋放)
3.2.3 往父親節點中put一個新的指針,以指向分裂后的新的葉子節點,這個put操作同時也需要遞歸的處理父親節點分裂
3.2.4 put成功后,將當前節點的所有上層節點的共享鎖從上往下依次釋放,然后釋放當前節點的排他鎖。
5. 將葉子節點A加入到寫操作的輸入參數recycle_node中,寫操作過程中所有的通過Copy On Write而遺留的老葉子節點都會加入進去,recycle_node內部是一個鏈表
6. 至此,寫操作成功,將當前節點的所有上層節點的共享鎖釋放,將當前節點的排他鎖釋放。
內存回收
由於寫操作采用Copy On Write的機制,所以每次成功的寫操作都會替換下至少一個TBtreeNode節點,如上圖,圓形的節點代表替換下來的TBtreeNode。每次寫操作所有的替換下來的節點都用一個TRecycleNode節點管理起來,上圖中的方形節點。全局只有一個這樣的TRecycleNode鏈表,每次寫操作開始都會從線程局部分配一個TRecycleNode,隨后將替換下來的TBtreeNode全部納入其中,最后寫操作結束時,將TRecycleNode掛在鏈表的最右端,即尾部。
由於讀操作不加鎖,所以被某次寫操作替換下來的TBtreeNode不能馬上重用,因為這個節點可能正在被別的線程讀。需要一種機制來從全局的TRecycleNode鏈表中回收不再被任何線程訪問的TRecycleNode和TBtreeNode以重用。節約內存。
目前實現采用的機制比較特殊:全局有兩個鏈表指針,一個指向鏈表頭,一個指向鏈表尾。每次讀寫操作開始時,都需要對鏈表尾部節點加上引用計數。操作結束時,減引用計數。如上圖中的TRecycleNode B,回收過程保證引用計數不為0的TRecycleNode及其內部的TBtreeNode不被回收或重用。由於只有寫操作才需要分配TRecycleNode和TBaseNode,所以每次寫操作結束后都會試圖去全局TRecycleNode回收鏈表中去回收節點,實際上,只有線程發現線程局部可用節點數低於某個閾值才會去全局TRecycleNode回收鏈表中回收。
回收的過程就是去拿一把全局的鎖,拿到了就可以進行回收:從鏈表頭部開始檢查回收TRecycleNode 引用計數為0的節點,一直回收到線程局部可用節點達到某個閾值上限。
顯然,當TRecycleNode引用計數為0時,代表它及其內部的TBtreeNode不會再被任何線程訪問,可以安全的回收。
具體實現中,這其中有一些需要注意的實現細節,不再贅述。這種方法某種程度上會影響寫性能和內存突增,因為寫操作結束后,回收內存的過程是多線程並發的,大家搶一把全局鎖,只有搶鎖成功的線程才能進行回收。沒有搶到鎖的線程不能回收任何節點內存到線程局部,導致后續該線程執行寫操作時就需要從系統分配內存。后續將改進這個過程。
讀操作全程不加鎖,不贅述。
鎖實現
以上描述的鎖是通過qlock實現的,原理如下: