前言
在线表格在前端领域是公认比较有挑战的一项技术,受制于浏览器自身性能的制约,表格面临诸多难点,包括内存消耗、性能、计算稳定性、渲染等问题。就以内存消耗为例,如何在满足表格功能稳定前提下保证内存消耗足够低,这个问题涉及底层数据存储、公式/渲染缓存等方面,要设计良好的数据存储结构,需要考虑以下业务因素:
1、表格数据分布没有规律,可能存在稀疏情况,也可能比较紧密(比如在线分析场景),那设计存储结构时就要兼顾二者
2、用户期望分析的数据量越大越好,然而浏览器内部单一进程下执行引擎(以V8为例)对堆内存分配有限制,PC端在64位OS下最大允许的内存是4G,在Mac上8G内存最多只允许2G内存,移动端限制更少。所以如何在有限内存下设计良好的存储结构,以便支撑大数据量级的交互,是个有挑战的问题
本文调研现有在线表格产品,首先介绍现有开源表格存储方案,接下来围绕onlyoffice spreadsheet功能,重点理解其sdk内部实现的二进制存储技术,该技术支持在千万格子数据量下内存消耗足够少,期望通过本文能为类似场景提供一种解决思路
目前表格存储方案
由于表格分布本质上是m*n矩阵,所以问题转换为矩阵的存储方案,目前主要有两种:二维矩阵和稀疏矩阵。
二维矩阵
这种方案就是用二维数组存储数据,适合在纯数据展示场景下使用,在大数据量下进行插入/删除操作时性能差。所以一般不作为在线表格的存储方案
稀疏矩阵
稀疏矩阵更适合作为电子表格数据存储结构,根据维基百科介绍,稀疏矩阵存储方案有多种,这里简单介绍常用的两种方案
1、字典:这种实现有多种表达方式,比如
- 包括一个dict对象,将(row, col)作为key,value是具体单元格值。这种写法对表格查询效率就不是特别高效。
- 针对前端开发者,比较常见的写法是:dict[row] = {}; dict[row][col]=value;其执行效率和Javascript的执行引擎有关,以Chrome浏览器内部的V8为例,数组/对象默认就支持稀疏存储形式,查询、插入效率高,而且对象的key是有序的,但删除操作下由于引擎内部设计决定其效率不高;所以在数据量不大时,可以作为表格的存储方案
2、压缩稀疏列:这个本意是通过压缩技术减少空单元格的数量,实际工程里有多种实现方式,典型做法如下
用三个数组(value、row、column偏移)来存储矩阵,其中:
- Column偏移数组按照索引从小到大排序,指定column列下,将row开始的索引值存储在该数组。column的长度等于矩阵列长度+1
- Row数组:存储单元格的行索引,某个列内容在row数组里是连续的
- value数组:根据column、row下标,存储具体的单元格值。value数组的长度和row数组长度是一样的,都等于矩阵中非0值的个数
举个例子,如下图,
假设要定位单元格(2,3)的值,查询过程如下
- 首先设置col_start=3, col_end=col_start+1,从column数组里获取column[col_start]和column[col_end]的值,分别为4和6,说明该列下有2个row元素
- 在row[4:6]区间,找到值为2所在的下标,为5
- 获取value[5]的值60,即可单元格(2,3)的值
插入/删除过程可以参考这里,该方案在时空复杂度比较良好,插入/删除的时间复杂度为O(N),葡萄城开发的SpreadJs也是使用类似方案。
上述介绍的存储方案只考虑value数组中每个元素值为数值的情况,从表格功能看,每个单元格的内容还包括其他类型信息,包括存储值value、UI属性信息、状态标记(比如是否公式)、公式内容、行/列信息,value可能的数据类型包括数字、字符串、多行文本等;这里用如下数据结构来模拟表达:
{
row: number,
col: number,
numValue: number,
type: number, // 标记值value类型,根据该值决定numValue/textValue/multiText
textValue: string,
multiText: string,
isDirty: boolean, //标记数据是否已更新
isCalc: boolean, //标记公示是否计算过
}
现在假设每个格子用JS的普通对象(Object)表示,通过简单实验看下1000w格子下内存消耗,如下图:
可以看出光存储初始化对象,V8内部堆内存占用800M,还没考虑属性值是字符串情况以及计算、渲染的额外内存开销。所以如果用普通对象形式作为格子的底层存储结构,受制于V8引擎内部内存分配策略,其很难支撑千万量级格子下的操作,因此考虑通过压缩技术来节省内存消耗。通过观察Cell内部结构字段内容,这里列出以下直观想法
- 从数据 类型看,Cell内部包含boolean、string、number三种基本类型,boolean和number类型的数据考虑通过字段压缩技术,将这几个字段的内容编码在一个Byte内,例如:type是一个枚举类型,假设有三种类型,用2个bit来表示,加上isDirty和isCalc类型,基本上一个Byte就能表示三个字段的值
- 一般来说业务表格内会包含重复的字符串,或者单元格之间共享默认的样式信息,所以参照XLSX协议,引入共享字符串表技术,保证workbook中唯一的字符串类型出现一次,然后单元格通过索引(而不是内联方式存储到单元格中)来引用字符串。
上述也是Onlyoffice内部使用思路,结合矩阵的稀疏列存储思想,Sdk内部最终的存储效果图如下,简单来说,是通过类型数组,将表格数据以二进制格式存储,通过字段压缩+共享字符串表来优化内存空间。这里说明下和上述稀疏列存储的区别
- 由于单元格通过字段压缩技术,所以row数组按照索引顺序依次排列,而不是只保存非空的行。
- col索引数组是稀疏的,其内容指向具体row数组,这和上述提到的column数组是不一样。当然好处是直接通过下标访问到row数组,编程上更符合开发者的直觉
下面重点介绍单元格信息压缩和存储单元格列表的数据结构
单元格信息压缩
通过二进制形式,将一个Cell内容压缩成如下结构
可以看出,一个Cell的基本信息占用了17B,即使是空的Cell。如果单元格实际数据是Number类型,那么数据直接存储在Cell结构内;如果是字符串类型,那么通过索引+SST表来表示一个字符串,SST表的代码如下
function CSharedStrings() {
this.all = [];
this.text = new Map();
this.multiTextMap = new Map();
}
CSharedStrings.prototype.addText = (text) => {
let index = this.text.get(text);
if (undefined == index) {
this.all.push(text);
index = this.all.length;
this.text.set(text, index);
}
return index;
}
样式下标、公式索引下标也是类似的,它们都存在类似SST的表,同样实现内存压缩目的。
关于Cell内部状态信息,通过字段压缩技术,事先枚举所有状态信息的可能值,确定每个状态占用的bit大小,然后约定好协议在单个Byte内编码各个状态位置和bit大小,最后通过位操作从Byte内设置/获取对应的状态信息。
单元格列表
为了存储单元格列表,onlyoffice内部开发SheetMemory数据结构,通过Js提供的类型数组(TypeArray)用来存储某段连续的二进制格式数据,包括某行/某列下单元格列表。关于SheetMemory的设计读者可以先尝试想下需要考虑的因素。这里根据表格上层支持的功能,总结如下考虑要点:
- 支持灵活扩容:由于表格稀疏特性,决定了调用者可以在任何一个Cell位置设置内容;所以如果设置的Cell行/列索引超过当前数据结构大小时,就要扩容,那么在具体编码时就要考虑扩容的策略
- 支持批量插入/删除单元格:想象下用户批量插入/删除多行数据,这个行为对底层存储的要求就是在指定位置插入多个空的记录
- 支持拷贝某个范围内的数据,比如用户通过拷贝/移动某个列的数据到另一个列,或者某个列进行排序操作结束后交换单元格区域
- 在指定数据类型下,支持设置/获取某个位置的数据:数据类型有UInt8、Uint32、Uint64等,所以需要提供API方便上层调用设置Cell数据
下面看onlyoffice SDK内部实现,先通过如下图对该数据结构有个直观认识:
下面列出内部具体实现
1、 扩容策略,核心实现如下:
// 检查大小,不足时扩容
SheetMemory.prototype.checkSize = function(index) {
// this.data.length表示当前已分配可以填充数据的容量大小
var allocatedCount = this.data ? this.data.length / this.structSize : 0;
// 给定下标比当前容量要大,就要扩容
if (allocatedCount < index + 1) {
var newAllocatedCount = Math.min(Math.max((1.5 * this.count) >> 0, index + 1), (this.maxIndex + 1));
// 新分配内存,并数据拷贝
if (newAllocatedCount > allocatedCount) {
var oldData = this.data;
//扩容时重新建立类型数组
this.data = new Uint8Array(newAllocatedCount * this.structSize);
if (oldData) {
// 老的数据恢复
this.data.set(oldData);
}
}
}
// 更新count大小
this.count = Math.min(Math.max(this.count, index + 1), this.maxIndex + 1);
};
扩容策略是在(当前数据量大小*1.5倍)和index之间取最大值,为何是1.5?这和分配时间复杂度有关,假设当前数据大小要扩大到maxIndex位置,如果按照structSize块大小进行扩容,那么内部会按照线性递增进行扩大,时间复杂度是O(N);按照指数倍数(比如2倍)扩容,那么执行log(N)次数即可,时间复杂度最优。但是考虑到2倍的扩容策略可能会使内存浪费比较多,所以在时间和空间之间做了权衡,取1.5作为扩容指数。
2、 批量插入/删除多行单元格
这个操作涉及到多个数据块(Range)的迁移,使用类型数组提供的set API操作拷贝内存数据,本质上和数组的插入、删除逻辑类似,具体操作如下
// 给定起始位置,删除delete count个数据
SheetMemory.prototype.deleteRange = function(start, deleteCount) {
if (start < this.count) {
var startOffset = start * this.structSize;
if (start + deleteCount < this.count) {
var endOffset = (start + deleteCount) * this.structSize;
// 把当前待删除结束位置后面的元素移动到开始位置
this.data.set(this.data.subarray(endOffset), startOffset);
// this.count - deleteCount位置后面的元素清零
this.data.fill(0, (this.count - deleteCount) * this.structSize);
this.count -= deleteCount;
} else {
this.data.fill(0, startOffset);
this.count = start;
}
}
};
// 给定起始位置,插入insert count个数据
SheetMemory.prototype.insertRange = function(start, insertCount) {
// 在已知存储的数据范围内插入
if (start < this.count) {
// 检查大小,必要时扩容
this.checkSize(this.count - 1 + insertCount);
// start下标所在的真实偏移量
var startOffset = start * this.structSize;
if (start + insertCount < this.count) {
// 要插入数据的结束偏移量
var endOffset = (start + insertCount) * this.structSize;
var endData = (this.count - insertCount) * this.structSize;
// 数据移动,将待插入位置(startOffset)后面的数据,拷贝到endOffset位置后面,这里做了简单优化,只拷贝startOffset到endData区间的数据
this.data.set(this.data.subarray(startOffset, endData), endOffset);
// 拷贝完成后,填充中间空出来的范围为0
this.data.fill(0, startOffset, endOffset);
} else {
this.data.fill(0, startOffset);
}
}
};
3、两个sheetMemory结构间拷贝指定范围的记录,实现逻辑见如下备注
// 将源数组内的开始位置startFrom,拷贝count个数据块,到目标数组内startTo位置
SheetMemory.prototype.copyRange = function (sheetMemory, startFrom, startTo, count) {
var countCopied = 0;
if (startFrom < sheetMemory.count) {
// 计算需要拷贝的数量
countCopied = Math.min(count, sheetMemory.count - startFrom);
this.checkSize(startTo + countCopied);
countCopied = Math.min(countCopied, this.count - startTo);
if (countCopied > 0) {
// 计算起始位置,语义上比较好理解
var startOffsetFrom = startFrom * this.structSize;
var endOffsetFrom = (startFrom + countCopied) * this.structSize;
var startOffsetTo = startTo * this.structSize;
this.data.set(sheetMemory.data.subarray(startOffsetFrom, endOffsetFrom), startOffsetTo);
}
}
// 为何会有清除逻辑?因为实际拷贝的数量countCopied比count小
// 比如源数组内只有1个数据块,但实际要拷贝2个,那么说明剩下1个就是空数据,
// 所以对目标数组而言多了这1个也是空数据,直接清0即可
var countErase = Math.min(count - countCopied, this.count - (startTo + countCopied));
if (countErase > 0) {
var startOffsetErase = (startTo + countCopied) * this.structSize;
var endOffsetErase = (startTo + countCopied + countErase) * this.structSize;
this.data.fill(0, startOffsetErase, endOffsetErase);
}
}
3、 在给定位置,支持设置/获取数据,以获取/设置Uint16大小的数据为例,操作如下;对其他基本类型数据如Uint8、Uint64也是类似。
// 在指定数据块(index),在数据块内部的offset偏移开始,获取2个字节的无符号整型数据
SheetMemory.prototype.getUint16 = function(index, offset) {
offset += index * this.structSize;
return AscFonts.FT_Common.IntToUInt(this.data[offset] | this.data[offset + 1] << 8);
};
SheetMemory.prototype.setUint16 = function(index, offset, val) {
offset += index * this.structSize;
this.data[offset] = (val) & 0xFF;
this.data[offset + 1] = (val >>> 8) & 0xFF;
};
通过上述API实现,基本上能满足应用层对底层数据的操作要求;除此之外上述实现具备以下优点:
1、节省内存:在100w行数据下,如果某列单元格内容都是纯数字,也就占用17M内存;相比下,通过js原生的数组存储单元格对象就比较大。在极端情况下,比如只有a[0]和a[100w]只有稀疏情况下,那么中间分配的内存就出现浪费,不过这种现象在业务中比较少见。
2、时间复杂度低:访问操作的时间最快,为O(1);插入/删除操作,时间复杂度O(n),不过依赖于高性能类型数组实现,底层涉及到原生二进制数据的一次内存拷贝(依赖引擎实现语言提供的memcpy操作),所以实际性能还是比较好
3、作为潜在性能优化提供基础:比如包括大数据量的undo/redo操作时,理论上其存储的数据也只要包含二进制数据即可,通过内存拷贝操作解决表格数据回滚和恢复问题。
4、数据结构比较通用,只要约定好struct记录内部的协议格式即可,使用时上层调用者使用SheetMemory提供的API来设置/获取数据,并且该结构能支持动态扩缩容。这对涉及音视频和实时通信业务的数据存储具备一定参考价值
总结
onlyoffice设计的这套存储方案支持在千万个格子下内存消耗足够少,这就为表格上层计算、渲染环节的缓存机制提供更多内存空间。该方案也说明了在设计数据结构时要结合语言特性来优化,特别是前端应用程序涉及大数据量存储,要考虑脚本执行引擎的限制,了解引擎内部的内存管理机制,方便应用层编写更高性能的程序。
当然有良好的底层存储机制还不够,面向上层开发者需要感知的是Cell、Range、Workbook等领域模型相关的实体概念,后续再继续介绍这些概念之间是如何抽象和封装以屏蔽底层的数据存储
参考