初探富文本之CRDT協同算法

初探富文本之CRDT協同算法

CRDT的英文全稱是Conflict-free Replicated Data Type,最初是由協同文本編輯和移動計算而發展的,現在還被用作在線聊天系統、音頻分發平臺等等。當前CRDT算法在富文本編輯器領域的協同依舊是典型的場景,常用於作爲實現文檔協同的底層算法,支持多個用戶同時編輯文檔,不會因爲用戶併發修改導致衝突,而導致結果不一致甚至數據丟失的問題。

描述

Conflict-free Replicated Data Type直譯過來就是無衝突的複製數據類型,從名字可以看出來,CRDT的重點在於無衝突複製和數據類型,去掉定語的話就可以得到CRDT是一種數據結構,也就是說CRDT是通過數據結構來保證最終一致性的。在分佈式系統中,不同節點之間的數據複製存在一致性問題即強一致性問題,CRDT是作爲一種理論來指導如何將原有數據結構設計成在數據複製過程中通向最終一致性的一種新的數據結構。假設此時我們擁有多個副本或者操作,如果託管副本的計算機之間沒有協調,此時進行合併的話則可能導致副本之間的不一致,這通常是無法解決的,當更新存在衝突時,要恢復一致性和數據完整性,可能需要部分甚至全部更新的刪除。由此CRDT指導我們在有多個副本或者操作進行合併或者更新時,使得我們的數據能根據一定的規則自動合併、解決衝突,最終達到強一致性的效果。回到富文本協同上,文檔協同編輯同樣可以理解爲分佈式應用的一種,而CRDT通過數據結構的設計保證併發操作數據的最終一致性。簡單來說,CRDT就是可以在網絡中的多個終端上覆制的數據結構,副本可以獨立和並行地更新,不需要在副本之間進行協調,並保證不會有衝突發生,從而保證最終各個副本的內容一致。

在討論具體的協同算法之前,我們探究一下爲什麼要有協同算法,如果沒有協同算法的話會出現什麼問題,以及具體會出現問題的場景。那麼假如我們有一個在線的文檔應用,而我們是一個團隊,我們有可能對同一篇文檔進行編輯,既然我們會同時編輯,那麼就有可能產生衝突。假設文檔此時的內容爲A,此時U1U2兩位用戶同時在編輯,也就是說這兩位都是從文檔的A狀態開始編輯,當U1編輯完成之後,文檔狀態是BU1對文檔進行了保存,此時U2也寫完了,文檔狀態是CU2也對文檔進行了保存,那麼此時文檔的狀態就是C了,由U1編寫的A -> B狀態的內容修改便丟失了,爲了解決這樣的問題,通常有以下幾個方案。

樂觀鎖

樂觀鎖,主要就是一種對比於悲觀鎖的說法,因爲樂觀鎖的操作過程中其實沒有沒有任何鎖的參與,嚴格的說樂觀鎖不能稱之爲鎖。樂觀鎖總是假設最好的情況,每次去拿數據的時候都認爲別人不會修改,所以不會上鎖,可能需要在更新的時候會判斷一下在此期間別人有沒有去更新這個數據提示一下,或者乾脆不會給予任何的提示信息。

那麼具體到文檔編輯上邊,我們可以樂觀地認爲永遠不會有兩個人同時編輯同一篇文檔,現實中也可能有這種情況,比如團隊中每個人只負責幾篇文檔,其他人不需要也沒有權限去編輯自己負責之外的文檔,那麼基於這種要求,我們可以樂觀地認爲永遠不會出現衝突的問題,那麼自然也就不需要對文檔的管理做任何限制了,只需要完整地提供編輯能力即可。

悲觀鎖

悲觀鎖,顧名思義是基於一種以悲觀的態度類來防止一切數據衝突的方式,其以一種預防的姿態在修改數據之前把數據鎖住,然後再對數據進行讀寫,在其釋放鎖之前其他的任何人都不能對數據進行操作,直到前面一個人把鎖釋放後下一個人纔可對數據進行加鎖,繼而纔可以對數據進行操作,通過這種方式可以完全保證數據的獨佔性和正確性。

那麼具體到文檔編輯上邊,我們可以對同一篇文檔的編輯操作權限進行加鎖,這樣就可以保證同一時間只有一個人可以對文檔進行編輯,其他人只能等待,直到前面的人把文檔編輯完成並且釋放鎖之後,下一個人才可以對文檔進行編輯,當然也可以開一個口子允許強行搶佔並且將被搶佔者的現場存儲下來,相當於將一個併發操作壓成了線性操作,這樣就可以通過獨佔的方式保證文檔的正確性,避免文檔的內容衝突與丟失。

自動合併

自動合併,文檔內容自動合併以及衝突處理的方式也是一個可行的方案,類似於Git的版本管理思想,可以對提交的內容進行diff差異對比、merge合併等操作,也可以在出現無法解決的衝突時出現時交給用戶主動處理,GitBook是採用這種方式解決衝突問題的。

協同編輯

協同編輯,可以支持多個用戶同時編輯文檔,不會因爲用戶併發修改導致衝突,而導致結果不一致甚至數據丟失的問題。協同編輯重點在於協同算法,主要有Operational Transformation(OT)Conflict-free Replicated DATA Type(CRDT)兩種協同算法。協同算法不需要是正確的,其只需要保持一致,並且需要努力保持你的意圖,就是說協同算法最主要的目的是在儘可能保持用戶的意圖的情況下提供最終的一致性,重點在於提供最終一致性而不是保持用戶的意圖。當前石墨文檔、騰訊文檔、飛書文檔、Google Docs都是基於OT協同算法的,Atom編輯器使用的是CRDT協同算法。

CRDT協同算法

Conflict-free Replicated DATA Type(CRDT)協同算法的核心思想在於解決衝突,而在於構造一種數據結構來避免衝突,避免了衝突就可以直接進行合併,最終得到文檔內容。CRDT協同算法的目的是在儘可能保持用戶意圖的情況下,保持文檔的最終一致性,舉個例子,當AB同時在文檔的L處插入了不同的字符,那麼誰插入的字符在前協同算法並不關心,其只需要儘可能地根據一定策略例如時間戳來判斷究竟是誰的字符在前,但是最終計算出的結果即究竟誰的字符在前並不影響協同算法,其關心的重點在於經過協同算法將用戶產生的Op調度之後,在每個人面前呈現的文檔內容是絕對一致的,這就是保持文檔的最終一致性。從功能的角度上說,協同算法保證的是在多人同時在線編輯的情況下,由於每個人提交的內容是不一樣的,就需要通過協同算法的調度,使得每個用戶最終都能看到一樣的內容。實際上在線文檔本身就是一個數據一致性要求很強的項目,所以無論是使用CRDT算法還是OT算法來實現協同,保證最終一致性就是必須要考慮的基本內容。

由於CRDT設計上可以完成對於各個副本的合併與更新而不會產生衝突,那麼經由CRDT實現的算法就可以直接在客戶端之間互相傳遞,相互同步至最終一致性的狀態,也就是各個用戶之間可以直接P2P進行數據合併而不需要中央服務器進行調度,由此CRDT可以很好地支持去中心化的應用,即使沒有中心化服務器各端之間也能完成同步。

談到了最終一致性和分佈式系統,那麼就不得不提到CAP理論,CAP理論指出在一個分佈式系統中,最多隻能同時滿足Consistency(一致性)、Availability(可用性)和Partition Tolerance(分區容錯性)中的兩項。

  • 一致性Consistency: 對於客戶端的每次讀操作,要麼讀到的是最新的數據,要麼讀取失敗。換句話說,一致性是站在分佈式系統的角度,對訪問本系統的客戶端的一種承諾:要麼我給你返回一個錯誤,要麼我給你返回絕對一致的最新數據,不難看出,其強調的是數據正確。
  • 可用性Availability: 任何客戶端的請求都能得到響應數據,不會出現響應錯誤。換句話說,可用性是站在分佈式系統的角度,對訪問本系統的客戶的另一種承諾:我一定會給你返回數據,不會給你返回錯誤,但不保證數據最新,強調的是不出現響應錯誤。
  • 分區容錯性Partition tolerance:由於分佈式系統通過網絡進行通信,網絡是不可靠的。當任意數量的消息丟失或延遲到達時,系統仍會繼續提供服務,不會掛掉。換句話說,分區容忍性是站在分佈式系統的角度,對訪問本系統的客戶端的再一種承諾:我會一直運行,不管我的內部出現何種數據同步問題,強調的是不掛掉。

對於一個分佈式系統而言,P是前提,必須保證,因爲只要有網絡交互就一定會有延遲和數據丟失,這種狀況我們必須接受,必須保證系統不能掛掉,所以只剩下CA可以選擇,要麼保證數據一致性即數據絕對正確,要麼保證可用性即保證響應不出錯。首先我們要理解一下爲什麼P是前提,這裏的場景是分佈式系統,網絡是不可靠的,一定會存在網絡延遲與丟失等問題,如果不允許存在網絡分區也就是說網絡是一直保證運行正常的,那麼顯然每次寫入數據都可以進行同步,自然一致性和可用性顯然得到了保障,但這也不是分佈式系統了。

我們來舉個例子,在網絡發生問題時,爲什麼需要在CA之間進行選擇,假設在分佈式系統中存在100個節點,但由於故障,使得網絡發生了分區,其中有一半的節點無法向另外一半節點通信,於是系統被分割爲A區與B區。在網絡分區的情況下,客戶端發送請求嘗試來對A區一個節點進行數據寫入,由於AB區網絡不通,這時候無法同步寫入信息給到B區節點。在這種場景下,究竟允不允許當前客戶端進行數據寫入呢。

  • 如果允許客戶端數據寫入,那麼當前節點的可用性得到了保證,但是由於網絡分區,所以網絡不可觸達,數據無法同步。因此此時是無法滿足一致性,也就是分佈式系統中,同時訪問兩個節點,可能會返回不同數據。
  • 如果不允許客戶端數據寫入,那麼當前節點的一致性得到了保證,所有節點數據都是一致的,但是由於數據都無法寫入,這時系統顯然是不可用的,需要阻塞等待,直到網絡連接恢復,也就是可用性無法滿足。

實際上CAP特性三選二的描述其實具有誤導性,從上邊的例子中也可以看出,不是在所有時候都只能選擇兩個特性,在不存在網絡失敗的情況下即分佈式系統正常運行時,CA能夠同時保證,只有當網絡發生分區或失敗時,纔會在CA之間做出選擇,但是誰也無法保證任何時刻網絡都是暢通無阻的,所以必須要處理在這種情況下的策略,以此來取得CA的權衡。作者Eric Brewer2012年也發表論文解釋了CAP實際上只是禁止了設計空間存在分區時的完美可用性和一致性。而實際上在CA之間的權衡的設計非常靈活,CRDT就是一個很好的例子。CRDT算法正是在滿足Partition Tolerance(分區容錯性)的前提下,儘可能地保證Consistency(一致性)和Availability(可用性),同樣OT協同算法也是一樣通過保證最終一致性來完成這個權衡的。

在協同編輯的場景下,我們似乎能夠同時滿足了CAP的三個場景限制,假設在網絡差的情況下,兩個客戶端同時提交,雖然暫時一致性無法滿足,本地客戶端會看到不同的內容,但網絡恢復後,通過協同算法數據也能保持一致,這不是既滿足了一致性又滿足了可用性。這個想法是對的,只是這裏我們保證的一致性不等於CAP理論的一致性,CAP理論是假設在沒有網絡延遲的情況下的強一致性,也就是數據時刻都是一致的,而協同編輯的場景的一致性則是最終一致性。前文也提到過,在CA之間的權衡的設計非常靈活,既然無法做到強一致性Strong consistency,那麼應用可以根據自身的業務特點,採用適當的方式來使系統達到最終一致性Eventual consistency,這個就又需要說到BASE理論了。

  • BA: Basically Avaliable,基本可用,允許損失部分可用性,允許犧牲響應時間、降級系統功能等操作。
  • S: Soft State,軟狀態,允許存在數據中間狀態,不要求強一致性,並不影響整體可用性,允許副本之間的數據同步延遲。
  • E: Eventually consistency,最終一致性,所有的副本,在最終都能達到數據一致的狀態,但不要求實時的強一致性。

BASE理論中,我們可以看到協同編輯文檔的場景中,雖然CAP的強一致性無法滿足,但通過靈活地設計CA,損失部分可用性,允許暫時的客戶端不一致情況,通過協同編輯衝突算法,可以解決數據不一致問題,達到了最終的數據一致性。實際上在分佈式理論上又有很多研究,有着強一致性、弱一致性、順序一致性、最終一致性、因果一致性等,而最終一致性也有讀寫一致性、寫讀一致性、單調讀一致性、單調寫一致性等劃分,在這裏就不再贅述了。

之後我們再簡單舉一個例子,看看應該如何去實現最終一致的分佈式系統。假設我們當前有一個賬戶是T,初始時這個賬戶有100塊錢,用戶可以通過系統裏面好幾個節點,例如ABC,訪問T這個賬戶,並且可以在任何時刻都對T進行操作。注意此時我們並沒有中央服務器來保存這100塊錢,而是每個賬戶都各自存儲了這個數字,在這個分佈式系統中我們不考慮安全方面的因素,我們只需要最終保證每個人的數據都是一致的即可。

假設某個時刻t1AT中存了10塊錢,B則向T中取了10塊錢,C則在接下來的一個時刻查詢T有多少錢,這幾個操作都是同時發生的。那麼,我們的分佈式系統會保證這三個操作都能完成,於是在本地:

  • A系統看來,T110塊錢。
  • B系統看來,T90塊錢;
  • C系統看來,T100塊錢。

在這個時刻t1,這三者都是對的,因爲我們可以在最終一致性系統中,存在不一致的時刻,那麼經過一段時間之後,假設是t2,我們需要使得ABC系統看來,T都有100塊錢,即保證最終一致。那麼這中間肯定需要做一些操作,即ABC系統之間交換一些必要的信息數據。那麼現在麻煩來了,我們需要如何進行數據交換,如果在各節點我們只是存了T的餘額,例如用一個整數變量,這樣顯然是不行的,因爲當A系統和B系統的數據傳輸到C系統時,C無法分辨A或者B系統的結果到底誰對。那麼重點來了,我們應該如何處理這個問題,最簡單的處理方案,加入額外的數據,通過額外的數據來保證合併的正確,這也就是CRDT的重點。

那麼在這個例子裏面,我們可以這樣設計,每個系統存儲的不是一個最終數值,而是一系列包含了時刻與餘額的記錄,假設我們的系統是從t0時刻開始的,那麼在我們的例子裏面,t1時刻存儲的數據如下所示:

  • A系統存儲的是(t0, 100)(t1, 110)
  • B系統存儲的是(t0, 100)(t1, 90)
  • C系統存儲的是(t0, 100)(t1, 100)

那麼在t2時刻,我們進行了數據傳輸,對於C而言收到了A t0->t1 +10的操作,收到了B t0->t1 -10的操作,由此Ct2時刻依舊是100;而對於A而言,收到了B t0->t1 -10的操作,C沒有更新操作所以不需要同步;對於B而言收到了A t0->t1 +10的操作,C沒有更新操作所以不需要同步。最終ABC三者得到了一致的數據總和表示100,由此得到了最終一致性。當然這個例子足夠簡單,答案也足夠的粗糙,而且在實際的應用中,我們必須要考慮更多的數據類型和應用場景,於是設計一個能夠保持最終一致性的數據結構,就會變成一件很難的事情。由此才需要圍繞着CRDT的理論進行分佈式系統的設計,從而減少我們的對於分佈式協同設計的心智負擔。

回到協同,在瞭解CRDT協同算法之前,我們也可以瞭解一下CRDT協同算法與OT協同算法的主要區別。首先CRDTOT都提供了最終一致性,這也是協同編輯的最終目標,但是這兩種方案達成這一目標的方式不一樣:

  • CRDT無衝突複製數據類型是通過數據結構來做到這一點,CRDT有兩種實現方式,基於狀態的CvRDT收斂複製數據類型和基於操作的CmRDT可交換複製數據類型。CvRDT是將各個副本進行合併,進行多少次合併或以何種順序進行合併並不重要,所有副本都會收斂。CmRDT則具有可交換的操作,因此無需轉換操作即可正確應用這些操作。
  • CRDT更適合分佈式系統,可以不需要中央服務器。
  • CRDT通過數據結構保證了編輯的無衝突,增加了空間複雜度。
  • OT操作轉換則是通過操作Operation轉換Transformation來做到這一點,終端所進行的操作O通過網絡傳輸,其他終端在收到操作O後需要進行轉換T,之後纔可以應用到文檔上,最基礎的OT是通過轉換索引位置以確保收斂。
  • OT通常必須要有中央服務器進行協同調度。
  • OT通過算法處理編輯衝突的問題,增加了時間複雜度。

基本原理

在上邊我們討論到了CAP定理,我們需要在一致性與可用性之間做出取捨,能夠達到最終一致性而暫時地損失部分可用性是完全可以接受的,不過無論如何在實際情況中設計這樣一個分佈式系統還是非常複雜的,而CRDT就是是這樣一個通向最終一致性的理論,可以指導我們去正確地設計一個高可用的分佈式系統。CRDT同樣也並不僅僅應用在在協同領域,當前各種分佈式系統和應用均開始嘗試CRDTredislabsriak已經實現多種數據結構,微軟的CosmosDB也在azure上使用CRDT作爲多活一致性的解決方案。

那麼CRDT究竟代表了什麼,其是如何做到合併時不會出現衝突的。在分佈式系統中複製有兩種實現途徑,一種是在主節點和從節點之間複製全量狀態,還有一種是就是複製操作本身。簡單解釋一下,全量複製狀態類似於我們把當前正在編寫的整個文檔都複製出來同步到其他客戶端,而複製操作本身類似於我們只把當前編輯時造成的Op複製出來同步到其他客戶端。那麼如果是複製狀態,也就是全量複製,就需要有一些狀態收斂規則,因此我們就可以創建Convergent Replicated Data Types,也就是CvRDT基於狀態的收斂複製數據類型,也被稱爲State-based CRDT。而如果是複製操作,那麼這個操作就需要被設計爲Commutative可交換的,而並不需要進行操作變換,由此可以創建Commutative Replicated Data Types,也就是CmRDT可交換複製數據類型,也被稱爲Op-based CRDT。在這兩種情況下,目標都是通過確保操作彼此不會衝突獨立發生從而爲了避免協調,所以可以總稱爲Conflict-free Replicated Data Type,也就是CRDT

State-based CRDT

基於狀態的CRDT,名字聽着很嚇人,實際上突然理解起來也挺嚇人,但是拆開看就沒有那麼難以理解了。State-based CRDT的結點和結點傳輸的是狀態,存在一個算子Merge: (SA, SB) => SC, 收到傳輸的狀態就和自己存儲的狀態Merge,這個算子必須滿足交換律、結合律和冪等律,所以如果用抽象代數的角度形容地話,其使整個狀態系統形成了一個半格。所以最終只要能接受到其他所有節點的新狀態,那麼永遠能保證系統最終會收斂,由此並不會發生衝突。這樣也去除了對於CmRDT的底層通訊機制依賴,但是因爲傳遞的是狀態,所以最後傳輸可能有點大,當然也是存在優化策略的。

上邊這段話可能有些難以理解,我們慢慢拆開來研究一下,首先我們看下算子Merge: (SA, SB) => SC,這也就是說我們同步狀態的內容是需要合併的,這也就是我們做State-based CRDT的目的,即收到傳輸的狀態就和自己存儲的狀態Merge。接下來就是我們這個算子需要保證的條件了,先來回顧一下交換律、結合律和冪等律:

  • 交換律: A ∪ B = B ∪ A
  • 結合律: (A ∪ B) ∪ C = A U (B U C)
  • 冪等律: A U A = A

看到這三個公式我們就可以比較容易地理解這個爲什麼這個算子需要滿足這三律了,我們首先需要關注的一點是網絡是不可靠的,而分佈式的系統必須要依賴於網絡進行數據傳輸,由此我們來看下爲什麼需要保證這三個公式:

  • 交換律,假設我們有AB兩位用戶相互進行數據傳輸,A數據同步到B就是A ∪ BB數據同步到A就是B U A,那麼在需要保證最終一致性的前提下則必須保證交換律進而能夠保證正確地進行數據合併,即應用順序無關緊要。
  • 結合律,假設我們有ABCDE五位用戶相互進行數據傳輸,此時ABC對數據進行了改動,對於DE而言就需要從其他三個客戶端進行數據同步,由於存在網絡延遲,無法哪個客戶端的位置先行到達,也就是D的合併順序可能是A B C,而E的合併順序可能是B C A,所以需要滿足結合律才能夠保證正確進行數據合併,即分組無關緊要。
  • 冪等律,假設我們有AB兩位用戶相互進行數據傳輸,當A用戶進行改動後將副本進行了網絡傳輸,但是久久沒有得到ACK確認,那麼此時可能會有一個超時重傳的機制,我們又將A發送了出去,但是巧合的是剛纔那個包並沒有丟失,只是傳遞比較慢,而新的A包由於選擇了其他的路由線路也沒有丟失,此時兩個相同的A包同時到達了B,由於B未發生修改,B合併了第一個A之後相當於內容與A相同,此時再合併第二個A就需要保證冪等性從而保證能夠正確進行數據合併,即重複無關緊要。

可以看出其實滿足上邊三個公式都是由於網絡的不可靠,而我們爲了保證最終一致性從而需要對數據結構進行設計。此外需要注意的是如果我們能夠保證Exactly once的語義,則冪等律條件是可以放寬的,舉個例子加法本身滿足交換律結合律但不冪等,假如此時我們能夠保證加法操作只複製一次,其結果還是最終一致的。實際上保證了上述三個定理,我們在分佈式系統同步數據的時候就可以無需考慮合併的順序以及多次合併的問題了,且能夠保證最終一致性。

下邊再看一下半格的概念,在瞭解半格之前我們需要探討一下偏序集的概念,實際上了解了半格之後,我們就可以大概理解CRDT如何做到合併不會出現衝突的問題了。設P是集合,P上的二元關係滿足以下三個條件,則稱P上的偏序關係或部分序關係。

  • 自反性: ∀a∈Pa≤a
  • 反對稱性: ∀a, b∈P,若a≤bb≤a,則a=b
  • 傳遞性:∀a, b, c∈P,若a≤bb≤c,則a≤c

需要注意的是這裏的不必是指一般數學意義上的小於或等於,我們通常認爲其意義是x排在y前面x precedes y,也就是說這個偏序關係不一定是比大小,也並不要求集合元素之間是數字,關鍵要如何定義這個二元關係,只要要滿足上述三條性質,即是偏序關係。可能有些抽象哈,我們舉個例子,自然數的集合配備了它的自然次序即小於等於關係,那麼這個偏序是全序。我們同樣也可以定義一個關係爲子集,也就是說我們可以通過子集來比較集合,從而構造一個偏序關係,下面這個例子中就可以由子集偏序關係看出{} ≤ {x} {x} ≤ {x, y} {x, y} ≤ {x, y, z} ...

         {x, y, z}
{x, y}    {x, z}    {y, z}
  {x}       {y}       {z}
             {}

接下來我們再看一下格相關的概念。格是一個偏序集,具有不同的頂部(最小上界)和不同的底部(最大下界)。半格同樣也是一個偏序集,且每個非空集合都有上確界或者下確界,而上確界被稱爲Least upper boundLUB。也就是說半格相較於格來說,只有一個不同的頂部或底部,所以是在名字上也可以看出是叫做半個格。連接半格是具有不同頂部(最小上界)的半格,而相交半格是具有不同底部(最大下界)的半格。任何表示爲半格的數據類型都可以實現爲保證收斂的數據結構。例如,計算一組值的Max()將始終返回相同的結果,無論接收值的順序如何,只要最終接收到所有值,因爲Max()操作是保證了交換律、結合律和冪等律的。依舊拿上邊的這個集合例子來說,我們此時改變了定義,在這裏有兩種格子,對於集合是定義了Union(items)格子,對於數據是定義了Max(value)格子,對於兩個半格而言,我們保證了一個最大上界,在例子中我們三個集合的最終一致性數據爲{x, y, z} 3

         {x, y, z}                 3   
{x, y}    {x, z}    {y, z}    2    3    3
  {x}       {y}       {z}     1    2    3
             {}                   null

在經歷了半格、偏序集之後,似乎我們最終又回到了交換律、結合律和冪等律,實際上我們在半格中也提到了,只要我們能夠定義一個有意義的最小上界LUB函數,我們就能創建CRDT。如果上邊的Max(x, y)不是很直觀的話,我們在本文數據結構一節中會有更多的數據結構示例來解釋這個問題。或許在富文本的數據結構中並沒有Max(x, y)這麼簡單地能夠滿足半格條件的實現方法,但是別忘了我們可以爲數據附加額外的信息,在我們前邊提到了CRDT是通過數據結構保證了編輯的無衝突,從而增加了空間複雜度,而數據結構的設計很重要的一點就是攜帶其他的信息,例如時間戳、邏輯時間戳、用戶唯一id等等,通過附帶元信息的方式是讓其更新保持單調,從而能夠構建一個帶有最小上界偏序關係的半格。

Op-based CRDT

基於操作的CRDT,實際上類似於上邊的State-based CRDT,只不過我們操作的對象變成了操作Op的複製與同步。那麼同樣對於操作,我們也需要爲其設計爲符合交換律、結合律與冪等律的,因爲即使傳輸的對象從狀態轉換到了操作,也是需要經過網絡進行傳輸的,那麼就不可避免地出現了上述分佈式系統必須要解決的問題,所以傳輸的操作也是需要經過設計的,而使用Op-based CRDT能夠獲得的明顯提升是同步過程中需要傳輸的數據顯著降低。

那麼當前客戶端的更新操作本身滿足以上三律,那麼複製過後的操作同步到其他客戶端的時候僅需要對傳輸過來的操作進行回放即可,最簡單的例子是集合求並集。如果本地的客戶端操作無法滿足上述的條件,則可以考慮將元信息附加到操作Op上從而滿足三律條件。同樣的,如果我們能夠保證Exactly once的語義,則冪等律條件是可以放寬的。如果依舊無法滿足的話,則可以考慮同步副本數據即State-based CRDT,同時附帶額外元信息,通過元信息讓本地更新與其他客戶端合併操作具備以上三律。有趣的是,使用基於操作的方式總是能夠模擬基於狀態的方式。

由於這裏操作的對象是Op,我們重新表示一下在快照的基礎上滿足於交換律、結合律與冪等律的方式,類似於OT的表示,我們需要定義一些概念,snapshot是當前文檔的內容,apply(snapshot, op)是在snapshot的基礎上應用Op從而更新文檔,那麼三律的表示如下:

  • 交換律: apply(apply(snapshot, A), B) = apply(apply(snapshot, B), A)
  • 結合律: apply(apply(apply(snapshot, A), B), C)= apply(apply(apply(snapshot, B), C), A)
  • 冪等律: apply(apply(snapshot, A), A) = apply(snapshot, A))

Op-based CRDT的設計中,我們還需要考慮到因果一致性,我們需要在apply之前對快照進行檢查,保證Op所依賴的因果順序成立,例如刪除x節點的操作依賴於x節點被創建的操作,否則就無法應用該操作,在不滿足條件的情況下需要阻塞延遲,這也意味着我們有責任保證每個操作的前置狀態都是能夠得到滿足的。也就是說我們需要保證每個操作的前置條件都能通過按因果順序應用得到滿足,從而所有更新函數都會終止。最終各個客戶端的副本之間通過傳遞彼此缺失的Op,並進行apply操作來達到最終一致性。

同樣的,即使是同步Op,我們也可以通過保證一個偏序關係來保證三律的實現,我們可以根據每個Op在副本上的執行順序來確定偏序關係,也就是說我們此時附加的元信息是時間戳或者是邏輯時間戳。

  • 如果一個OpA在副本上先於OpB執行,那麼A < B
  • 如果既沒有A < B也沒有B < A,那麼我們就稱AB是並行的操作。

由此在Op-based CRDT的設計中,我們需要設計使得Op滿足交換律、結合律與冪等律,並且需要保證每個操作的前置條件都能通過按因果順序應用得到滿足,從而所有更新函數都會終止,從而得到滿足Op-based CRDT的數據結構。實際上,在形式上Op-based CRDTState-based CRDT是可以互相轉換的,所以在Op-based CRDT這一節當中大部分需要了解的內容在State-based CRDT一節中都有體現。在State-based CRDT這一節的最後我們提到了在富文本中通過附加元信息的方式來滿足CRDT的條件,同樣我們也可以將元信息附加到Op上來滿足條件,簡單一點的話,我們如果爲富文本的每一個字都賦予了唯一的id,再配合上邏輯時間戳,我們就能精確地知道該如何將Op應用到文檔的位置,從而就不需要進行操作變換轉換索引了。當然這個實現非常非常粗糙,如果想深入涉及一下的話,可以看看YATA算法與Yjs,在Yjs中,大部分操作都是基於Op-based CRDT設計實現的,只有處理刪除操作的時候是類似於State-based CRDT的實現。

數據結構

下面是一些典型的CRDT數據結構的例子,可以幫助我們理解CRDT的設計思想,要注意的是我們在此處的例子是沒有中央服務器調度以及數據存儲的的,數據都是在各個客戶端之間進行同步。

Op-based Counter

Op-based Counter表示了一個整數計數器,假設此時我們有AB兩個客戶端共同維護一個計數器,也就是說我們兩個客戶端都存儲了一個整數,假設當前值爲10,那麼此時A進行了increment操作,B進行了decrement操作,那麼我們就可以暫時地得到A的值爲11B的值爲9,之後便要進行數據同步,以便使得AB得到最終一致性的10這個值,那麼我們就可以在網絡中同步Aincrement操作,Bdecrement操作,這樣就能夠使得AB的值都變爲10

看起來這個操作是很容易實現的,那便是因爲加法天然滿足交換律和結合律,減法也同樣可以看作是加法,只不過是加一個負值,那麼在這個例子中需要注意的點是加法不冪等,所以同步過程中需要保證不丟不重。

Grow-only Counter

State-based Counter在組織形式並非那麼的顯而易見,在這一小節我們先從Grow-only Counter看起,也就是隻增的計數器。首先明確的概念是我們此時是將數據全量同步的CRDT實現,由於同步的是全量,如果每個副本單獨進行累加,在進行合併的時候無法知道每個副本具體累加了多少,也不能簡單的取一個max作爲最終結果,例如此時我們有AB兩個客戶端共同維護一個計數器,初始時兩個客戶端數據都是0,此時A增加了1B增加了2,那麼全量同步數據時如果max(1, 2) = 2是不行的,因爲我們實際上想得到的數據是3。以上,雖然我們通過max設計的數據結構是符合了交換律結合律和冪等律,但是這個設計與我們的目的是相悖的,我們想得到的是一個計數器,而不是獲得客戶端的最大值。

那麼一種可行的方案是在每個副本上都使用一個數組保留其它所有副本的值,本地更新時只操作當前副本在數組中對應項,合併只能修改數組中除了當前副本的其他項目,並且對數組每一項求max進行合併,在查詢時將本地所有副本的值求和,即爲我們想要的計數器Counter。此時我們有AB兩個客戶端共同維護一個計數器,初始時兩個客戶端數據都是0,那麼A維護的數據是[0, 0]B維護的數據是[0, 0],此時A增加了1B增加了2,那麼A就變成了[1, 0]B變成了[0, 2],當數據同步之後,A即爲[1, 2]B[1, 2],這樣兩個客戶端的數據就是一致的了,最終的計數爲3

在上述的方式中,對數組每一項求max進行合併就是我們維持交換律結合律和冪等律的方式,由於網絡的不可靠,我們不能保證數據包都能夠正常同步,假如歷史遺留了一個[1, 1]的包在網絡中剛到達某一個客戶端,而此時客戶端的值已經達到了[6, 10],那麼這個包一定是要被捨棄的,否則就不能達到我們需要保證的三律,由此我們維護了一個偏序關係,當然了自然數實際上是全序關係。當然我們也可以爲數據附帶一個時間戳,或者邏輯時間戳,這也是可實現的方式,因爲同樣符合我們前邊論述的,通過附加元信息的方式來達到三律。

Positive-Negative Counter

在上述的Grow-only Counter的最後,我們舉了個假如歷史遺留了一個[1, 1]的包在網絡中剛到達某一個客戶端,而此時客戶端的值已經達到了[6, 10],那麼這個包一定是要被捨棄的,否則就不能達到我們需要保證的三律的例子。其實也能夠看出來,這個例子維護的是一個僅增的數據結構,如果加的是一個負數,或者直接叫做減法的話,那麼上述的這個系統就是無效的。

由此帶有DecrementState-based CRDT也並非像G-Counter那樣顯而易見,帶有減法之後,不能滿足更新時時單調的偏序關係,所以在這裏一種可行的方式是構造兩個G-Counter,一個存放Increment的累加值,一個存放Decrement的累加值,通過兩組帶有偏序關係的CRDT來達到我們維持三律的目的。

Grow-only Set

Grow-only Set表示的是一個僅增的集合,SetAdd操作本質上是求並,天然滿足交換律、結合律和冪等律, 可以很簡單地使用Op-based CRDT,此時也不需要與上述的Op-based Counter一樣需要保證不丟不重,因爲集合求並集是冪等的,當然也可以使用State-based CRDT進行全量的數據求並集,此時每個客戶端的副本也只需要保存一份集合即可。

Two-Phase Set

Two-Phase Set中,我們考慮到了刪除操作,實現思路上和PN-Counter一致,使用兩個G-SetSet A只負責添加,對於從Set Aremove的元素不做實際刪除,只是複製到Set R中,在查詢時如果元素在Set A且不在Set R中,則表示該元素存在。實際上2P-Set十分不實用,一方面已經被刪除的元素不能再次被添加,因爲集合必須保持只增的偏序關係,一方面刪除的元素還會保留在原Set中,佔用大量空間。

Last-Write-Wins-Element Set

Last-Write-Wins-Element Set中爲了解決刪除元素不能再次添加的問題,可以考慮給2P-SetAR的每個元素加一個更新時間戳,其它操作保持不變,那麼在查詢的時候就需要判斷該元素是否存在,即如果一個元素在添加集A中,並且不在刪除集R中,或者在刪除集R中但時間戳早於添加集A中的最新時間戳,那麼就認爲該元素存在。另一個更優化的實現是不要R集合,而A集合中每一個元素除了維護一個更新時間戳之外,還有一個刪除標誌位,這也可以看出實際上通過附加元信息的方式可以有很多實現,只要能夠滿足交換律、結合律和冪等律即可。

Observed-Remove Set

Observed-Remove Set類似於LWW-Element-Set的實現,但使用唯一標籤tag/uuid而不是時間戳,我們同樣需要維護一個增加集Set A與刪除集Set R。在添加元素時生成一個新的唯一標記tag/uuid,在刪除的時候就將該元素與tag複製到刪除集Set R中,查詢時如果元素在Set A中且不在Set R中,元素才存在於集合當中,因爲我們生成了全局唯一的tag,所以並不需要比較時間戳,這樣就可以保證刪除元素後可以再次添加,這可以看作是State-based CRDT的實現。

還有一種滿足Op-based CRDT的設計,核心思想是每次Add(e)的時候都爲元素e加一個唯一的tag/uuidRemove(e)將當前節點上的所有e和對應的tag/uuid都刪除,這樣在Remove(e)同時其它節點又有併發Add(e)的情況下e是能夠最終保證添加成功,此種語義稱爲Add wins。接下來我們可以看看這種方式是如何滿足交換律、結合律和冪等律的,在這裏的敘述都是要滿足因果一致性來表述的,首先對於交換律而言,由於tag/uuid是全局唯一的,無論是Add還是Remove顯然都是可以交換的,當然前提是滿足因果一致性,不滿足需要阻塞等待;同樣還有結合律,在滿足因果一致性的情況下我們是可以隨意對於集合進行結合操作的,主要是tag/uuid是全局唯一的,並不會出現不一致的問題;最後是冪等律,我們需要保證因爲網絡不可靠重新發送的包的tag/uuid與重試前的數據相同,那麼因爲該元素已經實際存在,我們並不會重複添加,因而這個操作是冪等的。其實類似於我們最初寫到的Op-based CounterOp是可交換的,並且保證了冪等性,所以這也是一種合理的CRDT設計。

最後

由於CRDT是處理分佈式系統數據同步問題的通用解決方案,所以本文並沒有侷限於在富文本數據結構的設計,而是從分佈式數據同步的角度來理解CRDT,並且穿插着CRDT在富文本領域上的應用,從而讓我們能夠更好地理解這個數據模型。同樣,本文介紹的內容也只是冰山一角,分佈式數據的同步一直以來都是個複雜的問題,迴歸到富文本領域上,如何保證多人協同的編輯器性能、在CAP理論下如何做取捨策略、如何保證數據的穩定性可恢復可回溯、光標的同步處理、如何處理Undo/Redo等等,都是需要深入研究並且設計的。

在富文本領域中,當前在線文檔的主流方案依舊是OTShareDB作者在20209月的文章CRDTs are the future中,說明了CRDTOT的取捨問題,OT最大的問題就是必須依賴中央服務器,那在所有設備之間無縫共享數據就必須依賴於服務器調度數據,而通過CRDT來實現就能夠做到無需中央服務器來實現數據同步,可能CRDT是未來解決一致性問題的一個有力手段。與OT一樣,在CRDT的領域也有很多開源的實現,通過應用CRDT基礎庫爲應用帶來了奇妙的可能,只需要一個APIBackbone幾乎一樣簡單的數據Model層,應用就能自然地獲得對多人協作場景下併發更新的支持,在這裏推薦兩個CRDT實現,automerge https://github.com/automerge/automerge/yjs https://github.com/yjs/yjs

每日一題

https://github.com/WindrunnerMax/EveryDay

參考

https://zhuanlan.zhihu.com/p/50990721
https://zhuanlan.zhihu.com/p/424467723
https://zhuanlan.zhihu.com/p/452980520
https://zhuanlan.zhihu.com/p/510797688
https://zhuanlan.zhihu.com/p/425265438
http://www.alloyteam.com/2020/01/14221/
https://www.jdon.com/artichect/crdt.html
https://www.zhihu.com/question/507425610
https://juejin.cn/post/6844903672032264199
https://juejin.cn/post/7049939780477386759
https://josephg.com/blog/crdts-are-the-future/
https://www.zxch3n.com/crdt-intro/design-crdt/
https://my.oschina.net/fileoptions/blog/1819610
https://hal.inria.fr/file/index/docid/555588/filename/techreport.pdf
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章