初探富文字之OT協同演算法
OT
的英文全稱是Operational Transformation
,是一種處理協同編輯的演算法。當前OT
演算法用的比較多的地方就是富文字編輯器領域了,常用於作為實現文件協同的底層演算法,支援多個使用者同時編輯文件,不會因為使用者併發修改導致衝突,而導致結果不一致甚至資料丟失的問題。
描述
從名字就可以看出來,OT
協同演算法的重點在於操作Operation
與轉換Transformation
,簡單來說,操作Operation
指明瞭所有的操作必須原子化,例如在第N
個位置插入了某個字元,在第M
個位置刪除了某個字元,類似於這樣的所有的操作必須能夠原子化地表示,轉換Transformation
指明瞭所有的操作必須要有轉換的方案,例如我在第N
個位置插入了字元,你在N+2
個位置同時插入了字元,假設我的操作比較靠前,由於需要同步操作,那麼在我本地執行你的Operation
時就必須將其轉換,插入的位置就必須增加我插入字元的長度,這就是大概的OT
所需要的條件,當然具體的演算法要遠遠比這個複雜,並且存在例如同步排程、Undo/Redo
、游標、穩定性、可溯源等等問題需要一併解決。本文不涉及具體的協同演算法,只是探討了OT
協同演算法的基本思路,當前也有比較成熟的OT
協同框架例如ShareDB
等,可以相對簡單地接入,當然只是相對而言,成本也是不低的。
在討論具體的協同演算法之前,我們探究一下為什麼要有協同演算法,如果沒有協同演算法的話會出現什麼問題,以及具體會出現問題的場景。那麼假如我們有一個線上的文件應用,而我們是一個團隊,我們有可能對同一篇文件進行編輯,既然我們會同時編輯,那麼就有可能產生衝突。假設文件此時的內容為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
協同演算法。
OT協同演算法
Operational Transformation(OT)
協同演算法的核心思想是將文件的每一次修改都看作是一個操作,然後將這些操作進行轉換來合併,最終得到文件內容。OT
演算法的目的是在儘可能保持使用者意圖的情況下,保持文件的最終一致性,舉個例子,當A
和B
同時在文件的L
處插入了不同的字元,那麼誰插入的字元在前協同演算法並不關心,其只需要儘可能地根據一定策略例如時間戳來判斷究竟是誰的字元在前,但是最終計算出的結果即究竟誰的字元在前並不影響協同演算法,其關心的重點在於經過協同演算法將使用者產生的Op
排程之後,在每個人面前呈現的文件內容是絕對一致的,這就是保持文件的最終一致性。從功能的角度上說,協同演算法保證的是在多人同時線上編輯的情況下,由於每個人提交的內容是不一樣的,就需要透過協同演算法的排程,使得每個使用者最終都能看到一樣的內容。
在瞭解OT
協同演算法之前,我們也可以瞭解一下OT
協同演算法與CRDT
協同演算法的主要區別。首先OT
與CRDT
都提供了最終一致性,這也是協同編輯的最終目標,但是這兩種方案達成這一目標的方式不一樣:
OT
操作轉換透過操作Operation
轉換Transformation
來做到這一點,終端所進行的操作O
透過網路傳輸,其他終端在收到操作O
後需要進行轉換T
,之後才可以應用到文件上,最基礎的OT
是透過轉換索引位置以確保收斂。OT
通常必須要有中央伺服器進行協同排程。OT
透過演算法處理編輯衝突的問題,增加了時間複雜度。CRDT
無衝突複製資料型別則是透過資料結構來做到這一點,CRDT
有兩種實現方式,基於狀態的CvRDT
收斂複製資料型別和基於操作的CmRDT
可交換複製資料型別。CvRDT
是將各個副本進行合併,進行多少次合併或以何種順序進行合併並不重要,所有副本都會收斂。CmRDT
則具有可交換的操作,因此無需轉換操作即可正確應用這些操作。CRDT
更適合分散式系統,可以不需要中央伺服器。CRDT
透過資料結構保證了編輯的無衝突,增加了空間複雜度。
基本原理
回到我們要介紹的OT
協同,我們在這裡不涉及具體的協同演算法,我們的側重點在於實現OT
的動機,例如協同為什麼不是直接應用協作者的Op
即可、為什麼要有操作變換、如何進行操作變換、什麼時候能夠應用Op
等等,當我們知道了一個技術的來由與動機時,其實現甚至都有可能躍然紙上了。那麼在這裡我們從A
、B
兩者同時編輯同一段文字的基本操作開始,探討一下OT
協同為了保持一致性究竟做了什麼。描述一篇文件的方式有很多,最經典的Operation
有quill
的delta
模型,透過retain
、insert
、delete
三個操作完成整篇文件的描述,還有slate
的JSON
模型,透過insert_text
、split_node
、remove_text
等等操作來完成整篇文件的描述。在這裡我們假設有一個加粗的操作Bold(start, end)
,一個插入的操作insert(position, content)
來描述整篇文件。
那麼此時,我們假設原始文字為12
,使用者A
、B
分別進行了一個加粗操作一個插入操作。
- 使用者
A
進行了一個Bold(1, 2)
操作,A
本地需要首先應用這個操作,由此A
本地的文字是(12)
,為了簡單起見,加粗用()
表示。 - 使用者
B
同時也進行了一個insert(2, "B")
操作,B
本地需要首先應用這個操作,由此B
本地的文字是12B
。 - 此時需要同步
Operation
,使用者A
收到了使用者B
的insert(2, "B")
操作,A
從本地的(12)
應用之後,得到了(12)B
。 - 使用者
B
收到了使用者A
的Bold(1, 2)
操作,B
從本地的12B
應用之後,得到了(12)B
。
看起來並沒有發生任何衝突,A
、B
最終都獲得了一致的文件內容(12)B
,當然事情並沒有那麼簡單,我們繼續往下看看其他的情況。為了簡單起見,我們假設目前的只有insert(position, content)
這個操作,從定義也能夠明顯的看出來,這個函式的意思是在position
處插入content
文字。
那麼此時,我們假設原始文字為123
,使用者A
、B
分別進行了一個插入操作。
- 使用者
A
進行了一個insert(2, "A")
操作,A
本地需要首先應用這個操作,由此A
本地的文字是12A3
。 - 使用者
B
同時也進行了一個insert(3, "B")
操作,B
本地需要首先應用這個操作,由此B
本地的文字是123B
。 - 此時需要同步
Operation
,使用者A
收到了使用者B
的insert(3, "B")
操作,A
從本地的12A3
應用之後,得到了12AB3
。 - 使用者
B
收到了使用者A
的insert(2, "A")
操作,B
從本地的123B
應用之後,得到了12A3B
。
經過上述協同結果是,使用者A
看到的內容是12AB3
,使用者B
看到的內容是12A3B
,內容不一致,沒有成功地保證最終一致性。那麼根據OT
的Operational Transformation
這個名字,我們來看上邊的協同,發現我們只是做了Operation
的同步,並沒有做Transformation
去轉換,所以我們這是一個不完整的協同,當然也就不能完整地覆蓋各種Case
。
我們再來看看上邊的協同方法有什麼問題,實際上我們只是對我們自己本地的內容應用了從其他位置同步過來的操作,而這個操作是失去了上下文Context
的,也可以稱為語境,具體來說,我們以A
為例,當我們接受到B
的insert(3, "B")
操作時,這個Op
實際上是在原始文字為123
這個文字為上下文的基礎上進行的Op
,而此時我們本地的文字內容是12A3
,而此時去執行B
的Op
就由於缺失了上下文而導致出現了問題,所以此時我們就需要OT
的Transformation
來將其進行轉換,當協作者變更到來時,我們需要變換操作以適應當前上下文,才能直接應用,而調整的過程,則基於當前文件已經發生的變更來完成。
Ob' = OT(Oa, Ob)
Oa' = OT(Ob, Oa)
而由上邊上下文的基本想法我們可以得到OT
協同的基本思路是,將每個使用者的操作都轉換成相對於原始文字的操作,這樣就可以保證最終一致性。具體來說,假設文件的初始狀態為S
,以同步時的A
使用者為例我們此時應用了Oa
也就是insert(2, "A")
這個操作,而此時恰好我們又收到了B
的Ob
也就是insert(3, "B")
操作,那麼我們此時要應用Ob
的時候,就需要進行轉換,也就是Ob' = OT(Oa, Ob)
,注意此時我們是將Oa
也作為引數傳入了進去,也就是說此時我們是透過Oa
與Ob
來作為引數算出來Ob'
的,那麼也就是說我們此時的上下文為S
,同理對於B
來說我們進行Oa' = OT(Ob, Oa)
計算要應用的Oa'
時,所處的上下文同樣也是S
,那麼這樣就將操作轉換成了相對於原始文字的操作了,從而得到一致性。換句話說,也可以這麼理解,Ob' = OT(Oa, Ob)
就相當於我們將原本已經執行的Oa
撤銷掉,然後結合Oa + Ob
從來得到Ob'
,將兩者的Op
結合起來再應用到S
上,對於Oa' = OT(Ob, Oa)
同理,那麼此時無論A
還是B
執行的上下文都是S
,從而得到一致性。
落實到具體實現上,我們需要定義一套演算法來完成這個Transformation
,下面我們就簡單實現一下,在這裡的實現很簡單,因為我們定義的操作只有insert
,假如是上文提到的retain
、insert
、delete
三種操作來描述文件的話,就需要實現3x3 = 9
種變換操作,在這裡我們對於兩個insert
的位置進行變換,如果此時新來的cur op
插入的位置是在先前的pre op
之後的,那麼說明在原來的內容上已經新增了內容,那麼我們就需要將插入的位置後移pre op
插入文字的長度。
function transform(pre, cur) {
// 在`pre`之後插入,需要向後移動`cur`作用的`position`
if (pre.insert && cur.insert && pre.insert.position <= cur.insert.position) {
return {
insert: {
position: cur.insert.position + pre.insert.content.length,
content: cur.insert.content
}
};
}
// ...
return cur;
}
此外還記得之前說的OT
的最終目的是保持最終的一致性,那麼落實到這裡,假設我們的兩個insert
操作都是同時在2
位置插入一個不同的字元,那麼在變換的時候我們需要決定究竟是誰在前,因為這兩個操作的時序是一樣的,也就是說可以認為是同時發生的,那麼就必須制定一個策略來決定誰的字元在前,那麼我們就透過第一個字元的ASCII
來決定究竟是誰在前,這只是一個簡單的策略,也就是所謂的儘可能保持使用者意圖的情況下,保持文件的最終一致性。
// 如果兩個`insert`的位置相同,那麼我們需要透過第一個字元的`ASCII`來決定誰在前
if(pre.insert.position === cur.insert.position) {
if(pre.insert.text.charCodeAt(0) < cur.insert.text.charCodeAt(0)) {
return {
insert: {
position: cur.insert.position + pre.insert.content.length,
content: cur.insert.content
}
};
}
return cur;
}
// A: 12 insert(2, A) 12A oa
// B: 12 insert(2, B) 12B ob
// A: 12A insert(3, B) 12AB ob'
// B: 12B insert(2, A) 12AB oa'
應用上邊的transform
函式,我們可以再來看一下上邊的例子。那麼此時,我們假設原始文字為123
,使用者A
、B
分別進行了一個插入操作。
- 使用者
A
進行了一個insert(2, "A")
操作,A
本地需要首先應用這個操作,由此A
本地的文字是12A3
,可以看作是2
後邊插入了A
。 - 使用者
B
同時也進行了一個insert(3, "B")
操作,B
本地需要首先應用這個操作,由此B
本地的文字是123B
,可以看作是3
後邊插入了B
。 - 此時需要同步
Operation
,使用者A
收到了使用者B
的insert(3, "B")
操作,經由變換transform(insert(2, "A"), insert(3, "B")) = insert(4, "B")
,A
從本地的12A3
應用之後,得到了12A3B
。 - 使用者
B
收到了使用者A
的insert(2, "A")
操作,經由變換transform(insert(3, "B"), insert(2, "A")) = insert(2, "A")
,B
從本地的123B
應用之後,得到了12A3B
。
我們最終A
與B
都得到了12A3B
,完成了最終一致性的操作,這就是OT
的基本原理,那麼接下來這個典型的菱形示意圖也就好理解了,
S
Oa / \ Ob
/ \
\ /
Ob' \ / Oa'
T
Ops
前邊的例子是協同的雙方只進行了一個Op
,那麼實際上我們平時寫文件的時候,大機率是會有多個Op
的,那麼對於多個Op
同時出現的情況,OT
又應該如何處理。首先要明確一點,OT
的核心思想是不變的,也就是Operational Transformation
,那麼對於多個Op
,我們的核心關注點就應該在如何transform
。另外在剛接觸OT
的時候,我有一個想法,既然是多個Op
那麼在傳輸的時候將其合併為一個Op
就可以了,後來仔細想了一下這樣是不行的,首先有些操作確實是可以合併的,比如在同一個位置增加了一些文字,那麼這些操作都可以歸併為insert
,相當於延時收集一下操作,但是有些操作就是不能合併的,比如在A
位置寫了一些文字,又在B
位置寫了一些文字,這樣顯然是不能合併的,除非是把整篇文件傳送出去,那這就是State-based CRDT
的範疇了,此外這樣會導致協同的基礎也就是原子化的Op
失效,原子化失效了後邊的變換、邏輯時序就都會出問題,那這是肯定不行的。
回到對多個Op
做transform
的問題上,假如此時A
做了Oa1
與Oa2
兩個Op
,假設我們此時是在A
的同步過程,也就是A
需要在當前的基礎上應用B
的Op
,那麼依照於前文的Ob' = OT(Oa, Ob)
,我們用當前最新的Oa2
作為引數進行變換,也就是即將要應用的Ob' = OT(Oa2, Ob)
,那麼此時我們可能會看出來問題,Oa1
的Op
資訊丟失了,那麼即將要Ob'
有可能是錯誤的,而且我們此時要應用的上下文並不是文件的初始內容S
,而是進行了Oa1
操作之後的S'
,這就使我們之前總結的方案出了問題,出現了內容的分叉。那麼如何糾正這個問題呢,很簡單,我們應該讓Ob
做兩次變換,也就是說我們需要Ob'' = OT(Oa2, OT(Oa1, Ob))
,這樣才可以將上下文迴歸到S
,才能獲得可以立即應用的正確的Op
操作。對於這個示例,其也可以用經典的菱形來是一個單向擴充比較大的菱形了示
有了上邊的時序的概念,我們再來看看具體的服務端與客戶端的架構設計,我們可以限制客戶端提交頻度,為了簡單起見我們每次都只能讓客戶端提交一個Op
,直到服務端處理完成之後,客戶端收到確認之後,我們才可以繼續傳送第二個Op
,此時我們也是用邏輯上的時序,也就是一個單調自增的版本號來表示上下文語境,那麼我們此時就有了幾個狀態,Synchronized
,兩個客戶端需要關注最外層的兩條線,其實也可以看出來當客戶端的操作比較多的時候,菱形會無限擴充。
S
Oa1 / \ Ob
/ \
/ \ /
Oa2 / Ob' \ / Oa1'
\ /
Ob'' \ / Oa2'
T
那麼我們不妨再總結一下,實際上兩個OP
在進行transform
時,本質上就是一個OP
向另一個OP
問詢資訊,並且根據資訊來調整自己,那麼只有產生自相同上下文,彼此通訊的空間資訊才是彼此信賴、可理解的,也才敢使用彼此的資訊調整自己。那麼我們可以總結出來:
- 可以做變換的前提是即將要變換的兩個引數應該是產生自同一上下文的,例如上邊的
OT(Oa1, Ob)
,當Ob'
產生之後,此時Oa2
和Ob'
都是經過了Oa1
操作之後得到的,也同屬於同一上下文,那麼OT(Oa2, Ob')
的變換操作也是可行的。 - 可以應用的前提是
Op
產生自同一上下文,例如上邊的Ob''
,即將應用時可以追溯到其產生的上下文的位置是S
,也就是文件的初始狀態,而產生Oa1
和Oa2
兩個操作的初始狀態也是S
,那麼應用Ob''
的操作也是可行的。
那麼假如例子再複雜一些,A
與B
分別都產生了兩個Op
,那麼該如何處理呢,那麼此時就是去查詢,找到可以做OT
的OP
,逐個進行變換,直到OP
變換到當前上下文可用。我們假設S(x,y)
表示在位置(x,y)
的文件狀態,x, y
分別表示A, B
兩個客戶端的狀態,A(x,y)
表示客戶端A
在狀態S(x,y)
下產生的操作,B(x,y)
表示客戶端B
在狀態S(x,y)
下產生的操作,那麼:
S(x,y) o A(x,y) = S(x+1,y)
S(x,y) o B(x,y) = S(x,y+1)
- 文件的初始狀態為
S(0,0)
。 A
執行了操作A(0,0)
,狀態更新為S(1,0)
,再執行A(1,0)
,狀態更新為S(2,0)
B
執行了操作B(0,0)
,狀態更新為S(0,1)
,再執行B(0,1)
,狀態更新為S(0,2)
。- 在
B
中,A(0,0)
基於B(0,0)
做OT
,得到可在狀態S(0,1)
上應用的A(0,1)
,可得S(1,1)
。 - 在
B
中,A(0,1)
基於B(0,1)
做OT
,得到可在狀態S(0,2)
上應用的A(0,2)
,可得S(1,2)
。 - 在
A
中,B(0,0)
基於A(0,0)
做OT
,得到可在狀態S(1,0)
上應用的B(1,0)
,可得S(1,1)
。 - 在
A
中,B(1,0)
基於A(1,0)
做OT
,得到可在狀態S(2,0)
上應用的B(2,0)
,可得S(2,1)
。 - 在
B
中,A(1,0)
基於B(1,0)
做OT
,得到可在狀態S(1,1)
上應用的A(1,1)
,可得S(2,1)
。 - 在
A
中,B(0,1)
基於A(0,1)
做OT
,得到可在狀態S(1,1)
上應用的B(1,1)
,可得S(1,2)
。 - 在
B
中,A(1,1)
基於B(1,1)
做OT
,得到可在狀態S(1,2)
上應用的A(1,2)
,可得S(2,2)
。 - 在
A
中,B(1,1)
基於A(1,1)
做OT
,得到可在狀態S(2,1)
上應用的B(2,1)
,可得S(2,2)
。
可以透過圖來比較直觀地觀察兩者究竟是如何進行的操作,當然實際上這也是多個菱形,只不過擺正了而已,兩個客戶端需要關注最外層的兩條線。當然上述流程以及圖中表現的是一個完整的狀態變換,對於A
和B
客戶端各自的變換來說,並不是都需要完整地進行所有狀態的變換的。對A
而言,我們首先需要根據B(0,0)
與A(0,0)
變換出B(1,0)
,再根據B(1,0)
與A(1,0)
變換出B(2,0)
,然後A(0,0)
與B(0,0)
變換出A(0,1)
,A(1,0)
與B(1,0)
變換出A(1,1)
,之後B(0,1)
與A(0,1)
變換出B(1,1)
,最後由B(1,1)
與A(1,1)
變換出B(2,1)
,這樣就得到了S(2,0) -> S(2,2)
所需要的兩個Op - B(2,0) B(2,1)
。同理,對於B
而言需要A(0,0)
與B(0,0)
變換出A(0,1)
,A(0,1)
與B(0,1)
變換出A(0,2)
,然後B(0,0)
與A(0,0)
變換出B(1,0)
,B(0,1)
與A(0,1)
變換出B(1,1)
,之後A(1,0)
與B(1,0)
變換出A(1,1)
,最後由A(1,1)
與B(1,1)
變換出A(1,2)
,這樣就得到了S(0,2) -> S(2,2)
所需要的兩個Op - A(0,2) A(1,2)
。
S(0,0) → A(0,0) → S(1,0) → A(1,0) → S(2,0)
↓ ↓ ↓
B(0,0) B(1,0) B(2,0)
↓ ↓ ↓
S(0,1) → A(0,1) → S(1,1) → A(1,1) → S(2,1)
↓ ↓ ↓
B(0,1) B(1,1) B(2,1)
↓ ↓ ↓
S(0,2) → A(0,2) → S(1,2) → A(1,2) → S(2,2)
對於A
、B
雙方,最終我們都得到了S(2,2)
的狀態,請注意我們在客戶端的起始位置是S(2,0)
與S(0,2)
,所以我們不能在以S(1,1)
為基準的基礎上做A(1,0)
與B(0,1)
的OT
,而我們實際應用的Op
如下所示,其餘的狀態都只是中間狀態。
A:
A(0,0) --> A(1,0) --> B(2,0) --> B(2,1)
S(2,0) ο B(2,0) ο B(2,1) = S(2,1) ο B(2,1) = S(2,2)
B:
B(0,1) --> B(0,2) --> A(0,2) --> A(1,2)
S(0,2) ο A(0,2) ο A(1,2) = S(1,2) ο A(1,2) = S(2,2)
中央伺服器
在前邊只是兩位使用者之間進行協同的操作,我們也探討了多個Op
的情況下如何進行OT
,在實際的應用場景中,我們還需要中央伺服器的角色來進行收集、派發、儲存各個客戶端的Op
,被儲存的Op
代表了可連續應用的操作序列,可以用這些Op
來描述一整篇文件的內容。服務端的如何排程各個Op
,也是需要進行設計的,實現的演算法的可靠性與效率決定了我們的應用的表現。
在研究有了中央伺服器加入的協同之前,我們先來思考一下為什麼協同這麼難以實現,究竟是什麼造成的,那麼假如此時我們利用中央伺服器來將多個使用者的操作強行指定成同步操作會怎麼樣,也就是說我們所有本地進行的操作需要由伺服器來進行Apply Op
,本地雖然做了修改但是並不應用,也就是說我們本地寫的內容不會立即應用到客戶端上,需要中央伺服器的確認之後才會正常顯示,所有的Op
都是在服務端中進行並且應用之後再同步到客戶端,類似於悲觀鎖,只不過這個鎖能夠自動轉移。假如是這種情況下,我們似乎就不需要一個很完善的排程演算法了,因為是儘可能地保證了一個同步鎖,當然由於網路的延時,還是很有可能出現衝突的問題,而且使用者體驗會特別差。那麼回到我們正常的協同上,可以想到造成協同比較難以實現的一個原因是網路的傳輸,另一個原因就是有N
個客戶端可以同時應用Op
,在無法實現完整同步的情況下,併發操作就有可能造成問題,由此就必須設計演算法來進行排程,關於這塊也可以看一下CAP
理論。
回到服務端加入後的OT
協同的場景,假設我們此時有A
、B
、Server
三者,我們實際上可以認為通訊的只有兩位,也就是A/B
與Server
通訊,A
與B
並不會直接通訊,所有的客戶端都只與Server
通訊,畢竟要是N
個客戶端直接通訊的話,那就處理同步與衝突解決就太複雜了。那麼此時,我們需要設計一下服務端的排程方案,我們先從最簡單的開始,假設我們的服務端只處理衝突,但是不解決衝突,如果發現衝突我們就將衝突的部分退回,並且攜帶從相同的起點S
以來所有的Op
,讓客戶端去解決衝突計算該應用的Op
,然後重新提交。
依照上邊的設計,我們做一下場景的推演,假定文件的初始狀態為S(0,0)
。
- 服務端已經儲存了
B
使用者的三個操作,B(0,0)
、B(0,1)
、B(0,2)
,文件狀態步進到了S(0,3)
。 A
使用者在S(0,0)
狀態下開啟文件,執行了四個操作A(0,0)
、A(1,0)
、A(2,0)
、A(3,0)
,文件狀態到達了S(4,0)
。- 此時到了同步環節,當
A
使用者將本地操作OpA 0-3
提交到服務端時,服務端文件此時的狀態是S(0, 3)
,而A
使用者的操作產生於S(0,0)
,在服務端無法直接應用,因此服務端不接收這些操作,但服務端把S(0,0)
後落庫的操作B(0,0)
、B(0,1)
、B(0,2)
幾個操作給到了A
,相當於給了A
所有S(0,0)
之後的變更,因為我們設計的服務端不處理衝突,所以需要讓A
去進行操作變換,當A
變換完成之後再度提交到服務端。 A
獲得服務端下發的OP
後,進行OT
,A(0,0) A(1,0) A(2,0) A(3,0)
基於B(0,0) B(0,1) B(0,2)
做變換,得到了A(0,3) A(1,3) A(2,3) A(3,3)
,對於這個OT
的結果,由於在服務端的狀態此時狀態為S(0,3)
,等同於A(0,3)
的所處的語境,服務端可以直接應用,那麼在A
這裡,注意這裡與之前同步的操作不一樣,之前同步做的OT
是將B
的Op
同步過來我們要應用到A
上,而此時我們做OT
的操作是在B
的基礎上做A
,然後在A
上應用變換後的A
,所以此時我們應該撤銷掉我們做過的A(0,0) A(1,0) A(2,0) A(3,0)
,然後應用B(0,0) B(0,1) B(0,2)
再應用A(0,3) A(1,3) A(2,3) A(3,3)
,此時我們的狀態便可以達到S(4,3)
,相當於模擬了一遍服務端要儲存的Ops
。- 當
A
達到狀態S(4,3)
後,我們可以向服務端提交A(0,3) A(1,3) A(2,3) A(3,3)
,服務端接受到這四個Op
後,由於此時所處的狀態為S(0,3)
,等同於A(0,3)
的所處的語境,服務端可以直接應用,那麼服務端也可以到達狀態S(4,3)
。 - 緊接著,服務端再將
A(0,3) A(1,3) A(2,3) A(3,3)
同步到客戶端B
,B
的狀態也是S(0,3)
,所以B
也可以直接應用這個操作,那麼此時三方的狀態都達到了S(4,3)
,達到了最終一致性。
看起來這個服務端設計還是可行的,但是設想一個場景,假如在A
做好了操作變換之後,再次嘗試提交時,服務端又多了B
的新的操作B(0,4)
,那麼此時A
新的操作因為上下文不匹配,再次被駁回,那麼在一個多人協同密集的應用中,這樣的架構設計顯然是不可用的。總結一下,這個設計方案優點是在服務端只檢測衝突,實現起來簡單,而且保證了各端的操作順序一致,一致性好;缺點就是在密集場景下打回機率高,操作容易滯留在本地,無法落庫,客戶端由於打回要頻繁執行OT
,會阻塞使用者編輯。綜上,架構能支援的協同人數非常有限,是一個不可用的架構。
既然前邊我們設計的架構不夠完善,那麼我們對其進行改進,既然服務端只處理衝突,但是不解決衝突的方案不行,那我們就讓服務端也能夠解決衝突,並且允許客戶端隨意提交,這樣的設計會發生什麼情況,我們依舊依照上邊的例子進行推演。
假定文件的初始狀態為S(0,0)
。
- 服務端已經儲存了
B
使用者的三個操作,B(0,0)
、B(0,1)
、B(0,2)
,文件狀態步進到了S(0,3)
。 A
使用者在S(0,0)
狀態下開啟文件,執行了四個操作A(0,0)
、A(1,0)
、A(2,0)
、A(3,0)
,文件狀態到達了S(4,0)
。- 此時
A
將Ops
傳送到了服務端,服務端在此時執行OT
,將OT
結果儲存落庫後,服務端的狀態也步進到S(4, 3)
。 - 此時服務端需要在基於
A
的操作對B
操作做變化,也就是將B(0,0) B(0,1) B(0,2)
在A(0,0) A(1,0) A(2,0) A(3,0)
基礎上做OT
,得到B(4,0) B(4,1) B(4,2)
,將OT
之後的B
操作傳送給A
,A
執行Ops
之後狀態從S(4,0)
到達了S(4,3)
。 - 服務端還需要將
A(0,3) A(1,3) A(2,3) A(3,3)
發給B
,狀態從S(0,3)
步進到S(4,3)
,那麼此時三方的狀態都達到了S(4,3)
,達到了最終一致性。
看起來這個服務端設計也還是可行的,主要在於服務端承載瞭解決衝突與分發Op
的功能,但是再設想一個場景。
- 假如服務端做好了
B(4,0) B(4,1) B(4,2)
的OT
後交還給A
的時候,A
本地又產生了兩個Op A(4,0) A(5,0)
,此時A
本地的狀態步進到了S(6,0)
,那麼服務端傳過來的OpB
是無法應用到本地的。 - 那麼此時在
A
中需要進行OT
,對B(4,0) B(4,1) B(4,2)
基於A(4,0) A(5,0)
做變換,得到B(6,0) B(6,1) B(6,2)
,此時A
需要應用B(6,0) B(6,1) B(6,2)
,狀態從S(6,0)
步進到S(6,3)
。 - 然後
A
需要將A(4,0) A(5,0)
傳送給服務端,然後再依據之前的過程完成服務端OT
,得到A(4,3), A(5,3)
,最終各端的狀態能達到相同的S(6,3)
。
總結起來,該架構設計的特點是當服務端收到Op
時,服務端檢測衝突,若無衝突直接落庫儲存,存在衝突則進行服務端OT
,並將結果傳送到客戶端,當客戶端收到Op
時,若無衝突,則直接應用,反之進行客戶端OT
再應用收到的Op
。那麼根據上邊的例子,我們可以看到對於A
和B
而言,兩者執行的Op
實際上是不一致的。
A: S(0,0) -> A(0,0) A(1,0) A(2,0) A(3,0) A(4,0) A(5,0) B(6,0) B(6,1) B(6,2) -> S(6,3)
B: S(0,0) -> B(0,0) B(0,1) B(0,2) A(0,3) A(1,3) A(2,3) A(3,3) A(4,3) A(5,3) -> S(6,3)
因此這個方案實際上依賴於S o OpsA o OT(OpsA, OpsB) = S o OpsB o OT(OpsB, OpsA)
,又為演算法增加了複雜性。這個設計方案的優點是在服務端能夠解決衝突,客戶端隨意提交,不會打回,但是缺點是服務端需要做大量的OT
,而且OT
的結果需要傳送給所有的客戶端,這樣的設計會導致服務端的壓力非常大,在密集的多人協同的場景下,這樣的設計能夠支援的協同人數也會變得非常有限,如果客戶端源源不斷的地提交Op
,服務端也將疲於應付,而且客戶端也不能及時收到其他客戶端的更新,此外如果有N
個客戶端同時傳送Op
,那麼服務端進行OT
的時候需要維護一個N
維狀態向量,這個過程的複雜度可就不只是上文我們看到的二維的棋盤變換了,這個架構也難以付諸實踐。
此時我們再來改進一下方案,我們一直以來都是得到的Op
就做變換與應用,沒有一個時序的概念,之前說的順序都是指時間先後順序,衝突也是指同時產生編輯,但我們現在在同時這個概念上可以換一個方式理解,我們不再去考慮時間上的同時,而是版本上的同時。也就是說我們需要用一個東西表示每一個版本,類似git
的每次提交有一個Commit Id
,在這裡我們每次提交到服務端的時候就要告訴服務端,我的修改是基於哪個版本的修改。那麼最簡單的標誌位就是遞增的數字,我們得到一個邏輯上的時序而不是類似於時間戳這種時間上的時許,那基於版本的衝突,可以簡單理解為我們都是基於100
版本的提交,那就是衝突了,也許我們並不是同時,只是誰先到後臺決定了誰先被接受而已。當然在這裡比較複雜的就是離線編輯,可能正常版本已經到了1000
了,某個使用者因為離線了,本地的版本一直停留在100
,提交上來的版本是基於100
的,那這個菱形就是一個單向擴充比較大的菱形了。
有了上邊的時序的概念,我們再來看看具體的服務端與客戶端的架構設計,我們可以限制客戶端提交頻度,為了簡單起見我們每次都只能讓客戶端提交一個Op
,直到服務端處理完成之後,客戶端收到確認之後,我們才可以繼續傳送第二個Op
,此時我們也是用邏輯上的時序,也就是一個單調自增的版本號來表示上下文語境,那麼我們此時就有了幾個狀態,簡單起見,在推演的過程中我們是用一個Sending
一個Pending
來分別表示等待確認的以及還未傳送的Op
。
那麼此時我們表示的運算子號發生了改變,假定初始版本為0
,且每次變更都會讓版本號+1
,客戶端提交Op
時,需要捎帶版本號也就生生成該操作的語境,以便檢測衝突,那麼我們使用Op(Index)
來表示完整的操作,例如A0(0)
表示OpA0
,並且操作的語境(邏輯時序)為0
,B0(1)
表示OpB0
,並且操作的語境(邏輯時序)為1
。
- 客戶端
A
本地產生了兩個操作A0(0)
、A1(1)
。 - 客戶端
B
本地產生了一個操作B0(0)
。 - 首先
B0(0)
被提交到服務端,此時B0(0)
在B
客戶端的Sending
佇列中,由於此時服務端中Op
序列為空,因此B0(0)
可以直接落庫,服務端將版本更新為1
,並且將B0(0)
傳送至其他客戶端。 - 服務端將
B0(0)
發給其他客戶端,然後傳送ACK
到B
,通知B
該Op
已經確認,此時B
將B0(0)
從Sending
佇列中出隊,並且同步服務端的版本號為1
。 - 在客戶端
A
,提交了A0(0)
,此時A
的Sending
佇列中有A0(0)
,Pending
佇列中有A1(1)
。 - 服務端收到
A0(0)
後,此時服務端的版本號大於收到的版本號,由此檢測到衝突,服務端執行OT
,將獲得的A0'(1)
落庫,更新服務端版本為2
,並將A0'(1)
分發到其他客戶端,以及向客戶端A
返回A0
的ACK
。 - 在客戶端
A
,收到了服務端傳送的B0(0)
,A
檢測到衝突,基於A0(0), A1(1)
對B0(0)
做變換,得到B0'(2)
,並更新本地版本為3
。 - 接下來
A
收到了A0'(1)
的ACK
,此時本地版本號已經到達了3
,但是ACK
確認的服務端版本號是2
,此時我們依舊保持版本3
,並且A
將A0(0)
從Sending
出隊,然後將A1(1)
傳送到服務端,並且從Pending
出隊再入隊Sending
,當然即將要傳送的A1(1)
也可以在上邊收到B0(0)
就做處理,然後作為ACK
同步過來的版本號傳送出去,類似於提前解決衝突,這就涉及具體實現了。 - 同理,
B
客戶端收到ACK
以後也更新自己的版本為1
,緊接著到來的A0'(1)
也可直接應用,更新本地版本到2
。 - 在服務端,當前版本是
2
,因此收到的A1(1)
發生了衝突,需要進行OT
變換,得到A1(2)'
後並應用,服務端更新版本為3
,併傳送A1(2)'
到其它客戶端,以及向客戶端A
回撥A1
的ACK
。 B
再收到A1(2)'
之後,能夠直接應用,應用後更新狀態為3
。A
收到A1(2)'
的ACK
之後,將A1(1)
出隊Sending
,更新本地版本號為3
,至此各個客戶端和服務端到達了一致的版本3
。
上述的實現就比較接近真實的OT
實現場景了,基於ACK
機制,不但限制了Op
提交的頻度,也方便地透過簡單地版本號就表示了文件上下文,避免維護一個N
維狀態向量。具體到真實的實現,例如ot.js
,透過三種狀態來控制操作,Synchronized
沒有正在提交併且等待回包的Op
,AwaitingConfirm
有一個Op
提交了,等待後臺確認,本地沒有編輯資料,AwaitingWithBuffer
有一個Op
提交了,在等待後臺確認,本地有編輯資料。接下來就是對於這三種狀態分別進行處理了,可以具體實現可以參考https://github.com/Operational-Transformation/ot.js/blob/master/lib/client.js
,還有一個視覺化的實現http://operational-transformation.github.io/index.html
。
最後
在上邊的論述中我們似乎得到了一個不錯的方案,但是實際上文中描述的內容也只是冰山一角,一個穩定協同過程還面臨著諸多問題,例如需要支援多人協同的Undo/Redo
,保證客戶端與服務端OT
演算法的統一、在CAP
理論下如何做取捨策略、如何保證多人協同的編輯器效能、如何保證資料的穩定性可恢復可回溯、游標的同步處理等等,當然不可能擁有從一開始就完美的架構設計,都是在發現問題之後一步步地讓其變得完美。
說了這麼多,實際上目前已經有很多開源的OT
演算法實現,我們並不需要特別關注於具體實現的細節,當然基礎理論還是要懂的,當前有很多成熟的框架,例如ot.js
、ShareDb
、ot-json
、EasySync
等等,當然因為場景的複雜性,就算是我們接入也是需要大量工作的,文章也提到了,具體到Transformation
是需要自己實現的,當然對於開源的富文字引擎來說也有很多開源的實現,在接入之前也是有比較深入研究一下的,否則很容易有種無從下手的感覺,特別推薦閱讀實現的單元測試部分,來了解OT
演算法處理的場景和範圍,在這裡推薦兩個OT
的實現,基於Quill
實現的https://github.com/ottypes/rich-text
與基於Slate
實現的https://github.com/solidoc/slate-ot
。
每日一題
https://github.com/WindrunnerMax/EveryDay
參考
https://zhuanlan.zhihu.com/p/50990721
https://zhuanlan.zhihu.com/p/426184831
https://zhuanlan.zhihu.com/p/559699843
https://zhuanlan.zhihu.com/p/425265438
http://www.alloyteam.com/2020/01/14221/
http://www.alloyteam.com/2019/07/13659/
https://segmentfault.com/a/1190000040203619
https://www.shangyexinzhi.com/article/4676182.html
http://operational-transformation.github.io/index.html
https://xie.infoq.cn/article/a6fad791493bf4f698781d98e
https://github.com/yoyoyohamapi/book-slate-editor-design
https://www3.ntu.edu.sg/scse/staff/czsun/projects/otfaq/