石墨文檔的雲端表格實時壓縮策略

多人實時協做是石墨文檔吸引人的一大特性之一。使用石墨文檔,你能夠和同事、朋友同時編寫一篇文檔或表格,每一個人的修改都會實時的同步給其餘人。你能夠看到每一個人光標的跳動,每個鍵入的文字。一篇篇運營文案、一份份產品頭腦風暴,伴着一杯杯茶與咖啡,就這樣在石墨文檔上誕生了。算法

美好的事物背後老是充滿艱辛。在技術實現上,多人實時編寫會形成許多的衝突,拿表格來講,當用戶 Bob 在 B2 單元格編寫內容的時候,他的朋友 Jeff 在 B 列的前面又插入了一列,若是兩個操做同時發給服務器就會產生衝突。在石墨文檔,咱們維護了一個數據計算集羣經過一套算法計算分析來幫助用戶解決衝突。如上面提的例子,最終 Bob 在 B2 單元格編寫內容的操做通過服務端的計算會被 transform 成在 C2 單元格的操做發給 Jeff。數組

爲了儘量地下降多人實時編寫的時延,咱們付出了很是多的努力來使得這套算法可以在符合語義地解決編寫衝突的前提下儘量地高效。數據統計代表,在石墨文檔有將近 90% 的衝突數據計算能夠在幾毫秒的時間內運算完成。成就這瞬息時間的功臣之一,就是咱們這套算法的一個基本原則:運算耗時僅和操做自己相關,與文檔(或表格)原始內容大小無關。換句話來說,就是算法的時間複雜度不能和原始內容大小正相關。瀏覽器

這個基本原則來源於咱們對用戶體驗的直覺感知:隨着用戶在一篇文檔或表格中不斷地編寫,數據同步的速度不該該隨着內容的增多而不斷變慢,不然使用者對寫做體驗的好感會逐漸下降,最終致使用戶慢慢傾向於儘可能少地在石墨文檔上編寫內容。緩存

去年 12 月,石墨文檔正式對外發布了表格公測版。在上線了一段時間後,表格的性能問題逐漸引發咱們的重視。當在表格選擇一個範圍後,設置表格屬性(如對齊方式、字號等)後,程序會爲範圍內的每一個單元格建立一個數據對象來記錄這些數據。若是選擇的範圍很大,數據對象就會變得很是多,影響了網絡傳輸和算法計算的速度。服務器

爲了解決這個問題,咱們決定引入 Range 的概念來將這些擁有一樣屬性的鄰近單元格經過一個範圍矩形來表示。如爲 B2-C4 單元格設置了文本右對齊格式,以前的表示方法爲:markdown

{
  B2: { attributes: { align: 'right' } },
  B3: { attributes: { align: 'right' } },
  B4: { attributes: { align: 'right' } },
  C2: { attributes: { align: 'right' } },
  C3: { attributes: { align: 'right' } },
  C4: { attributes: { align: 'right' } }
}複製代碼

而經過 Range 來表示則爲:網絡

{
  RANGE: {
    start: 'B2',
    end: 'C4',
    attributes: { align: 'right' }
  }
}複製代碼

可見使用 Range 來表示表格內容可以使數據的存儲更爲精簡,這樣既下降了網絡帶寬開銷,也相應地提升了計算的性能。數據結構

肯定目標後,問題就被歸結爲「尋找一個矩陣中的最大公共屬性子矩陣」這樣清晰的算法邏輯了。oop

由經驗可知,實現尋找最大公共矩陣的目標算法的最佳時間複雜度應該是 O(M*N),由於不管漏掉矩陣中的哪個元素,都沒法確保找到的矩陣是最佳方案。另外一方面,與這個問題很是接近的經典算法 Largest Rectangle in Histogram,其時間複雜度爲 O(N)。因此咱們這裏能夠進一步地將算法歸結成尋找 M 次直方圖中的最大矩形,以下圖所示。性能

以 A1-D5 爲矩陣邊界,咱們從 D 列開始開始對每一列計算直方圖的最大矩陣,其中圖中的「upper」爲直方圖的上部方向。對於每一列,咱們使用一個長度爲 N (若是使用 Sentinel 來避免邊界計算的話則爲 N+1)的 cache 數組來存儲當前列的直方圖高度,即其右側連續公共屬性矩陣的長度。拿 B 列舉例,其對應的直方圖爲:

能夠看出,B 列最大的矩陣是由第三行和第四行組成的面積爲 4 的方形。實際計算時能夠經過維護一個堆棧來存儲遞增的直方柱高度,y遍歷一次找出最大的矩形,具體細節能夠參考相關的算法資料。對每列進行一樣的計算,咱們最終能夠得出最終的結果。

然而這種算法雖然可以在功能上解決咱們的需求,可是其卻違背了咱們上述提到的算法的基本原則——每次用戶的修改,即便只更改了一個單元格,由於有可能會把獲得的最大矩形破壞掉,因此咱們也不得不對整個表格進行從新運算。

爲了可以解決這個問題,咱們支持了一個表格存在多個 Range 的結構。在上述算法的基礎上,咱們定義了一個候選矩陣閾值,每當發現一個矩陣得分超過閾值時,就將其加入一個列表中。閾值的大小取值與表格自己的大小(由於表格數據結構自己緩存了自身的大小,因此這裏並不違反「基本原則」)相關,基於咱們根據生產環境中的數據計算出的經驗公式呈正相關關係。加入列表的時候,由於當前的矩形可能和列表中已經存在的矩形重合,重合的面積就是當同時保留這兩個矩形時所浪費的面積,咱們稱之爲冗餘面積。咱們一樣給出了一個經驗公式來根據這個冗餘面積對新加入(或已存在)的候選矩形進行取捨,宏觀來說便是當候選矩形面積越大、冗餘面積越小時就更傾向於保留兩個候選矩形,反之則傾向於捨棄一個候選矩形。

接下來,當用戶對錶格作了修改時,咱們再也不對整個表格進行從新計算了,只須要對 Range 列表進行一些更新。根據修改位置和原先存在的 Range 中的每一個矩形的關係,分爲以下幾種狀況:

  1. 與原先 Range 中的矩形不相連
  2. 與原先 Range 中的矩形相連
  3. 在原先 Range 中的矩形內

以下圖所示:

對於第一種狀況,則判斷用戶修改的矩形是否達到了候選矩陣閾值,若是達到了則加入 Range 列表中,不然就以單元格的形式存儲。

對於第二種狀況,則判斷有沒有新造成一個更大的矩形(根據座標進行簡單運算便可,是一個 O(1) 操做),若是有則更新原矩形,不然就以單元格形式存儲用戶的修改。

對於第三種狀況,用戶的修改會將原來的矩形打散成幾個部分,這時會具體分析打散後的每一個矩形是否達到候選矩陣閾值,若是達到則放入 Range 中,不然就將改矩形轉存成單元格的形式。

可想而知,隨着用戶修改的增多,原有 Range 中的矩形會不斷地被打散,致使愈來愈趨近於候選矩陣閾值;同時屢次增長小的矩形即便最終組成了符合閾值的矩形,也由於沒有全局遍歷致使沒法識別。以上兩種狀況共同致使了 Range 的碎片化。

針對碎片化的問題,咱們爲每一個表格增長了 fragment 參數記錄了當前表格的碎片化程度。每次有針對單元格的操做和行列變換時,就會更新 fragment 的值(實際上,單元格操做和行列變換對 fragment 值的影響並不相同,行列變換時若是命中 Range 中的不少矩形,咱們會將 fragment 值進行更大幅度的提高)。當 fragment 達到臨界值時,咱們會從新跑一次算法來對錶格數據進行一次全盤壓縮,並重置 fragment。

如今,咱們只剩最後一個問題了。那就是儘管咱們對錶格壓縮算法作了精細的優化,實際壓縮起來,面對有幾萬個單元格的大表格來講,壓縮一次也要消耗十幾毫秒左右。並且通常來講,越大的表格,其協做頻率越高,即 fragment 越容易達到臨界值,也致使了壓縮的頻率會更高,從而對服務器的壓力也更大。

當多我的編寫同一份表格時,每一個人拿到的表格數據都是完整且最終一致(約幾十毫秒的時延)的。根據這個背景,咱們在工程層面對大表格的碎片問題進行了進一步地解決:多我的同時編寫表格時,每個用戶都會內置一個碎片計數器並以固定的相位差來定時在瀏覽器端計算候選矩陣列表,而後和當前服務器版本的結果比較,並在下次向服務器發送本地修改時附帶比較的結果。服務器端會根據這個結果相應地調整表格的 fragment 值。對於大表格而言,用戶操做的頻率雖然會相對更高,可是由於每每都是在已經規範好格式的表格中進行編寫的,因此致使的碎片程度反而會比較低。使用這種方法使得服務器只須要在必要的時候才從新計算 Range;而且因爲在瀏覽器端使用了 Web Worker 進行計算,用戶實際的表格編寫體驗並不會受到影響,反而下降碎片整理頻率最終能給用戶帶來更好的編寫體驗。

咱們正在招聘!

石墨文檔技術部是一個有趣的團隊,咱們熱衷於嘗試新技術,思考新方向,探索一切能夠爲目之可及的世界增添色彩的方法。歡迎加入咱們來一塊兒改進身邊人的文檔編寫體驗,經歷人生中的下一場波瀾!

[北京/武漢] 石墨文檔 作最美產品 - 尋找中國最有才華的工程師加入

相關文章
相關標籤/搜索