背景
公司后台管理系统某个下拉选择字段对应的数据源接口请求耗时极长(10秒左右),用户体验极差,所以该问题亟待优化。
初步定位接口耗时过长的原因是数据量过大,后端数据库查询时间及接口网络传输时间过长。
简单说下这里的业务场景,这是一个商品的品牌类目字段的下拉选择组件,组件使用的ant-design-vue的a-cascader组件,因为要级联选择,所以数据是多层嵌套的数组(不定层数,最多4层)。如果从前端角度寻求解决方案,可能会想到把过大的数据分页查询。但是业务需求需要同时满足组件本身提供的前端模糊查询功能及编辑时的字段回显。如果采用分页方案,则模糊查询只能查询到已请求的数据,且字段回显可能会失败。所以分页方案在这里行不通,还是得从数据源本身下手解决问题。
分析与解决
背景里提到,接口耗时过长的原因是数据量过大,后端数据库查询时间及接口网络传输时间过长,针对这两个问题逐一分析解决。
接口网络传输时间长
我们先尝试解决接口网络传输时间长的问题,这个问题主要是数据本身太大。我们来看下数据结构:
[
"categoryId": "",
"categoryName": "",
"children": [
"categoryId": "",
"categoryName": "",
"children": [
// 更多层级
]
]
]
这是一个多层嵌套的对象数组,每一层有categoryId和categoryName字段,另外使用children来嵌套下层数组。
实测从生产环境取真实数据,一条length为954的多层嵌套数组大小达到了8.69M。
通过观察可以发现,数据的字段是很简单的,就3个字段反复嵌套出现很多次。但字段名其实是比较长的,在多次重复的情况下,会占据很多字节。如果把字段名都改为单英文字符可以节省大量重复的字节。
[
"i": "",
"n": "",
"c": [
"i": "",
"n": "",
"c": [
// 更多层级
]
]
]
另外,JSON数据中的大量空白字符也占据了大量的字节,这些空白字符都可以删掉。
实测上文提到的8.69M的数据源经过字段名压缩和删除空白字符操作后,大小可以压缩到3.64M。网络传输开启gzip压缩后Network面板显示的请求大小为591KB,大小约为最初的1/15,优化效果显著。
后端数据库查询时间长
目前后端的设计是,每次服务器收到浏览器发起的请求,都要去查询一次数据库,即使多次请求返回的数据是一样的,都要重复查询数据库。
实际上我们的品牌类目数据变动频率是很低的,所以考虑用Redis加一层缓存。先从Redis中取数据,如果取到数据就返回给前端。取不到数据则查询数据库,把查到的数据写入Redis,并返回给前端。
当后台有修改数据源操作时,需要清除对应的Redis缓存,然后查询数据库写入Redis,并返回给前端。
现在服务端已经通过Redis加了一层缓存了,但浏览器仍然没法缓存请求数据。如果能让请求内容变成一个静态文件,浏览器就能缓存这个文件。
具体的,服务端可以把json数据转成一个js文件,上传到OSS对象存储,接口只需要返回一个js文件的url地址,前端就可以使用动态加载js技术来完成js数据的获取,并且这个js文件对应的url是能被浏览器缓存的。
基本流程是:
- 第一次请求数据源时,先查询数据库获取品牌类目数据,转成json,写入到一个js文件里,上传到OSS,同时把上传的url写入Redis并返回给前端。
- 后续请求数据源时,先从Redis缓存中取url,如果存在则直接返回给前端。如果不存在,则重新走一遍第1步的流程。
- 如果后台修改了品牌类目的数据源,则需要先清除Redis缓存,再走一遍第1步的流程。
上面提到的json数据写入js文件的时候,需要在前面拼上字符export default ,例如:
export default [
"i": "",
"n": "",
"c": [
"i": "",
"n": "",
"c": [
// 更多层级
]
]
]
得益于现代浏览器对ESM的支持,前端可以使用动态导入import()来导入上面的js数据。
前端示例代码:
axios('xxx')
.then((res) => {
const { url } = res.data.data
// url: https://xxx/xxx.js
import(url)
.then((data) => {
const list = data.default
// list就是我们需要的下拉数据
})
.catch(() => {})
})
.catch(() => {})
直接引入JSON?
你可能会觉得由后端手动拼接export default 字符写入js文件这种操作不够优雅,前端能不能直接引入.json文件呢?tc39确实有一个直接引入json文件的提案proposal-import-attributes,写法就像下面这样:
import data from './data.json' with { type: 'json' }
import('./data.json', { with: { type: 'json' } })
那么上面的示例代码只需要简单的改动,但现在是直接引入json文件了:
axios('xxx')
.then((res) => {
const { url } = res.data.data
- // url: https://xxx/xxx.js
+ // url: https://xxx/xxx.json
- import(url)
+ import(url, { with: { type: 'json' } })
.then((data) => {
const list = data.default
// list就是我们需要的下拉数据
})
.catch(() => {})
})
.catch(() => {})
但这个语法毕竟还处于提案阶段,它的兼容性见下图。我用最新的Chrome浏览器尝试了是支持这个特性的。
成果
请求先返回一个js文件的url
第一次请求这个js文件时,大小降到了591kb,时间为841ms。
后续的请求会走disk cache,并且时间也降低到26ms。
总结
本文介绍了在下拉选择场景下(其实其它场景也一样)请求超大量数据时请求时间过长的优化手段。
首先经过字段名压缩和删除空白字符操作后及网络传输gzip压缩后数据大小为最初的1/15。
然后后端通过Redis加了一层缓存,避免每次请求都要查询数据库的问题。
最后又把数据写入js文件上传OSS,Redis只缓存js文件的url地址,返回给前端的也是url地址,前端通过动态import()获取js文件内的数据,这一步更是使请求数据能被浏览器缓存。
不难发现,优化思路总结起来就是减少文件大小和加入多层缓存策略。优化前请求大小8.69M、请求时长10s左右,优化后请求大小591KB、请求时长841ms(disk cache后26ms),效果显著。