由于业务每秒极限能生成数十万的数据,echarts的单条曲线就可能就有千万到十亿级的数据量。即使echarts本身具有采样能力,但这个量级的数据js内存本身就承载不了。
为了提升整体性能,我们进行了以下技术改进:
一、流式传输或websocket
全量查询即便后端也会因构建的json过大而出现内存溢出,而且大数据量一次性下载也需要等待很久。因此我们前后端采用了流式传输,基于SSE的协议进行数据解析,边传输边渲染,可以很好地减少页面loading时间。
不过这里更推荐使用websocket,直接传输二进制数据,避免构建json,也方便前端后续处理。
二、采样
最关键的部分就是采样。比如按最常见的分辨率1920,dpr为2来计算,曲线图即便最大,其像素点数量也远远小于需要渲染的数据,因此在拿到数据后可以进行二次采样。采样算法根据不同场景,可以选择固定步长的系统采样或尽可能保留形状的lttb(Largest-Triangle-Three-Bucket)算法。
结合前面的流式传输,在接收到数据片段时就进行采样并渲染,并在数据全部接收完成后,针对已有全量数据再次采样,并渲染完整图形。
每根曲线采用后的数据量,建议不超过200万——后文会详细解释200万数据的内存大小。
引申:lttb算法
lttb(Largest Triangle Three Buckets)算法是一种高保真的折线数据降采样算法,它通过在数据分桶后选择能最大化三角形面积的点,在大幅减少数据量的同时,最大程度保留原始数据的趋势特征(峰值、谷值、拐点)。
lttb算法大致原理如下图,首先根据采样率分桶,比如10采1则可以每10个点分一个桶,每个桶内取一个点。根据上一个保留点和当前桶内的候选点、下一个桶的中点计算三角形面积,取能形成最大面积的点作为这个桶的保留点。(此处面积计算可以使用向量叉积)
三、内容裁剪
内容裁剪主要分为两个部分,冗余字段删除和区间查询。
曲线需要的数据只有time和value,因此在后端传输时就将额外字段全部剔除。
同时前端查询时,根据当前曲线图的缩放范围进行查询,比如当前窗口的x轴范围是100 ~ 200,则查询80 ~ 220区间。这里为了避免echarts缩放失效,要额外增加一定的冗余区间。
四、IDB存储
全量数据的传输和采样成本很高,因此每当曲线全量采样完成后,可以针对采样数据存储到本地IndexedDB中,当二次查询该曲线时可以直接使用本地的。
这里有两个难点:存储时机和清理时机的选择。
IDB的存储和清理时机
存储时机的最大问题在于,如何判断数据已经传输完毕。
一方面流式传输过程中用户可能关闭曲线而取消后续传输,另一方面如果数据还在持续入库就查看曲线,也会导致数据传输不完整。前者比较简单,通过AbortController取消传输时就可以判断;而后者最简单粗暴的方法是,如果最后一条数据的时间小于查询区间,就认为数据尚未入库完成。当然,如果能拿到当前数据消费状态,或者能够根据业务规则判断数据完整性就更好。
清理时机的可以根据业务需求,可以按天清理或按项目维度清理或者按使用量清理(navigator.storage.estimate()可以获取IndexedDB占用存储大小)。
虽然IndexedDB可以持久化存储,但曲线查看操作往往是临时性的,因此我的决策更激进:每次页面打开(或刷新)就清理。
五、采用TypedArray而非Array
前文有提到1亿条数据建议采样到200万条,那么200万条数据实际占用内存多少呢?
const array = [];
const step = 0.01;
let x = 0;
while (x < 20000) {
const y = Math.sin(x);
array.push([x, y]);
x = Number((x + step).toFixed(2));
}
如果是单一的200万元素二维数组,占用91738kB,也就是100M左右。
然而如果将其传给echarts,数组会被拷贝和包装——下图中三个数组都有200万个元素。
整个曲线图仅一根曲线就占用内存达到了624M! 但如果改用Float64Array,内存降到了142MB,明显更能接受(js内存限制为4G)。同样200万条数据,Float64Array结构占用内存约为30M,只有普通Array的30%!(如果只看浅层大小,两者差距更明显,引申中有解释浅层大小差距)
引申1: js内存大小限制
js内存可以通过window.performance.memory查看
关键指标说明:
usedJSHeapSize:当前 JS 堆已使用的内存;totalJSHeapSize:浏览器为 JS 堆分配的总内存;jsHeapSizeLimit:浏览器允许 JS 堆使用的最大内存(我的是4G)。
引申2: 堆快照中的名词解释
- 距离:对象到垃圾回收根(GC Roots)的最短路径长度,数字越大嵌套越深。如果是
-,要么已经被gc回收了,要么将在下一次gc会被回收。 - 浅层大小:对象本身占用的内存
- 保留的大小:如果删除该对象,能释放的总内存量,包括对象本身以及它唯一引用的其他对象
引申3: 从backing_store到 Native Heap 与 JS Heap
TypedArray的内存机制和普通Array不一样,简单来说TypedArray仅仅是一个视图对象,具体内容存在ArrayBuffer中,而ArrayBuffer的内容存在Native Heap中,并不占用JS Heap。相关具体解释可见juejin.cn/post/759705…
六、增量渲染
echarts的文档也指出在百万级以上数据量时,最好使用增量渲染
参考
大数据的渲染其实有很成熟的方案了,有需要的可以直接使用perspective: perspective-dev.github.io/examples/