關鍵詞:MVCC HBase 一致性
本文最好結合源碼進行閱讀
什么是MVCC ?
MVCC(MultiVersionConsistencyControl , 多版本控制協議),是一種通過數據的多版本來解決讀寫一致性問題的解決方案。在隔離性級別中,MVCC可以解決“可重復讀”的隔離(即除了最后一級別的幻讀無法解決,幻讀只能事務串行化解決),基本是同一份數據並發條件下保證讀寫一致性的一個理想方案了。
一般情況下MVCC的一種實現思路是類似樂觀鎖(OCC,又叫樂觀並發控制) 的實現機制。樂觀鎖適用於寫沖突不大的並發場景,先執行寫入,檢查是否有沖突,若有沖突則回滾重來,否則提交寫請求成功。MVCC獲取最新的版本進行寫操作,如果失敗則回滾,成功則會將當前的版本作為可讀點;讀操作只能讀大於或小於當前版本的數據。這里用版本概念可能會有點混淆,通常可能是timestamp或seqID。
對於單行數據,MVCC非常美好;但對於多行數據事務的更新操作就有問題了。MVCC是在最后檢查才上鎖,所以,如果Transaciton1執行理想的MVCC,修改Row1成功,而修改Row2失敗,此時需要回滾Row1,但因為Row1沒有被鎖定,其數據可能又被Transaction2所修改,如果此時回滾Row1的內容,則會破壞Transaction2的修改結果,導致Transaction2違反ACID。因此,一般MVCC實際會配合二階段鎖(2PL)去實施。這樣做雖然寫事務被迫串行化了,但純讀取的事務不受鎖影響且能保證最終的讀寫一致性。
HBase里的MVCC
HBase里雖然利用了版本這個概念做到了Cell層面的Version,HBase應用側的version一般使用毫秒級時間戳作為版本,基於LSM的數據修改機制也是利用這個version來實現,包括官方一直未解決的同一毫秒內Delete和Put語義順序問題(參考:https://yangzhe1991.org/blog/2016/06/hbase-versions-delete-limitations/)。其實這個問題就體現了Version機制的好處,以及由於Version定為毫秒級時間戳不夠唯一導致的Version機制崩潰的並發問題。
但HBase真正的MVCC實現則是在HRegion中的寫操作的實現。HRegion采用的是一次封鎖法(示例代碼可參見 HRegion.doMiniBatchMutation(BatchOperationInProgress<?> batchOp) )。
封鎖步驟:
1. 對當前事務的所有行獲取行鎖(其中doMiniBatchMutation不會阻塞獲取所有行鎖,而是獲取多少處理多少,然后在外部循環直至所有mutation操作完成;其他則會阻塞等待行鎖)
2. 對region級別的updateLock 進行上讀鎖。updateLock是個讀寫鎖,並發行級寫操作的時候上讀鎖,region級別的寫操作(flush,dropMemStore)的時候上寫鎖全部寫操作阻塞。
MVCC的實現類是MultiVersionConsistencyControl,是個Region級別的MVCC控制。當有寫操作來時,MVCC會做如下事情:
1. HRegion級別的seqID自增加一,並且當前 writeNo 設為 seqID + 1000000000。 這個大數的意義是防止別的寫操作提交時把readNo提高了,導致當前writeNo成為一個可讀狀態的id,后面會將其設回正常的seqID (1.1.2 這里貌似有個坑)。
2. 把當前的寫操作的一個包含seqID的dummy對象 WriteEntry加進隊列。
3. 對於實際寫操作本身,先寫memstore,再寫WAL,如果中間失敗則回滾,否則則當做成功繼續執行。
4. 不管失敗成功,當前這個seqID都是不可再用的了,然后MVCC內排隊等待處理當前寫請求提交。
5. 寫請求提交實際上就是把當前HRegion級別的readNo設為隊列中已完成的寫請求(包括別的線程的寫請求)的seqID最大值,表示seqID以下的寫請求都處理完了,可讀。
1中提到了設回seqID的坑,正常做法可能是在當前線程cache住AtomicLong自增后的新seqID作為唯一id,但是HBase並沒有這樣做,而是在較后的build WAL代碼中好幾個地方調用getSequenceID去設到WALKey里。如果這時候有另一個寫請求自增了,然后后續沒寫請求了,此時兩個WriteEntry的seqID是一樣的。而且寫操作的流程看下來,很可能實際的seqID是NO_SEQUENCE_ID=-1。關於這個問題我翻了一下issue沒找到比較相關的,倒是由於一些同步自增的performance問題被人改過(確實挺繞的)。於是我特地去看了1.3.1(1.x 最后的版本)的這部分代碼,發現變動很大,MVCC的部分調用邏輯還移到了 WALKey里了。
HBase 1.3.1 的 寫操作封鎖步驟不變(實際寫的步驟也略有變化,把非IO型的構建WALEdit操作從MVCC事務中提出,顯然使事務更細粒度), MVCC流程:
1. 去掉HRegion的seqID,writePoint和readPoint統一改由MVCC內維護
2. 開始構建WALEdit,其中在FSWALEntry.stampRegionSequenceId() 方法中會自增writePoint,並WALKey.setWriteEntry。
3. 另一邊獲取WALKey.getWriteEntry是個異步的過程(用了Disruptor做線程間消息通信),get方法會等待直到第一次set完成才獲取出writeEntry。注意此時沒有了1.1.2的加一個大數的機制。
4. 獲取到writePoint后,開始寫memstore和同步WAL(即實際的寫操作),如果成功則和1.1.2類似,將readPoint設為已完成的最大writePoint,並調用waitForRead校驗readPoint >= currentWritePoint。若失敗一樣要調整readPoint,只是不用校驗。
這里有幾個改變點:
1. HBase1.1.2會加上1億來防止並發的readPoint調整大於當前值,實際在1.3的實現方案中沒必要。因為在MVCC的隊列中,排在前面的寫請求沒完成,后面的是無法完成屬於自己的WriteEntry的complete操作的,也就是說比自己后添加WriteEntry到隊列的寫請求是不用擔心的。因此1.3.1將自增writePoint和添加隊列封裝了一個原子方法 MultiVersionConcurrencyControl.begin() ,解決了前文所說的1.1.2每個操作相距甚遠同步風險較大的問題。
2. HBase1.1.2的MVCC在complete邏輯是等待之前的寫操作完成排到自己,原子操作將隊頭所有completed的WriteEntry移除,並將它們的最大值作為readPoint。HBase1.3.1的邏輯是嚴格保證寫請求順序,移除隊列頭completed的WriteEntries並設最后的那個(就是最大值,因為有序)為readPoint。 waitForRead被單獨拎出來作為一個方法,用來解決萬一因為同步問題readPoint小於當前的writePoint了,則強制阻塞直到恢復正常的MVCC機制為止。目前還沒想到不知道是前面什么操作失敗的情況下會出現readPoint<writePoint的情況,但是1.3.1的機制顯然更加清晰和安全。
