ShowMeBug 核心技術內幕

Rina發表於2019-11-21

ShowMeBug 是一款遠端面試工具,雙方可通過線上面試板進行實時溝通技術。所以關鍵技術要點在於 “實時同步”。關於實時同步,ShowMeBug 採用了以下技術。

OT 轉換演算法

本質上,ShowMeBug 核心就是多人同時線上實時編輯,難點即在這裡。因為網路原因,操作可能是非同步到達,丟失,與他人操作衝突。想想這就是個複雜的問題。

經過研究,最好使用者體驗的方式是 OT 轉換演算法。此演算法由 1989 年 C. Ellis 和 S. Gibbs 首次提出,目前像 quip,google docs 均用的此法。

OT 演算法允許使用者自由編輯任意行,包括衝突的操作也可以很好支援,不用鎖定。它的核心演算法如下:

文件的操作統一為以下三種型別的操作( Operation ):

  1. retain(n): 保持 n 個字元
  2. insert(s): 插入字串 s
  3. delete(s): 刪除字串 s

然後客戶端與服務端各記錄歷史版本,每次操作都經過一定的轉換後,推送給另一端。

轉換的核心是

S(o_1, o_2) = S(o_2, o_1)

換言之,把正在併發的操作進行轉換合併,形成新的操作,然後應用在歷史版本上,就可以實現無鎖化同步編輯。

下圖演示了對應的操作轉換過程。

https://daotestimg.dao42.com/ipic/070918.j...

這個演算法的難點在於分散式的實現。客戶端服務端均需要記錄歷史,並且保持一定的序列。還要進行轉換演算法處理。

OT Rails 側的處理

本質上,這是一個基於 websocket 的演算法應用。所以我們沒有懷疑就選用 ActionCable 作為它的基礎。想著應該可以為我們節省大量的時間。實際上,我們錯了。

ActionCable 實際上與 NodeJS 版本的 socket.io 一樣,不具備任何可靠性的保障,做一些玩意性的聊天工具還可以,或者做訊息通知允許丟失甚至重複推送的弱場景是可以的。但像 OT 演算法這種強要求的就不可行了。

因為網路傳輸的不可靠性,我們必須按次序處理每一個操作。所以首先,我們實現了一個互斥鎖,也就是針對某一個面試板,準備一個鎖,同時只有一個操作可以進行操作。鎖採用了 Redis 鎖。實現如下:

def unlock_pad_history(lock_key)
logger.debug "[padable] unlock( lock_key: #{lock_key} )..."
old_lock_key = REDIS.get(_pad_lock_history_key)
if old_lock_key == lock_key
REDIS.del(_pad_lock_history_key)
else
log = "[FIXME] unlock_pad_history expired: lock_key=#{lock_key}, old_lock_key=#{old_lock_key}"
logger.error(log)
e = RuntimeError.new(log)
ExceptionNotifier.notify_exception(e, lock_key: lock_key, old_lock_key: old_lock_key)
end
end

def lock_pad_history(lock_key)
return REDIS.set(_pad_lock_history_key, lock_key, nx: true, ex: 5*60)
end

def wait_and_lock_pad_history(lock_key, retry_times = 200)
total_retry_times = retry_times
while !lock_pad_history(lock_key)
sleep(0.05)
logger.debug '[padable] locked, waiting 50ms...'
retry_times-=1
raise "wait_and_lock_pad_history(in #{total_retry_times*0.1}s) #{lock_key} failed" if retry_times == 0
end
logger.debug "[padable] locking it(lock_key: #{lock_key})..."
end

服務端的併發控制完畢後,客戶端通過 “狀態佇列” 技術一個個排隊釋出操作記錄,核心如下:

class PadChannelSynchronized {
sendHistory(channel, history){
channel._sendHistory(history)
return new PadChannelAwaitingConfirm(history)
}
}

class PadChannelAwaitingConfirm {
constructor(outstanding_history) {
this.outstanding_history = outstanding_history
}

sendHistory(channel, history){
return new PadChannelAwaitingWithHistory(this.outstanding_history, history)
}

receiveHistory(channel, history){
return new PadChannelAwaitingConfirm(pair_history[0])
}

confirmHistory(channel, history) {
if(this.outstanding_history.client_id !== history.client_id){
throw new Error('confirmHistory error: client_id not equal')
}
return padChannelSynchronized
}
}

class PadChannelAwaitingWithHistory {
sendHistory(channel, history){
let newHistory = composeHistory(this.buffer_history, history)
return new PadChannelAwaitingWithHistory(this.outstanding_history, newHistory)
}
}

let padChannelSynchronized = new PadChannelSynchronized()

export default padChannelSynchronized

以上,便實現了一個排隊傳送的場景。

除此之外,我們設計了一個 PadChannel 用來專門管理與伺服器通訊的事件,維護歷史的狀態,處理斷線重傳,操作轉換與校驗。

定義自己的歷史(history) 協議

解決了編輯器協同的問題,才是真正的問題的開始。每次的 ”程式碼執行”,“編輯”,“清空終端”,“首次同步” 都是需要記錄的歷史操作。於是,ShowMeBug 定義了以下協議:

#

值得說明的是,client_id 是客戶端生成的一個8位隨機碼,用於去重和與客戶端進行 ACK 確認。

id 是由服務端 Redis 生成的自增 id,客戶端會根據這個判斷歷史是否是新的。prev_id 用來操作轉換時記錄所需要進行轉換操作的歷史佇列。

event 是最重要的操作記錄,我們用 OT 的轉換資料進行儲存,如: [length, "string", length]

通過上述的設計,我們將面試板的所有操作細節涵蓋了,從而實現多人面試實時同步,面試題和麵試語言自動同步,操作回放等核心功能。

總結

篇幅限制,這裡只講到 ShowMeBug 的核心技術,更多的細節我們以後繼續分享。

ShowMeBug 目前承載了 3000 場面試記錄,成功支撐大量的實際面試官的面試,可靠性已得到進一步保障。這裡面有兩種重要程式設計正規化值得考慮:

  1. 如何在不可信鏈路上設計一種有序可靠的交付協議,定義清晰的協議資料和處理好非同步事件是關鍵。
  2. 如何平衡研發效率與穩定性之間的關係,比如實現的忙等鎖,允許一定原因的失敗,但處理好使用者的提示與重試。既高效完成了功能,又不影響到使用者體驗。

ShowMeBug( showmebug.com ) 讓你的技術面試更高效,助你找到你想要的候選人。

本作品採用《CC 協議》,轉載必須註明作者和本文連結

相關文章