石墨文档的云端表格实时压缩策略

多人实时协做是石墨文档吸引人的一大特性之一。使用石墨文档,你能够和同事、朋友同时编写一篇文档或表格,每一个人的修改都会实时的同步给其余人。你能够看到每一个人光标的跳动,每个键入的文字。一篇篇运营文案、一份份产品头脑风暴,伴着一杯杯茶与咖啡,就这样在石墨文档上诞生了。算法

美好的事物背后老是充满艰辛。在技术实现上,多人实时编写会形成许多的冲突,拿表格来讲,当用户 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 进行计算,用户实际的表格编写体验并不会受到影响,反而下降碎片整理频率最终能给用户带来更好的编写体验。

咱们正在招聘!

石墨文档技术部是一个有趣的团队,咱们热衷于尝试新技术,思考新方向,探索一切能够为目之可及的世界增添色彩的方法。欢迎加入咱们来一块儿改进身边人的文档编写体验,经历人生中的下一场波澜!

[北京/武汉] 石墨文档 作最美产品 - 寻找中国最有才华的工程师加入

相关文章
相关标签/搜索