初探富文字之OT協同演算法

WindrunnerMax發表於2023-01-08

初探富文字之OT協同演算法

OT的英文全稱是Operational Transformation,是一種處理協同編輯的演算法。當前OT演算法用的比較多的地方就是富文字編輯器領域了,常用於作為實現文件協同的底層演算法,支援多個使用者同時編輯文件,不會因為使用者併發修改導致衝突,而導致結果不一致甚至資料丟失的問題。

描述

從名字就可以看出來,OT協同演算法的重點在於操作Operation與轉換Transformation,簡單來說,操作Operation指明瞭所有的操作必須原子化,例如在第N個位置插入了某個字元,在第M個位置刪除了某個字元,類似於這樣的所有的操作必須能夠原子化地表示,轉換Transformation指明瞭所有的操作必須要有轉換的方案,例如我在第N個位置插入了字元,你在N+2個位置同時插入了字元,假設我的操作比較靠前,由於需要同步操作,那麼在我本地執行你的Operation時就必須將其轉換,插入的位置就必須增加我插入字元的長度,這就是大概的OT所需要的條件,當然具體的演算法要遠遠比這個複雜,並且存在例如同步排程、Undo/Redo、游標、穩定性、可溯源等等問題需要一併解決。本文不涉及具體的協同演算法,只是探討了OT協同演算法的基本思路,當前也有比較成熟的OT協同框架例如ShareDB等,可以相對簡單地接入,當然只是相對而言,成本也是不低的。

在討論具體的協同演算法之前,我們探究一下為什麼要有協同演算法,如果沒有協同演算法的話會出現什麼問題,以及具體會出現問題的場景。那麼假如我們有一個線上的文件應用,而我們是一個團隊,我們有可能對同一篇文件進行編輯,既然我們會同時編輯,那麼就有可能產生衝突。假設文件此時的內容為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協同演算法。

OT協同演算法

Operational Transformation(OT)協同演算法的核心思想是將文件的每一次修改都看作是一個操作,然後將這些操作進行轉換來合併,最終得到文件內容。OT演算法的目的是在儘可能保持使用者意圖的情況下,保持文件的最終一致性,舉個例子,當AB同時在文件的L處插入了不同的字元,那麼誰插入的字元在前協同演算法並不關心,其只需要儘可能地根據一定策略例如時間戳來判斷究竟是誰的字元在前,但是最終計算出的結果即究竟誰的字元在前並不影響協同演算法,其關心的重點在於經過協同演算法將使用者產生的Op排程之後,在每個人面前呈現的文件內容是絕對一致的,這就是保持文件的最終一致性。從功能的角度上說,協同演算法保證的是在多人同時線上編輯的情況下,由於每個人提交的內容是不一樣的,就需要透過協同演算法的排程,使得每個使用者最終都能看到一樣的內容。

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

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

基本原理

回到我們要介紹的OT協同,我們在這裡不涉及具體的協同演算法,我們的側重點在於實現OT的動機,例如協同為什麼不是直接應用協作者的Op即可、為什麼要有操作變換、如何進行操作變換、什麼時候能夠應用Op等等,當我們知道了一個技術的來由與動機時,其實現甚至都有可能躍然紙上了。那麼在這裡我們從AB兩者同時編輯同一段文字的基本操作開始,探討一下OT協同為了保持一致性究竟做了什麼。描述一篇文件的方式有很多,最經典的Operationquilldelta模型,透過retaininsertdelete三個操作完成整篇文件的描述,還有slateJSON模型,透過insert_textsplit_noderemove_text等等操作來完成整篇文件的描述。在這裡我們假設有一個加粗的操作Bold(start, end),一個插入的操作insert(position, content)來描述整篇文件。

那麼此時,我們假設原始文字為12,使用者AB分別進行了一個加粗操作一個插入操作。

  • 使用者A進行了一個Bold(1, 2)操作,A本地需要首先應用這個操作,由此A本地的文字是(12),為了簡單起見,加粗用()表示。
  • 使用者B同時也進行了一個insert(2, "B")操作,B本地需要首先應用這個操作,由此B本地的文字是12B
  • 此時需要同步Operation,使用者A收到了使用者Binsert(2, "B")操作,A從本地的(12)應用之後,得到了(12)B
  • 使用者B收到了使用者ABold(1, 2)操作,B從本地的12B應用之後,得到了(12)B

看起來並沒有發生任何衝突,AB最終都獲得了一致的文件內容(12)B,當然事情並沒有那麼簡單,我們繼續往下看看其他的情況。為了簡單起見,我們假設目前的只有insert(position, content)這個操作,從定義也能夠明顯的看出來,這個函式的意思是在position處插入content文字。

那麼此時,我們假設原始文字為123,使用者AB分別進行了一個插入操作。

  • 使用者A進行了一個insert(2, "A")操作,A本地需要首先應用這個操作,由此A本地的文字是12A3
  • 使用者B同時也進行了一個insert(3, "B")操作,B本地需要首先應用這個操作,由此B本地的文字是123B
  • 此時需要同步Operation,使用者A收到了使用者Binsert(3, "B")操作,A從本地的12A3應用之後,得到了12AB3
  • 使用者B收到了使用者Ainsert(2, "A")操作,B從本地的123B應用之後,得到了12A3B

經過上述協同結果是,使用者A看到的內容是12AB3,使用者B看到的內容是12A3B,內容不一致,沒有成功地保證最終一致性。那麼根據OTOperational Transformation這個名字,我們來看上邊的協同,發現我們只是做了Operation的同步,並沒有做Transformation去轉換,所以我們這是一個不完整的協同,當然也就不能完整地覆蓋各種Case

我們再來看看上邊的協同方法有什麼問題,實際上我們只是對我們自己本地的內容應用了從其他位置同步過來的操作,而這個操作是失去了上下文Context的,也可以稱為語境,具體來說,我們以A為例,當我們接受到Binsert(3, "B")操作時,這個Op實際上是在原始文字為123這個文字為上下文的基礎上進行的Op,而此時我們本地的文字內容是12A3,而此時去執行BOp就由於缺失了上下文而導致出現了問題,所以此時我們就需要OTTransformation來將其進行轉換,當協作者變更到來時,我們需要變換操作以適應當前上下文,才能直接應用,而調整的過程,則基於當前文件已經發生的變更來完成。

Ob' = OT(Oa, Ob)
Oa' = OT(Ob, Oa)

而由上邊上下文的基本想法我們可以得到OT協同的基本思路是,將每個使用者的操作都轉換成相對於原始文字的操作,這樣就可以保證最終一致性。具體來說,假設文件的初始狀態為S,以同步時的A使用者為例我們此時應用了Oa也就是insert(2, "A")這個操作,而此時恰好我們又收到了BOb也就是insert(3, "B")操作,那麼我們此時要應用Ob的時候,就需要進行轉換,也就是Ob' = OT(Oa, Ob),注意此時我們是將Oa也作為引數傳入了進去,也就是說此時我們是透過OaOb來作為引數算出來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,假如是上文提到的retaininsertdelete三種操作來描述文件的話,就需要實現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,使用者AB分別進行了一個插入操作。

  • 使用者A進行了一個insert(2, "A")操作,A本地需要首先應用這個操作,由此A本地的文字是12A3,可以看作是2後邊插入了A
  • 使用者B同時也進行了一個insert(3, "B")操作,B本地需要首先應用這個操作,由此B本地的文字是123B,可以看作是3後邊插入了B
  • 此時需要同步Operation,使用者A收到了使用者Binsert(3, "B")操作,經由變換transform(insert(2, "A"), insert(3, "B")) = insert(4, "B")A從本地的12A3應用之後,得到了12A3B
  • 使用者B收到了使用者Ainsert(2, "A")操作,經由變換transform(insert(3, "B"), insert(2, "A")) = insert(2, "A")B從本地的123B應用之後,得到了12A3B

我們最終AB都得到了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失效,原子化失效了後邊的變換、邏輯時序就都會出問題,那這是肯定不行的。

回到對多個Optransform的問題上,假如此時A做了Oa1Oa2兩個Op,假設我們此時是在A的同步過程,也就是A需要在當前的基礎上應用BOp,那麼依照於前文的Ob' = OT(Oa, Ob),我們用當前最新的Oa2作為引數進行變換,也就是即將要應用的Ob' = OT(Oa2, Ob),那麼此時我們可能會看出來問題,Oa1Op資訊丟失了,那麼即將要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'產生之後,此時Oa2Ob'都是經過了Oa1操作之後得到的,也同屬於同一上下文,那麼OT(Oa2, Ob')的變換操作也是可行的。
  • 可以應用的前提是Op產生自同一上下文,例如上邊的Ob'',即將應用時可以追溯到其產生的上下文的位置是S,也就是文件的初始狀態,而產生Oa1Oa2兩個操作的初始狀態也是S,那麼應用Ob''的操作也是可行的。

那麼假如例子再複雜一些,AB分別都產生了兩個Op,那麼該如何處理呢,那麼此時就是去查詢,找到可以做OTOP,逐個進行變換,直到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)

可以透過圖來比較直觀地觀察兩者究竟是如何進行的操作,當然實際上這也是多個菱形,只不過擺正了而已,兩個客戶端需要關注最外層的兩條線。當然上述流程以及圖中表現的是一個完整的狀態變換,對於AB客戶端各自的變換來說,並不是都需要完整地進行所有狀態的變換的。對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)

對於AB雙方,最終我們都得到了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協同的場景,假設我們此時有ABServer三者,我們實際上可以認為通訊的只有兩位,也就是A/BServer通訊,AB並不會直接通訊,所有的客戶端都只與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後,進行OTA(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是將BOp同步過來我們要應用到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)同步到客戶端BB的狀態也是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)
  • 此時AOps傳送到了服務端,服務端在此時執行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操作傳送給AA執行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。那麼根據上邊的例子,我們可以看到對於AB而言,兩者執行的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,並且操作的語境(邏輯時序)為0B0(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)發給其他客戶端,然後傳送ACKB,通知BOp已經確認,此時BB0(0)Sending佇列中出隊,並且同步服務端的版本號為1
  • 在客戶端A,提交了A0(0),此時ASending佇列中有A0(0)Pending佇列中有A1(1)
  • 服務端收到A0(0)後,此時服務端的版本號大於收到的版本號,由此檢測到衝突,服務端執行OT,將獲得的A0'(1)落庫,更新服務端版本為2,並將A0'(1)分發到其他客戶端,以及向客戶端A返回A0ACK
  • 在客戶端A,收到了服務端傳送的B0(0)A檢測到衝突,基於A0(0), A1(1)B0(0)做變換,得到B0'(2),並更新本地版本為3
  • 接下來A收到了A0'(1)ACK,此時本地版本號已經到達了3,但是ACK確認的服務端版本號是2,此時我們依舊保持版本3,並且AA0(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回撥A1ACK
  • B再收到A1(2)'之後,能夠直接應用,應用後更新狀態為3
  • A收到A1(2)'ACK之後,將A1(1)出隊Sending,更新本地版本號為3,至此各個客戶端和服務端到達了一致的版本3

上述的實現就比較接近真實的OT實現場景了,基於ACK機制,不但限制了Op提交的頻度,也方便地透過簡單地版本號就表示了文件上下文,避免維護一個N維狀態向量。具體到真實的實現,例如ot.js,透過三種狀態來控制操作,Synchronized沒有正在提交併且等待回包的OpAwaitingConfirm有一個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.jsShareDbot-jsonEasySync等等,當然因為場景的複雜性,就算是我們接入也是需要大量工作的,文章也提到了,具體到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/

相關文章