前端百万数据的加载优化

1,547 阅读13分钟

写在前面的话

你可以通过此文章收获到以下:

  • 前端如何增量缓存百万数据
  • 设计一个方案的架构实施过程
  • 关于学习框架或解决方案原理的理解

背景

我们的字体应用中需要展示大量字体信息(json),而这些字体信息更新的频率较低,如果不加前端缓存,就会导致每个用户(或每台设备)对字体信息都会进行反复加载,徒增服务器压力也降低了因网络传输而导致的用户体验下降,随着字体数量增加,用户量增加,增加前端缓存刻不容缓。

以下讨论我们用“数据信息”代替“字体信息”,以便让讨论不狭义在特定行业中。

还有一个大前提:业务对数据的实时性要求不高,若数据持续变化就没必要做缓存。

1、初阶缓存:利用 http 缓存

将所有数据信息整合成一个 json 文件存入 CDN 中,前端GET请求 CDN 链接将数据解析到内存中供应用使用。

1.1 强缓存

这个方案要求数据更新频率要很低,而且当有意外情况需要立刻更新数据时,便达不到目的。

1.2 协商缓存

这个方案能解决更新频率的问题,但是无法解决更新颗粒度的问题,当某条数据的某个属性发生变化,则需要更新整个 json 文件

2、前端DB + 差量更新

利用前端可用的数据库(如IndexDB,客户端还可以使用SQLite),后端增加差量比较接口,前后端配合如下:

每条数据都一个唯一标识(id)和最后更新时间(updateTime),前端每次调用差量比较接口时都传入唯一标识和更新时间的键值对

const diffData = (params) => fetch("/diff", { params });

diffData({ 1: 1723043486, 2: 1723053486 })

当后端发现前端相应 id 的更新时间小于数据库中的更新时间,则表明前端的数据过期了,差量比较接口则需要返回 id 对应的最新数据, 数据库中存在的数据但前端并没有传相应 id 的数据,后端同样需要返回。前端收到数据后更新本地数据库并更新本地当前 id 对应数据的更新时间。

这样解决了更新颗粒度的问题,当某些数据变化后不用全量更新数据了。但首次加载,本地数据库为空时,差量更新接口会返回全量数据,而当数据量超大时,首次加载的时间便会变的无法容忍。

前端DB + 差量更新 解决了细颗粒度更新的问题,但是依然没有解决海量数据初次加载的问题。

3、增量更新

技术的方案的设计和实现往往需要结合具体业务场景。

数据信息总体是海量的,但是实际上用户的页面展示是极其有限的。那我们为什么不提前加载用户界面加载时需要的数据,而其他的数据在被需要时再加载呢?

3.1 初始

框架如下

image.png

节点解释:

  • 视图层(UI):是我们发起获取数据的伊始;
  • 缓存层(Cache):仅用于数据存储服务的媒介(如:LocalStorage、IndexDB、SQLite等);
  • 源数据更新(Resource Updater):仅用于向 数据源(如:后端服务器)发送请求,获取数据后将数据写入缓存层,并响应给请求方。

当视图层的发起请求时,优先从缓存层查询数据是否存在,若不存在则将请求数据源,将请求到的数据缓存并返回至视图层。

当前的增量更新已经做到“用户需要什么就加载什么”的目的,而不至于让用户因为他不需要的数据而等待超长时间。

但,聪明的你会发现一个问题,如果视图层每发起一个请求(以下我们称为 UI请求 )都没有命中缓存,那岂不是每个UI请求都需要单独向数据源发起一个请求(以下我们称为 数据源请求 )吗?这会在瞬间产生大量http请求。

我们需要有种机制可以收集这些零碎的独立请求,使其可以批量发送至数据源。

3.2 调度(批操作)

为了解决大量零碎请求导致的与数据源数据交互频繁的问题,我们在视图层和缓存层之间增加调度层,以优化 UI请求 的执行。

image.png

学习 React 的同学对 Schedule有没有种似曾相识的感觉。这一小节我们暂时只在调度层增加来优化零碎的UI请求

所有的UI请求都发送至调度层,调度层采用节流(每100 ms内的请求形成一组)的方式将零碎请求合并成一组请求。

// UI
query({ id: 1 });
query({ id: 2 });
query({ id: 3 });
// Schedule
cacheFind([{ id: 1 }, { id: 2 }, { id: 3 }]);
// 请求源数据
fetch('https://api.example.com/query/diff', { params: { query: [{ id: 1 }, { id: 2 }, { id: 3 }] } });

以此来减少大量数据源请求

3.3 批响应优化

假设现在短时间内产生了若干UI请求:

// UI
query({ id: 1 });
query({ id: 2 });
query({ id: 3 });

他们被处理成批请求:

// Schedule
cacheFind([{ id: 1 }, { id: 2 }, { id: 3 }]);

但其中 id:1id:2 在缓存中是有值的,只有 id:3 需要发起数据源请求,难道还需要等待 id:3 数据返回了才能响应本次的 批请求吗?我们把这个问题称之为 细颗粒的批响应

我们在调度层与缓存层中增加 diff 层,用于处理该问题。

image.png diff层代理 批请求 去查询缓存,筛选出缓存中存在的有效信息并立刻响应给 调度层 ,没有命中的请求则进入 源数据更新

3.4 有效性校验

缓存中的数据并不是永久有效的,所以需要一个机制来使得过期数据得到更新。

为数据增加 max-ageupdateTime ,则数据过期时间max-age + updateTime,数据过期后则需要重新请求最新数据。

但这会带来一个问题,数据过期后,UI请求 需等待 数据源请求 完成后才能得到相应,对于用户体验是不佳的,而往往此类数据的更新往往不需要 立刻 更新,能否将过期的数据先相应给 UI请求 与此同时再去发起更新数据的请求呢?

以下我们来解决这个问题。

3.5 SWR

slate-while-revalidate (swr)的概念主要来源于 HTTP RFC 5861

当为某一条数据设置revalidate=10时,在10秒内认定该数据有效,超过10秒后认定该数据应立即重新验证,但在验证完成前仍然认为其是可用的。除非验证其已过期,该数据才不可用。

我们用 revalidate 来取代 max-age 用于对数据有效性的检查

{
    "id": "1001",
    "data": { "fontId": "1001", "fontName": "得意黑" },
    "revalidate": 10,
    "updateTime": 0
}

当数据在有效时间内时则会直接响应;

当数据在有效时间外时,数据会直接响应,与此同时会将 请求任务 提交给

revalidate 的特性可以大幅度提高数据响应效率,也可以保证数据在可接受的时间范围内保持更新。而 revalidate 的工作我们交给 swr 层去处理。

image.png

至此,我们将整个优化模型抽象封装成了 本地数据存储 服务,服务内的各个组件各自分工来处理零碎的 UI请求 已基本达到数据增量更新的目的。

但掉头发的是,生产缓存实操的过程中依然有未实现的刚性需求。

例如:

运营人员在上线内容时,将一条配置错误的数据更新上线了,所有用户接收到这条信息后导致该数据对应的内容无法显示,而由于“最新的上线的内容排序优先级较高”的规则,就导致所有用户的首页都会展示这条无法正常工作的内容😫。

我们将上述实现的方案称之为 被动更新, 因为是由用户发起 UI请求 而触发的更新,并不是产品侧触发。那解决例子中的问题,就需要一种由产品侧主动触发更新的机制来解决此类问题,我们称它为 主动更新

4、主动更新

主动更新可以有两种方式:

  1. 前端轮询,查询最新更新
  2. 全双工通信,获取最新更新

更新方式按需选择即可,不是本次讨论的重点。

我们主动更新要做的事要区别于 源数据更新不获取完整的数据,而仅仅做 过期检查 。也就是前后端只进行 唯一键(id)和 更新时间(updateTime)的通信。

因为后端数据的改变是任意时刻且任何内容的,所以我们的过期检查是较为频繁 全量检查

image.png

4.1 全量检查(服务启动时)

在本地数据存储首次启动时,获取本地所有数据的 idupdateTime 将其发送至后端,后端存储的每一条数据都记录了 最后更新时间,每当运营人员更新该数据时都会更新该时间,后端以此来校验前端数据是否已经过期,并将需要更新的 id 返回给前端,前端进入 源数据更新 阶段。

const localData = {
    1: { upd: 1723441906013 },
    14: { upd: 1723441906013 },
    ...
    20012: { upd: 1723441906013 },
    ...
}

当前端存储的数据量超大时,就会有以下缺点:

  1. 前端需从本地数据库获取全部数据,并组成相应的数据结构,会造成Javscript线程的阻塞;
  2. 前后端交互中传输的数据量大;
  3. 后端需要校验每条数据,服务端压力大;

如何解决这种问题呢?

4.1.1 数据分区

降低数据检查的颗粒度

我们预设数据的ID是递增且不可变,以此为基础将海量数据分成若干片区,如:

1~1000 记为分区一
1001~2000 记为分区二
...

image.png

每一个分区都有它的 updateTime ,分区内任意一条数据发生变化,分区的 updateTime 都要被更新。

这样初次检查时,只需要检查分区的有效情况,当某一分区发生变化时再去检查分区下的具体数据。目前我们一个分区为 1000 条数据,后端比较级别的数据是完全可以的,这样配置针对 百万(1000 * 1000 = 1000000) 以下的数据完全没问题。

针对千万、甚至亿量级的数据,可以套娃分区,对一级分区进行二次分区,但这种场景是伪场景,即使你分区做好了,将千万级别的数据载入本地的数据库后,你还得考虑查询效率等问题,与其如此倒不用使用前端缓存了,交给后端的优化手段吧😄(搜索引擎,redis...)

4.2 主动推送(运行时)

当经过 4.1 全量检查 后,我们可以认为本地所缓存的数据跟服务器数据是同步的,那么接下来的主动更新便可以更为简便。

运营更新完数据后,后端便将更新通知至前端,重点是只需要在运营更新时发送一次通知。前端收到信息后,若本地有缓存则进入 源数据更新 ,否则不做任何动作。

5、亿点点思考

以上我们利用了 调度diffswr主动更新数据分区等手段解决各类问题以达到海量数据增量更新的目的。

5.1 关于架构设计与实现

5.1.1 缓存的前置抽象层

在设计和实际代码实现的过程中,所有的Api调用都是围绕着 缓存层 来处理的,每一层对于上一层是透明的,无感知的,每一层都模拟了最终层(缓存层)的输入输出:

// UI层
db.query({ font_id: 1 })
db.query({ font_id: 2 })
// 调度层
db.query([{ font_id: 1 }, { font_id: 2 }])
// diff 层
const data = await db.query([{ font_id: 1 }, { font_id: 2 }])
const validQuery = []
const nullOrExpiredQuery = []
...(其他层的实现)

这样的设计可以让我们在后期 希望增加 更多的中间层来优化数据处理时,可以无侵入性的将新的抽象层有机的结合在流程中,也可以很方便的去除掉流程中某些抽象层。

5.1.2 数据库的抽象

缓存的媒介可以是LocalStorage、IndexDB、SQLite或者其他的数据存储方案,而缓存的前置抽象是平台无关的,但数据存储方案是平台相关的,如何让前置抽象的逻辑在不同平台得到复用呢?

将数据库本身的操作进行抽象代理:

image.png

这样一来,当需要切换存储媒介时,只需要新增一个对应的API代理转接层,其余的部分都不用改动。

但这种设计很考验通用API的设计!

5.1.3 总体设计

image.png

5.2 防止白屏

以上提供了一整套数据增量加载与更新的方案,但在本地缓存数据为空时,用户首次进入页面时,程序等待数据的获取,此时就会发生 “白屏” 现象,针对此类问题我们可以预取数据,将其存入缓存中。例如将首页设计的数据打包成json放到CDN,在程序启动之初就加载该json文件。

5.3 活学活用

方案中的 schedule 思想来自 React、SWR 来自 Nextjs 的封装、数据库的抽象可以借鉴 localForage 等,经常在网上能看到“会原理不就是应付面试嘛”,甚至团队内的小伙伴也会有这种想法,但是我结合自身的经历来看“知晓原理,知晓原理的好处”,在日常工作中处理和设计复杂场景时会给你带来惊喜,帮助你理清思路,将复杂问题拆解成各个单元,而不至于因为“复杂流程”让思绪一团乱麻,这在业务拆解和代码实现上都有帮助。