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.jpg

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

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

# 為防止死鎖,鎖的時間為 5 分鐘,超時自動解鎖,但在 unlock 時會發出異常
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 定義了以下協議:

# 包含以下: edit( 更新編輯器內容 ), run( 執行命令 ), clear( 清空終端 ), sync( 同步資料 )
# select( 游標 ), locate( 定位 )
# history 格式如下:
#
# {
# op: 'run' | 'edit' | 'select' | 'locate' | 'clear'
# id: id // 全域性唯一操作自增 id, 首次前端傳入時為 null, 服務端進行填充, 如果返回時為空, 則說明此 history 被拒絕寫入
# version: 'v1' // 資料格式版本
# prev_id: prev_id // JS 端生成 history 時上一次收到服務端的 id, 用於識別操作序列
# client_id: client_id // 客戶端生成的 history 的唯一標識
# creator_id: creator_id // 操作人的使用者 id, 為了安全首次前端傳入時為 null,由中臺填充
# event: { // op 為 edit 時, 記錄編輯器 OT 轉化後的資料, see here: https://github.com/Aaaaash/blog/issues/10
# [length, "string", length]
# // op 為 select 時, 記錄編輯器選擇區域 (包括游標)
# }
# snapshot: {
# editor_text: '' // 記錄當前編輯器內容快照, 此快照由服務端填充
# language_type: '' // 記錄當前編輯器的語言種類
# terminal_text: '' // 記錄當前終端快照
# }
# }
# created_at: created_at // 生成時間

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

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

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

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

總結

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

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

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

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

相關文章