记一次前端解决业务接口并发请求的性能实践

2,240 阅读8分钟

场景

在工作中,前端的工作不仅在于写页面的展示实现和交互,有时候在一些业务场景的数据请求中需要前端和后端调和才能取得更好的性能对接方案。

在某次和后台对接并发批量操作数据的接口时,有一个接口请求需要较长的查询时间,如果查询的数据过大,可能会引起接口超时导致操作失败。

场景如下

接口请求参数:

1. 传入需要批量操作的数据数组 id_arr ,如 [id1, id2, id3, id4],重复 id 会被去重
2. 传入批量操作的次数 number
=> 为 id_arr 下每个 id 创建 number 个子节点

接口返回:

1. 是否成功状态 success
2. 失败集合字典 fail_id_map { id: number } 表示某个 id 失败几次

一个树形表格数据,我们可以多选当中的某一节点数据,并为选中的数据创建子节点,同时也可以为同个数据一次创建多个子节点,创建子节点可能会因为当前父节点被移动或者某种限制而失败,失败的结果需要告知用户并提示重试操作。由于平台数据结构原因,平台接口创建子节点的速度比较慢,而且只支持单次扁平操作, 即后台在处理批量操作次数时其实是分拆成一次一次进行操作的。

同时,由于接口时长原因,一次请求限制创建最多 20 个子节点,那么我们就要将超过 20 个子节点的请求拆分并发处理,也就是我们要牺牲服务端的请求压力,来换取用户体验。

而如何做到最佳体验和最少并发,就是我们代码需要实现的内容了。

接口并发

接口并发,我们可以使用 Promise.all 来实现,并在 then 中收集所有返回的 data,合成我们需要的数据格式。


const data = [id1, id2, id3, id4, id5, id6], // 需要创建子节点的 ID 数组
const number = 5 // 每个 ID 需要创建的数量
const max = 20 // 一次请求最多能创建多少节点

const request = function (id_arr, number, callback) {
    return new Promise(resolve => {
        createNode(ids, number).then(data => {
            callback(data)
            resolve(data)
        }).catch(error => {
            callback(error)
            resolve({
            	success: 0,
                fail_id_map: ids.reduce((acc, cur) => (acc[cur] = number, acc), {})
            })
        })
    })
}

function splitRequest(data, number, max) {} // 我们拆分请求的方法

Promise.all(splitRequest(data, number, max).then(data => {
	
	// Callback code
})

这里注意 Promise.all 当中若有一个 Promisereject,则整个 Promise.all 会被 reject,所以我们在内部统一无论成功与否只使用 resolve,错误保留。

结果预演

对于像这样很难看出我们具体实现过程的需求,我通常会先预演一下方法输入输出,输入就是我们有什么参数,输出就是我们要什么结果

例如我们的数据开始是这样

  const data = [id1, id2, id3, id4, id5, id6], // 需要创建子节点的 ID 数组
  const number = 5 // 每个 ID 需要创建的数量
  const max = 20 // 一次请求最多能创建多少节点

其实因为我们后来并不知道这些 id 究竟成功了多少个,失败了多少个,重试操作的时候数据不一定跟目前一样这么规整,为了方便复用,我们的数据需要转变为这样

  const data = {
      id1: 5,
      id2: 5,
      id3: 5,
      id4: 5,
      id5: 5,
      id6: 5,
  }
  const max = 20

这样即使当中有部分失败,我们直接把成功的数量减去,剩下的继续请求就可以了。

输入的参数形式统一好了之后,我们就可以考虑输出结果,我们知道请求部分成功时,请求视为失败,后台会返回 fail_id_map 字段,这个字段跟我们上面整合的输入参数结构是一样的,这样的话我们的整个迭代重试循环就很明显了。

const ids = [id1, id2, ...]
const number = 5
const map = ids.reduce((acc, cur) => (acc[cur] = number, acc), {})
function createNodes (map, max) {
    requestData(map, max).then(data => {
        if (data.success) {}
        if (!data.success) {
            confirm(() => { // 询问是否重试
                createNodes(data.fail_id_map, max)
            })
        }
    })
}
createNodes(map, 20)

实现难点

这次并发需求的实现难点在于以下几点

  1. 接口请求参数的限制,由于重试的机制,我们有比较小的概率会碰见这样的重试数据 {id1: 1, id2:2, id3:3, id4:4, id5:5},而接口并不支持我们这么传,而如果按 id 一个一个的做并发,在选择大量节点的时候就会产生大量请求,我们需要控制请求数量到最低以谋求最佳性能。

  2. 请求数据的长度问题,并不是简单的根据 length > max 来切割请求,而是升了一个维度要根据 Σ m*n 的数量是否超过 max 来切割 (m 为每次请求的数据长度即 length,n为每个数据的节点创建次数 )

  3. 除了考虑请求并发之外,我们还要考虑后台处理数据的速度问题,由于后台接口处理数据模式是单次扁平处理,也就是说我们每次切割出来的请求要保证每次请求数据长度尽量长的同时,不超过限制次数

实现过程

普遍情况

先思考我们的并发限制策略,像面对 {id1: 1, id2:2, id3:3, id4:4, id5:5} 这样的数据,我们的最佳处理方式如下

	第1次请求 id1 id2 id3 id4 id5 次数 1
	第2次请求 id2 id3 id4 id5 次数 1
	第3次请求 id3 id4 id5 次数 1
	第4次请求 id4 id5 次数 1
	第5次请求 id5 次数 1

由于接口传参限制,要并发五次请求

超出情景①

如果所有次数都增加 5 次,则结果如下

	第1次请求 id1 id2 id3 id4 id5 次数 5
	第2次请求 id2 id3 id4 id5 次数 1
	第3次请求 id3 id4 id5 次数 1
	第4次请求 id4 id5 次数 1
	第5次请求 id5 次数 1

在这种情况,第一次请求是可能会超时失败的,而且这样的超时失败,我们是无法获取数据请求是否成功,页面也就无法得到适当的响应。

根据上面的情况,那我们只需要多做一步,就是把第一次请求的次数再拆分,拆到满足并发限制为止

	第1次请求 id1 id2 id3 id4 id5 次数 4
	第2次请求 id1 id2 id3 id4 id5 次数 1
	第3次请求 id2 id3 id4 id5 次数 1
	第4次请求 id3 id4 id5 次数 1
	第5次请求 id4 id5 次数 1
	第6次请求 id5 次数 1

超出情景②

还有一种情况是当一次请求的数据长度本身就大于限制

	第1次请求 id1 id2 id3 ... id20 id21 次数 n (n>0)

这种情况我们也要拆分,但不是次数拆分而是数据拆分,限制一次请求的数据长度。

1次请求 id1 id2 id3 ... id20 次数 n (n>0) // 继续拆2次请求 id21 次数 n (n>0)

解决流程

我们需要把数据先按 max 分割数据长度,生成普遍情况下的数据,然后找出当中 id_arr.length * times 大于 max 的数据,进行再拆分。

过程就是先从最少次数开始拆起,每一轮都拆最少那一次。代码如下

function chunkArr(arr, max) { // 分割数组方法
  let index = 0
  const result = []
  while (index * num < arr.length) {
    result[index] = arr.slice(index * num, (index + 1) * num)
    index ++
  }
  return result
}

function splitRequest(arr, times, max) { // 分割次数
  // 场景2 的情况
  if (arr.length > max) {
    return chunkArr(arr, max).reduce((acc, cur) => (acc = acc.concat(splitRequest(cur, times, max)), acc), []) 
  }
  const result = []
  const len = arr.length
  // 拆分过程
  // [{ id: [A,B,C,D,E,F], times: 5 }] max: 20
  // 输出结果
  // => [{id: [A,B,C,D,E,F], times: 3}, {id: [A,B,C,D,E,F], times: 2}]
  while (len * times > max) {
    if (result[0] && ((result[0].times + 1) * len <= max)) {
      result[0].times ++
    } else {
      result.unshift({ ids:arr, times: 1 })
    }
    times --
  }
  if (times) result.unshift({ids: arr, times})
  return result
}
// 获取处理后的请求队列
function getQueue (map, max) {
  /*  
  { A: 5, B: 4, C: 3, D: 3, E:5, H: 4 }  max: 20
  => [
    {id: [A,B,C,D,E,H], time: 3}, 
    {id: [A,B,E,H], time: 1}, 
    {id: [A,E], time: 1}
  ]
  */
  // 生成升序数组 [ {key: C, value: 3},{key: D, value: 3},{key: B, value: 4},{key: H, value: 4},... ]
  const sortArr = Object.entries(numberMap).map(item => ({key:cur[0], value:cur[1]})).sort((a,b) => a.value - b.value)
  // 生成初步结果 [
  //	{id: [A,B,C,D,E,H], time: 3},
  //    {id: [A,B,E,H], time: 1}, 
  //    {id: [B,E], time: 1}
  //]
  const initResult = []
  while (sortArr.length) {
    let pointer = 0
    // 拿到最少次数
    const min_times = sortArr[0].value
    const obj = { ids: [], times: min_times }
    while (sort_arr[pointer]) {
      const item = sort_arr[pointer]
      obj.ids.push(item.key)
      pointer++
      if (item.value === min_times) {
        sortArr.shift()
        pointer = 0
      }
      if (item.value > min_times) item.value -= min_times
    }
    initResult.push(obj)
  }
  
  const requestQueue = initResult.reduce((acc, cur) => {
    if (cur.ids.length * cur.times > 20) { // 限定单次请求只请求最多创建 20 个任务
    // {a: m, b: n} => [{}, {}, ...]
      acc = acc.concat(splitRequest(cur.ids, cur.times, 20).map(ele => request(ele.ids, ele.times)))
    } else acc.push(request(cur.ids, cur.times))
    return acc
  }, [])
  return requestQueue
}

function requestData (numberMap, max) {
  return new Promise(resolve => {
    Promise.all(getQueue(numberMap, max)).then(data => {
      // 整合成功失败信息
      let success = 1
      const fail_id_map = {}
      for (let i = 0, len = data.length; i < len; i++) {
        const ele = data[i];
        if (!ele.success) success = 0
        for (const key in ele.fail_id_map) {
          fail_id_map[key] === void 0 
          ? fail_id_map[key] = ele.fail_id_map[key]
          : fail_id_map[key] += ele.fail_id_map[key]
        }
      }
      const result = { success, fail_story }
      resolve(result)
    })
  })
}

总结

这是一次相对比较繁杂的业务实践,见过很多初学者会问前端除了写页面还需要做什么?算法是不是和前端关系不大,但是在企业项目实践中会有各种各样的复杂场景和性能挑战。我觉得会写页面布局跟交互逻辑可以说算是前端的下限了,而获取最佳性能的页面渲染以及数据处理,就需要更强大的代码逻辑能力和算法能力。像阅读框架源码,不仅仅是为了让你了解你的代码真实的运行情况,也能让你更好地把控你用这个框架写的项目情况,提高你的代码能力上限。