CRDT
的英文全稱是Conflict-free Replicated Data Type
,最初是由協同文字編輯和移動計算而發展的,現在還被用作線上聊天系統、音訊分發平臺等等。當前CRDT
演演算法在富文字編輯器領域的協同依舊是典型的場景,常用於作為實現檔案協同的底層演演算法,支援多個使用者同時編輯檔案,不會因為使用者並行修改導致衝突,而導致結果不一致甚至資料丟失的問題。
Conflict-free Replicated Data Type
直譯過來就是無衝突的複製資料型別,從名字可以看出來,CRDT
的重點在於無衝突複製和資料型別,去掉定語的話就可以得到CRDT
是一種資料結構,也就是說CRDT
是通過資料結構來保證最終一致性的。在分散式系統中,不同節點之間的資料複製存在一致性問題即強一致性問題,CRDT
是作為一種理論來指導如何將原有資料結構設計成在資料複製過程中通向最終一致性的一種新的資料結構。假設此時我們擁有多個副本或者操作,如果託管副本的計算機之間沒有協調,此時進行合併的話則可能導致副本之間的不一致,這通常是無法解決的,當更新存在衝突時,要恢復一致性和資料完整性,可能需要部分甚至全部更新的刪除。由此CRDT
指導我們在有多個副本或者操作進行合併或者更新時,使得我們的資料能根據一定的規則自動合併、解決衝突,最終達到強一致性的效果。回到富文字協同上,檔案協同編輯同樣可以理解為分散式應用的一種,而CRDT
通過資料結構的設計保證並行運算元據的最終一致性。簡單來說,CRDT
就是可以在網路中的多個終端上覆制的資料結構,副本可以獨立和並行地更新,不需要在副本之間進行協調,並保證不會有衝突發生,從而保證最終各個副本的內容一致。
在討論具體的協同演演算法之前,我們探究一下為什麼要有協同演演算法,如果沒有協同演演算法的話會出現什麼問題,以及具體會出現問題的場景。那麼假如我們有一個線上的檔案應用,而我們是一個團隊,我們有可能對同一篇檔案進行編輯,既然我們會同時編輯,那麼就有可能產生衝突。假設檔案此時的內容為A
,此時U1
和U2
兩位使用者同時在編輯,也就是說這兩位都是從檔案的A
狀態開始編輯,當U1
編輯完成之後,檔案狀態是B
,U1
對檔案進行了儲存,此時U2
也寫完了,檔案狀態是C
,U2
也對檔案進行了儲存,那麼此時檔案的狀態就是C
了,由U1
編寫的A -> B
狀態的內容修改便丟失了,為了解決這樣的問題,通常有以下幾個方案。
樂觀鎖,主要就是一種對比於悲觀鎖的說法,因為樂觀鎖的操作過程中其實沒有沒有任何鎖的參與,嚴格的說樂觀鎖不能稱之為鎖。樂觀鎖總是假設最好的情況,每次去拿資料的時候都認為別人不會修改,所以不會上鎖,可能需要在更新的時候會判斷一下在此期間別人有沒有去更新這個資料提示一下,或者乾脆不會給予任何的提示資訊。
那麼具體到檔案編輯上邊,我們可以樂觀地認為永遠不會有兩個人同時編輯同一篇檔案,現實中也可能有這種情況,比如團隊中每個人只負責幾篇檔案,其他人不需要也沒有許可權去編輯自己負責之外的檔案,那麼基於這種要求,我們可以樂觀地認為永遠不會出現衝突的問題,那麼自然也就不需要對檔案的管理做任何限制了,只需要完整地提供編輯能力即可。
悲觀鎖,顧名思義是基於一種以悲觀的態度類來防止一切資料衝突的方式,其以一種預防的姿態在修改資料之前把資料鎖住,然後再對資料進行讀寫,在其釋放鎖之前其他的任何人都不能對資料進行操作,直到前面一個人把鎖釋放後下一個人才可對資料進行加鎖,繼而才可以對資料進行操作,通過這種方式可以完全保證資料的獨佔性和正確性。
那麼具體到檔案編輯上邊,我們可以對同一篇檔案的編輯操作許可權進行加鎖,這樣就可以保證同一時間只有一個人可以對檔案進行編輯,其他人只能等待,直到前面的人把檔案編輯完成並且釋放鎖之後,下一個人才可以對檔案進行編輯,當然也可以開一個口子允許強行搶佔並且將被搶佔者的現場儲存下來,相當於將一個並行操作壓成了線性操作,這樣就可以通過獨佔的方式保證檔案的正確性,避免檔案的內容衝突與丟失。
自動合併,檔案內容自動合併以及衝突處理的方式也是一個可行的方案,類似於Git
的版本管理思想,可以對提交的內容進行diff
差異對比、merge
合併等操作,也可以在出現無法解決的衝突時出現時交給使用者主動處理,GitBook
是採用這種方式解決衝突問題的。
協同編輯,可以支援多個使用者同時編輯檔案,不會因為使用者並行修改導致衝突,而導致結果不一致甚至資料丟失的問題。協同編輯重點在於協同演演算法,主要有Operational Transformation(OT)
與Conflict-free Replicated DATA Type(CRDT)
兩種協同演演算法。協同演演算法不需要是正確的,其只需要保持一致,並且需要努力保持你的意圖,就是說協同演演算法最主要的目的是在儘可能保持使用者的意圖的情況下提供最終的一致性,重點在於提供最終一致性而不是保持使用者的意圖。當前石墨檔案、騰訊檔案、飛書檔案、Google Docs
都是基於OT
協同演演算法的,Atom
編輯器使用的是CRDT
協同演演算法。
Conflict-free Replicated DATA Type(CRDT)
協同演演算法的核心思想在於解決衝突,而在於構造一種資料結構來避免衝突,避免了衝突就可以直接進行合併,最終得到檔案內容。CRDT
協同演演算法的目的是在儘可能保持使用者意圖的情況下,保持檔案的最終一致性,舉個例子,當A
和B
同時在檔案的L
處插入了不同的字元,那麼誰插入的字元在前協同演演算法並不關心,其只需要儘可能地根據一定策略例如時間戳來判斷究竟是誰的字元在前,但是最終計算出的結果即究竟誰的字元在前並不影響協同演演算法,其關心的重點在於經過協同演演算法將使用者產生的Op
排程之後,在每個人面前呈現的檔案內容是絕對一致的,這就是保持檔案的最終一致性。從功能的角度上說,協同演演算法保證的是在多人同時線上編輯的情況下,由於每個人提交的內容是不一樣的,就需要通過協同演演算法的排程,使得每個使用者最終都能看到一樣的內容。實際上線上檔案本身就是一個資料一致性要求很強的專案,所以無論是使用CRDT
演演算法還是OT
演演算法來實現協同,保證最終一致性就是必須要考慮的基本內容。
由於CRDT
設計上可以完成對於各個副本的合併與更新而不會產生衝突,那麼經由CRDT
實現的演演算法就可以直接在使用者端之間互相傳遞,相互同步至最終一致性的狀態,也就是各個使用者之間可以直接P2P
進行資料合併而不需要中央伺服器進行排程,由此CRDT
可以很好地支援去中心化的應用,即使沒有中心化伺服器各端之間也能完成同步。
談到了最終一致性和分散式系統,那麼就不得不提到CAP
理論,CAP
理論指出在一個分散式系統中,最多隻能同時滿足Consistency
(一致性)、Availability
(可用性)和Partition Tolerance
(分割區容錯性)中的兩項。
Consistency
: 對於使用者端的每次讀操作,要麼讀到的是最新的資料,要麼讀取失敗。換句話說,一致性是站在分散式系統的角度,對存取本系統的使用者端的一種承諾:要麼我給你返回一個錯誤,要麼我給你返回絕對一致的最新資料,不難看出,其強調的是資料正確。Availability
: 任何使用者端的請求都能得到響應資料,不會出現響應錯誤。換句話說,可用性是站在分散式系統的角度,對存取本系統的客戶的另一種承諾:我一定會給你返回資料,不會給你返回錯誤,但不保證資料最新,強調的是不出現響應錯誤。Partition tolerance
:由於分散式系統通過網路進行通訊,網路是不可靠的。當任意數量的訊息丟失或延遲到達時,系統仍會繼續提供服務,不會掛掉。換句話說,分割區容忍性是站在分散式系統的角度,對存取本系統的使用者端的再一種承諾:我會一直執行,不管我的內部出現何種資料同步問題,強調的是不掛掉。對於一個分散式系統而言,P
是前提,必須保證,因為只要有網路互動就一定會有延遲和資料丟失,這種狀況我們必須接受,必須保證系統不能掛掉,所以只剩下C
、A
可以選擇,要麼保證資料一致性即資料絕對正確,要麼保證可用性即保證響應不出錯。首先我們要理解一下為什麼P
是前提,這裡的場景是分散式系統,網路是不可靠的,一定會存在網路延遲與丟失等問題,如果不允許存在網路分割區也就是說網路是一直保證執行正常的,那麼顯然每次寫入資料都可以進行同步,自然一致性和可用性顯然得到了保障,但這也不是分散式系統了。
我們來舉個例子,在網路發生問題時,為什麼需要在C
和A
之間進行選擇,假設在分散式系統中存在100
個節點,但由於故障,使得網路發生了分割區,其中有一半的節點無法向另外一半節點通訊,於是系統被分割為A
區與B
區。在網路分割區的情況下,使用者端傳送請求嘗試來對A
區一個節點進行資料寫入,由於AB
區網路不通,這時候無法同步寫入資訊給到B
區節點。在這種場景下,究竟允不允許當前使用者端進行資料寫入呢。
實際上CAP
特性三選二的描述其實具有誤導性,從上邊的例子中也可以看出,不是在所有時候都只能選擇兩個特性,在不存在網路失敗的情況下即分散式系統正常執行時,C
和A
能夠同時保證,只有當網路發生分割區或失敗時,才會在C
和A
之間做出選擇,但是誰也無法保證任何時刻網路都是暢通無阻的,所以必須要處理在這種情況下的策略,以此來取得C
和A
的權衡。作者Eric Brewer
在2012
年也發表論文解釋了CAP
實際上只是禁止了設計空間存在分割區時的完美可用性和一致性。而實際上在C
和A
之間的權衡的設計非常靈活,CRDT
就是一個很好的例子。CRDT
演演算法正是在滿足Partition Tolerance
(分割區容錯性)的前提下,儘可能地保證Consistency
(一致性)和Availability
(可用性),同樣OT
協同演演算法也是一樣通過保證最終一致性來完成這個權衡的。
在協同編輯的場景下,我們似乎能夠同時滿足了CAP
的三個場景限制,假設在網路差的情況下,兩個使用者端同時提交,雖然暫時一致性無法滿足,本地使用者端會看到不同的內容,但網路恢復後,通過協同演演算法資料也能保持一致,這不是既滿足了一致性又滿足了可用性。這個想法是對的,只是這裡我們保證的一致性不等於CAP
理論的一致性,CAP
理論是假設在沒有網路延遲的情況下的強一致性,也就是資料時刻都是一致的,而協同編輯的場景的一致性則是最終一致性。前文也提到過,在C
和A
之間的權衡的設計非常靈活,既然無法做到強一致性Strong consistency
,那麼應用可以根據自身的業務特點,採用適當的方式來使系統達到最終一致性Eventual consistency
,這個就又需要說到BASE
理論了。
BA
: Basically Avaliable
,基本可用,允許損失部分可用性,允許犧牲響應時間、降級系統功能等操作。S
: Soft State
,軟狀態,允許存在資料中間狀態,不要求強一致性,並不影響整體可用性,允許副本之間的資料同步延遲。E
: Eventually consistency
,最終一致性,所有的副本,在最終都能達到資料一致的狀態,但不要求實時的強一致性。在BASE
理論中,我們可以看到協同編輯檔案的場景中,雖然CAP
的強一致性無法滿足,但通過靈活地設計C
和A
,損失部分可用性,允許暫時的使用者端不一致情況,通過協同編輯衝突演演算法,可以解決資料不一致問題,達到了最終的資料一致性。實際上在分散式理論上又有很多研究,有著強一致性、弱一致性、順序一致性、最終一致性、因果一致性等,而最終一致性也有讀寫一致性、寫讀一致性、單調讀一致性、單調寫一致性等劃分,在這裡就不再贅述了。
之後我們再簡單舉一個例子,看看應該如何去實現最終一致的分散式系統。假設我們當前有一個賬戶是T
,初始時這個賬戶有100
塊錢,使用者可以通過系統裡面好幾個節點,例如A
、B
、C
,存取T
這個賬戶,並且可以在任何時刻都對T
進行操作。注意此時我們並沒有中央伺服器來儲存這100
塊錢,而是每個賬戶都各自儲存了這個數位,在這個分散式系統中我們不考慮安全方面的因素,我們只需要最終保證每個人的資料都是一致的即可。
假設某個時刻t1
,A
往T
中存了10
塊錢,B
則向T
中取了10
塊錢,C
則在接下來的一個時刻查詢T
有多少錢,這幾個操作都是同時發生的。那麼,我們的分散式系統會保證這三個操作都能完成,於是在本地:
A
系統看來,T
有110
塊錢。B
系統看來,T
有90
塊錢;C
系統看來,T
有100
塊錢。在這個時刻t1
,這三者都是對的,因為我們可以在最終一致性系統中,存在不一致的時刻,那麼經過一段時間之後,假設是t2
,我們需要使得A
、B
和C
系統看來,T
都有100
塊錢,即保證最終一致。那麼這中間肯定需要做一些操作,即A
、B
和C
系統之間交換一些必要的資訊資料。那麼現在麻煩來了,我們需要如何進行資料交換,如果在各節點我們只是存了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
的操作,由此C
在t2
時刻依舊是100
;而對於A
而言,收到了B t0->t1 -10
的操作,C
沒有更新操作所以不需要同步;對於B
而言收到了A t0->t1 +10
的操作,C
沒有更新操作所以不需要同步。最終A
、B
、C
三者得到了一致的資料總和表示100
,由此得到了最終一致性。當然這個例子足夠簡單,答案也足夠的粗糙,而且在實際的應用中,我們必須要考慮更多的資料型別和應用場景,於是設計一個能夠保持最終一致性的資料結構,就會變成一件很難的事情。由此才需要圍繞著CRDT
的理論進行分散式系統的設計,從而減少我們的對於分散式協同設計的心智負擔。
回到協同,在瞭解CRDT
協同演演算法之前,我們也可以瞭解一下CRDT
協同演演算法與OT
協同演演算法的主要區別。首先CRDT
與OT
都提供了最終一致性,這也是協同編輯的最終目標,但是這兩種方案達成這一目標的方式不一樣:
CRDT
無衝突複製資料型別是通過資料結構來做到這一點,CRDT
有兩種實現方式,基於狀態的CvRDT
收斂複製資料型別和基於操作的CmRDT
可交換複製資料型別。CvRDT
是將各個副本進行合併,進行多少次合併或以何種順序進行合併並不重要,所有副本都會收斂。CmRDT
則具有可交換的操作,因此無需轉換操作即可正確應用這些操作。CRDT
更適合分散式系統,可以不需要中央伺服器。CRDT
通過資料結構保證了編輯的無衝突,增加了空間複雜度。OT
操作轉換則是通過操作Operation
轉換Transformation
來做到這一點,終端所進行的操作O
通過網路傳輸,其他終端在收到操作O
後需要進行轉換T
,之後才可以應用到檔案上,最基礎的OT
是通過轉換索引位置以確保收斂。OT
通常必須要有中央伺服器進行協同排程。OT
通過演演算法處理編輯衝突的問題,增加了時間複雜度。在上邊我們討論到了CAP
定理,我們需要在一致性與可用性之間做出取捨,能夠達到最終一致性而暫時地損失部分可用性是完全可以接受的,不過無論如何在實際情況中設計這樣一個分散式系統還是非常複雜的,而CRDT
就是是這樣一個通向最終一致性的理論,可以指導我們去正確地設計一個高可用的分散式系統。CRDT
同樣也並不僅僅應用在在協同領域,當前各種分散式系統和應用均開始嘗試CRDT
,redislabs
和riak
已經實現多種資料結構,微軟的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
。
基於狀態的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
。看到這三個公式我們就可以比較容易地理解這個為什麼這個運算元需要滿足這三律了,我們首先需要關注的一點是網路是不可靠的,而分散式的系統必須要依賴於網路進行資料傳輸,由此我們來看下為什麼需要保證這三個公式:
A
、B
兩位使用者相互進行資料傳輸,A
資料同步到B
就是A ∪ B
,B
資料同步到A
就是B U A
,那麼在需要保證最終一致性的前提下則必須保證交換律進而能夠保證正確地進行資料合併,即應用順序無關緊要。A
、B
、C
、D
、E
五位使用者相互進行資料傳輸,此時A
、B
、C
對資料進行了改動,對於D
、E
而言就需要從其他三個使用者端進行資料同步,由於存在網路延遲,無法哪個使用者端的位置先行到達,也就是D
的合併順序可能是A B C
,而E
的合併順序可能是B C A
,所以需要滿足結合律才能夠保證正確進行資料合併,即分組無關緊要。A
、B
兩位使用者相互進行資料傳輸,當A
使用者進行改動後將副本進行了網路傳輸,但是久久沒有得到ACK
確認,那麼此時可能會有一個超時重傳的機制,我們又將A
傳送了出去,但是巧合的是剛才那個包並沒有丟失,只是傳遞比較慢,而新的A
包由於選擇了其他的路由線路也沒有丟失,此時兩個相同的A
包同時到達了B
,由於B
未發生修改,B
合併了第一個A
之後相當於內容與A
相同,此時再合併第二個A
就需要保證冪等性從而保證能夠正確進行資料合併,即重複無關緊要。可以看出其實滿足上邊三個公式都是由於網路的不可靠,而我們為了保證最終一致性從而需要對資料結構進行設計。此外需要注意的是如果我們能夠保證Exactly once
的語意,則冪等律條件是可以放寬的,舉個例子加法本身滿足交換律結合律但不冪等,假如此時我們能夠保證加法操作只複製一次,其結果還是最終一致的。實際上保證了上述三個定理,我們在分散式系統同步資料的時候就可以無需考慮合併的順序以及多次合併的問題了,且能夠保證最終一致性。
下邊再看一下半格的概念,在瞭解半格之前我們需要探討一下偏序集的概念,實際上了解了半格之後,我們就可以大概理解CRDT
如何做到合併不會出現衝突的問題了。設P
是集合,P
上的二元關係≤
滿足以下三個條件,則稱≤
是P
上的偏序關係或部分序關係。
∀a∈P
,a≤a
。∀a, b∈P
,若a≤b
且b≤a
,則a=b
。∀a, b, c∈P
,若a≤b
且b≤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 bound
即LUB
。也就是說半格相較於格來說,只有一個不同的頂部或底部,所以是在名字上也可以看出是叫做半個格。連線半格是具有不同頂部(最小上界)的半格,而相交半格是具有不同底部(最大下界)的半格。任何表示為半格的資料型別都可以實現為保證收斂的資料結構。例如,計算一組值的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
等等,通過附帶元資訊的方式是讓其更新保持單調,從而能夠構建一個帶有最小上界偏序關係的半格。
基於操作的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
,那麼我們就稱A
和B
是並行的操作。由此在Op-based CRDT
的設計中,我們需要設計使得Op
滿足交換律、結合律與冪等律,並且需要保證每個操作的前置條件都能通過按因果順序應用得到滿足,從而所有更新函數都會終止,從而得到滿足Op-based CRDT
的資料結構。實際上,在形式上Op-based CRDT
和State-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
表示了一個整數計數器,假設此時我們有A
、B
兩個使用者端共同維護一個計數器,也就是說我們兩個使用者端都儲存了一個整數,假設當前值為10
,那麼此時A
進行了increment
操作,B
進行了decrement
操作,那麼我們就可以暫時地得到A
的值為11
,B
的值為9
,之後便要進行資料同步,以便使得A
和B
得到最終一致性的10
這個值,那麼我們就可以在網路中同步A
的increment
操作,B
的decrement
操作,這樣就能夠使得A
和B
的值都變為10
。
看起來這個操作是很容易實現的,那便是因為加法天然滿足交換律和結合律,減法也同樣可以看作是加法,只不過是加一個負值,那麼在這個例子中需要注意的點是加法不冪等,所以同步過程中需要保證不丟不重。
State-based Counter
在組織形式並非那麼的顯而易見,在這一小節我們先從Grow-only Counter
看起,也就是隻增的計數器。首先明確的概念是我們此時是將資料全量同步的CRDT
實現,由於同步的是全量,如果每個副本單獨進行累加,在進行合併的時候無法知道每個副本具體累加了多少,也不能簡單的取一個max
作為最終結果,例如此時我們有A
、B
兩個使用者端共同維護一個計數器,初始時兩個使用者端資料都是0
,此時A
增加了1
,B
增加了2
,那麼全量同步資料時如果max(1, 2) = 2
是不行的,因為我們實際上想得到的資料是3
。以上,雖然我們通過max
設計的資料結構是符合了交換律結合律和冪等律,但是這個設計與我們的目的是相悖的,我們想得到的是一個計數器,而不是獲得使用者端的最大值。
那麼一種可行的方案是在每個副本上都使用一個陣列保留其它所有副本的值,本地更新時只操作當前副本在陣列中對應項,合併只能修改陣列中除了當前副本的其他專案,並且對陣列每一項求max
進行合併,在查詢時將本地所有副本的值求和,即為我們想要的計數器Counter
。此時我們有A
、B
兩個使用者端共同維護一個計數器,初始時兩個使用者端資料都是0
,那麼A
維護的資料是[0, 0]
,B
維護的資料是[0, 0]
,此時A
增加了1
,B
增加了2
,那麼A
就變成了[1, 0]
,B
變成了[0, 2]
,當資料同步之後,A
即為[1, 2]
,B
為[1, 2]
,這樣兩個使用者端的資料就是一致的了,最終的計數為3
。
在上述的方式中,對陣列每一項求max
進行合併就是我們維持交換律結合律和冪等律的方式,由於網路的不可靠,我們不能保證封包都能夠正常同步,假如歷史遺留了一個[1, 1]
的包在網路中剛到達某一個使用者端,而此時使用者端的值已經達到了[6, 10]
,那麼這個包一定是要被捨棄的,否則就不能達到我們需要保證的三律,由此我們維護了一個偏序關係,當然了自然數實際上是全序關係。當然我們也可以為資料附帶一個時間戳,或者邏輯時間戳,這也是可實現的方式,因為同樣符合我們前邊論述的,通過附加元資訊的方式來達到三律。
在上述的Grow-only Counter
的最後,我們舉了個假如歷史遺留了一個[1, 1]
的包在網路中剛到達某一個使用者端,而此時使用者端的值已經達到了[6, 10]
,那麼這個包一定是要被捨棄的,否則就不能達到我們需要保證的三律的例子。其實也能夠看出來,這個例子維護的是一個僅增的資料結構,如果加的是一個負數,或者直接叫做減法的話,那麼上述的這個系統就是無效的。
由此帶有Decrement
的State-based CRDT
也並非像G-Counter
那樣顯而易見,帶有減法之後,不能滿足更新時時單調的偏序關係,所以在這裡一種可行的方式是構造兩個G-Counter
,一個存放Increment
的累加值,一個存放Decrement
的累加值,通過兩組帶有偏序關係的CRDT
來達到我們維持三律的目的。
Grow-only Set
表示的是一個僅增的集合,Set
的Add
操作本質上是求並,天然滿足交換律、結合律和冪等律, 可以很簡單地使用Op-based CRDT
,此時也不需要與上述的Op-based Counter
一樣需要保證不丟不重,因為集合求並集是冪等的,當然也可以使用State-based CRDT
進行全量的資料求並集,此時每個使用者端的副本也只需要儲存一份集合即可。
在Two-Phase Set
中,我們考慮到了刪除操作,實現思路上和PN-Counter
一致,使用兩個G-Set
,Set A
只負責新增,對於從Set A
中remove
的元素不做實際刪除,只是複製到Set R
中,在查詢時如果元素在Set A
且不在Set R
中,則表示該元素存在。實際上2P-Set
十分不實用,一方面已經被刪除的元素不能再次被新增,因為集合必須保持只增的偏序關係,一方面刪除的元素還會保留在原Set
中,佔用大量空間。
在Last-Write-Wins-Element Set
中為了解決刪除元素不能再次新增的問題,可以考慮給2P-Set
中A
和R
的每個元素加一個更新時間戳,其它操作保持不變,那麼在查詢的時候就需要判斷該元素是否存在,即如果一個元素在新增集A
中,並且不在刪除集R
中,或者在刪除集R
中但時間戳早於新增集A
中的最新時間戳,那麼就認為該元素存在。另一個更優化的實現是不要R
集合,而A
集合中每一個元素除了維護一個更新時間戳之外,還有一個刪除標誌位,這也可以看出實際上通過附加元資訊的方式可以有很多實現,只要能夠滿足交換律、結合律和冪等律即可。
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/uuid
,Remove(e)
將當前節點上的所有e
和對應的tag/uuid
都刪除,這樣在Remove(e)
同時其它節點又有並行Add(e)
的情況下e
是能夠最終保證新增成功,此種語意稱為Add wins
。接下來我們可以看看這種方式是如何滿足交換律、結合律和冪等律的,在這裡的敘述都是要滿足因果一致性來表述的,首先對於交換律而言,由於tag/uuid
是全域性唯一的,無論是Add
還是Remove
顯然都是可以交換的,當然前提是滿足因果一致性,不滿足需要阻塞等待;同樣還有結合律,在滿足因果一致性的情況下我們是可以隨意對於集合進行結合操作的,主要是tag/uuid
是全域性唯一的,並不會出現不一致的問題;最後是冪等律,我們需要保證因為網路不可靠重新傳送的包的tag/uuid
與重試前的資料相同,那麼因為該元素已經實際存在,我們並不會重複新增,因而這個操作是冪等的。其實類似於我們最初寫到的Op-based Counter
,Op
是可交換的,並且保證了冪等性,所以這也是一種合理的CRDT
設計。
由於CRDT
是處理分散式系統資料同步問題的通用解決方案,所以本文並沒有侷限於在富文字資料結構的設計,而是從分散式資料同步的角度來理解CRDT
,並且穿插著CRDT
在富文字領域上的應用,從而讓我們能夠更好地理解這個資料模型。同樣,本文介紹的內容也只是冰山一角,分散式資料的同步一直以來都是個複雜的問題,迴歸到富文字領域上,如何保證多人協同的編輯器效能、在CAP
理論下如何做取捨策略、如何保證資料的穩定性可恢復可回溯、遊標的同步處理、如何處理Undo/Redo
等等,都是需要深入研究並且設計的。
在富文字領域中,當前線上檔案的主流方案依舊是OT
,ShareDB
作者在2020
年9
月的文章CRDTs are the future
中,說明了CRDT
和OT
的取捨問題,OT
最大的問題就是必須依賴中央伺服器,那在所有裝置之間無縫共用資料就必須依賴於伺服器排程資料,而通過CRDT
來實現就能夠做到無需中央伺服器來實現資料同步,可能CRDT
是未來解決一致性問題的一個有力手段。與OT
一樣,在CRDT
的領域也有很多開源的實現,通過應用CRDT
基礎庫為應用帶來了奇妙的可能,只需要一個API
與Backbone
幾乎一樣簡單的資料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