如何让轮询效率最大化

3,153 阅读6分钟

请先拒绝轮询技术方案,而应该采用服务端推送,前端只需当做普通事件注册即可,因为:

  1. 轮询很难设计,设计不好有『自我 DDOS』风险,其次轮询请求命中效率不高,需要发送多次请求方可命中。
  2. 服务端推送的实时性是很高的。 总结:轮询方案实时性和命中率都不高 🤕。 如果实在要轮询可以参考以下方案。

需求

我们总会面临类似异步长时间才能拿到操作结果的情景,假定有个操作预期 1s 左右方可拿到回执,为了更好的用户体验只要能拿到就展示已到账,否则继续请求,用户最多等待 1.5s。第一眼转换成技术需求就是:在1.5s 内不断轮询直到成功为止。

本文从特定需求触发,总结通用思路和方法。 总共 5 种方案。下面依照不断进化的思路铺开讲述。

方案一:固定超时 & 固定请求次数

前端限定每次请求设置超时 500ms,得不到结果继续下一轮,三次都不行直接结束轮询。

分析

该方案是串行

优点

代码简单易懂。但是依赖了请求平均耗时在 500ms 内。

缺点

这种策略可能会造成接口可能每次都是 600ms 才返回结果,而我们却限制了 500ms,导致无论发送多少次请求都无法拿到回执,在规定时间内肯定得不到结果。

方案二:限制轮询总时长 & 不限制次数

前端限制轮询总时长 1500ms,在总时长内,无论调用多少次接口,只要轮询时长超过 1500ms,就结束轮询。

分析

该分案是串行

缺点

请求快速失败的情况会导致发送过多无谓的请求。假若需要在第 800ms 的时刻才能拿到回执,一次请求耗时 100 ms,则前面 7 次请求都浪费了。

1500ms 内到底请求多少次合适,万一接口 50ms 返回一次,这不得一次提交调用多次该接口。。。

网络通顺的情况下:3次?

网络不通的情况下:最多两次?

方案三:固定间隔离散轮询 & 单个请求设置递减超时

第一个轮询的方案的优化版:我们分别在 0s 0.5s 1s 这 3 个时刻发送请求,总共 3 个,超时分别设置 1.5s 1s 0.5s,只要有一个返回了已到账,则提示用户已到账。解决了快速失败的问题,同时请求打散,保证一定有一个请求能拿到回执的概率最大化。

分析

该分案不是纯粹的串行,串中有并。

优点
  • 将请求打散,保证各个时间段均匀分布提高请求命中率
  • 没有定时器不用考虑关闭定时器,没有内存泄漏风险
  • 没有 recursion,无需考虑无限循环或无限次请求
  • 必定是有限次数。解决方案二的痛点
缺点
  • 请求有超时,还是有整体失败的概率

具体方案

一、使用 Promise 实现

第一步:先简化下,只要拿不到回执就认为请求失败。

// 1 先简化下,只要非到账就认为请求报错
function fetchList(params) {
  return request(params).then((resp) => {
    if (resp.status === 'SUCCESS') {
      return resp;
    }

    throw resp;
  });
}

第二步:不考虑通用,定死 3 个请求每隔 0.5s 发送一个,3 个请求的超时时间依次是 1.5 1 0.5。

Tips: 不考虑通用能让思路变得更清晰

function fetchLoop() {
  // 该 Promise constructor 不可能报错,故可以用 async
  // https://stackoverflow.com/a/43050114
  return new Promise(async (resolve, reject) => {
    let times = 0;

    const r1 = handle(fetchList({ timeout: 1500 }));
    
    await delay(500)
    const r2 = handle(fetchList({ timeout: 1000 }));
    
    await delay(500)
    const r3 = handle(fetchList({ timeout: 500 }));

    function handle(promise) {
      times++;

      promise.then(resolve).catch((error) => {
        if (times >= 3) {
          reject(error);
        }
      });
    }
  })  
}

代码 Tips:promise.then(resolve) 利用了 Promise 的『终态不可逆转性』

第三步:在第二步的基础上泛化,考虑通用情况,用 for 循环解决不定次请求。

// 考虑通用情况,用 for 循环解决不定次请求
function loopInLimitTime(asyncFn, { timeLimit, interval }) {
  return new Promise(async (resolve, reject) => {
    const loopCnt = Math.ceil(timeLimit / interval);
  
    // timeLimit=3 interval=2 loopCnt = ceil(3/2) =  2
    // #i moment timeout
    // #1 0 3-0*2=3
    // #2 2 3-1*2=1
  
    // timeLimit=1.5 interval=0.5 loopCnt = ceil(1.5/0.5) = 3
    // #i moment timeout
    // #1 0.0 1.5-0*0.5=1.5
    // #2 0.5 1.5-1*0.5=1.0
    // #3 1.0 1.5-2*0.5=0.5
    for (let index = 0; index < loopCnt; index++) {
       if (succeed) {
        // DO NOTHING
        // no need to re-request
        return;
      }
      
      // resume on not succeed
      handle(asyncFn({ timeout: timeLimit - index * interval }));
  
      await delay(interval);
    }

    function handle(promise) {
      times++;

      promise.then(resolve).catch((error) => {
        if (times >= loopCnt) {
          // 只有超过次数才结束,否则让其继续接下来的请求
          reject(error);
        }
      });
    }
  })
}

使用

Page({
  async onLoad() {
    const [error, resp] = await loopInLimitTime(fetchList, {
      timeLimit: 1500, interval: 500,
    });

    if (error) {
      // 上报监控
      rc.error(`未在1.5s内返回明确回执`, { code: xxx })

      return;
    }

    this.setData({ status: resp.status });
  },
})

使用『发布-订阅者』模式 - 事件机制

如果想知道进度和更详细的过程描述,比如是在第几次请求获得了回执。可使用『发布-订阅者』模式,即前端熟悉的事件机制,不是『我等你,而是你回调我』。

Hollywood Principle – Don't Call me, I'll Call You! 好莱坞法则 / 好莱坞原则

function loopInLimitTime$(asyncFn, { timeLimit, interval, eventName }) {
  const loopCnt = Math.ceil(timeLimit / interval);

  for (let index = 0; index < loopCnt; index++) {
    handle(asyncFn({ timeout: timeLimit - index * interval }));

    await delay(interval);
  }

  function resolve(resp, index) {
    event.emit(`${eventName}:success`, resp, index)
  }

  function reject(error) {
    event.emit(`${eventName}:failed`, error)
  }
  
  function progress(index) {
    event.emit(`${eventName}:progress`, { index, loopCnt })
  }

  function handle(promise) {
    times++;

    promise
      .then((resp) => { resolve(resp, index) })
      .catch((error) => {
        if (times >= loopCnt) {
          reject(error);
        }
    });
  }
}

使用

let offSuccessEvent;
let offFailedEvent;
let offProgressEvent;

Page({
  onLoad() {
    // 需提前注册
    const eventName = 'operation-result'

    offSuccessEvent = event.on(`${eventName}:success`, ({ status }, index) => {
      rc.info(`回执在第${index}个请求返回`);
      
      this.setData({ status });
    });
    
    offProgressEvent = event.on(`${eventName}:progress`, ({ index, loopCnt }) => {
      rc.info(`共${loopCnt}次轮询,已发起第${index}次轮询`);
    })

    offFailedEvent = event.on(`${eventName}:failed`, (error) => {
      // 上报
      rc.error(`未在1.5s内返回明确回执`, { error, code })
    })

    loopInLimitTime$(fetchList, { timeLimit: 1500, interval: 500, eventName })
  },

  // 记得页面 / 组件卸载时解除监听,防止内存泄漏
  onUnload() {
    offSuccessEvent?.();
    offFailedEvent?.()
    offProgressEvent?.()
  }
})
代码分析

?. 写法见关于 JavaScript 的几个冷知识

方案分析

该方案越来越像 Sync 模式了 😄。

优点

过程全透明,可更细粒度的控制整个过程,甚至页面可以显示倒计时 😎。

缺点

API 不明确,需要查看源代码,或者有很好的文档。

使用 RxJS 实现

Promises are not able to work on multiple events. RxJS Observable not only works like promises but can accomplish even more.

function poolInLimitTime$(
  asyncFn: (...args: any[]) => Promise<any>,
  { timeLimit, interval }
) {
  return new Observable((subscriber) => {
    const loopCnt = Math.ceil(timeLimit / interval);

    let succeeded = false;

    const startTimestampOfAllRequests = Date.now();

    // Must fill the array otherwise the reduce cb wont be called!
    new Array(loopCnt).fill(0).reduce((acc, cur, index) => {
      return acc.then(() => {
        if (succeeded) {
          // DO NOTHING
          // no need to re-request
          return;
        }

        // continiue request on failed
        handle(asyncFn({ timeout: timeLimit - index * interval }), index + 1);

        return delay(interval);
      });
    }, Promise.resolve());

    function resolve({ resp, index, totalRequestsCosts, singleRequestCosts }) {
      subscriber.next({
        type: `success`,
        payload: { resp, index, totalRequestsCosts, singleRequestCosts },
      });

      succeeded = true;

      subscriber.complete();
    }

    function reject({ index, error, totalRequestsCosts, singleRequestCosts }) {
      subscriber.error({
        index,
        error,
        totalRequestsCosts,
        singleRequestCosts,
      });
    }

    function progress(index) {
      subscriber.next({
        type: `progress`,
        payload: { index, loopCnt },
      });
    }

    async function handle(promise: Promise<any>, requestIndex) {
      progress(requestIndex);

      const start = Date.now();

      try {
        const resp = await promise;
        const singleRequestCosts = Date.now() - start;

        resolve({
          resp,
          index: requestIndex,
          totalRequestsCosts: Date.now() - startTimestampOfAllRequests,
          singleRequestCosts,
        });
      } catch (error) {
        const singleRequestCosts = Date.now() - start;

        subscriber.next({
          type: `error`,
          payload: {
            index: requestIndex,
            loopCnt,
            error,
            singleRequestCosts,
          },
        });

        if (requestIndex >= loopCnt) {
          reject({
            index: requestIndex,
            error,
            totalRequestsCosts: Date.now() - startTimestampOfAllRequests,
            singleRequestCosts: singleRequestCosts,
          });
        }
      }
    }
  });
}

详细代码见 Polling Use RxJS

代码分析

使用 reduce 代替 for await,是一种常用模式,但主要是因为 Observable 的构造函数不能是 async 函数,否则代码会更简单,更容易理解,如果大家有好的方法欢迎留言。

for (let index = 0; index < loopCnt; index++) {
  handle(asyncFn({ timeout: timeLimit - index * interval }));

  await delay(interval);
}

二者等价

new Array(loopCnt).fill(0).reduce((acc, cur, index) => {
  return acc.then(() => {
    handle(asyncFn({ timeout: timeLimit - index * interval }), index + 1);

    return delay(interval);
  });
}, Promise.resolve());

方案四:固定间隔轮询 & 单个请求不加超时控制

结束条件是整体超时 1.5s。

优点

适合中长时间的轮询

缺点

方案五:递增间隔轮询

如果很长时间轮询,而且网络条件普遍很差的情况下,采用间隔递增方案可以进一步减少请求的浪费。

递增规则可以是指数型,比如 2 的阶乘 1 2 4 8 16 32 64 ...。

附录

"Don't call me; I'll call you." 好莱坞原则

通常,Client即you调用下层Server即me天经地义,但是,对于某些方法, 请你不要轮询/骚扰我,我通知你

现实生活中乘客/you打的士到某地,沿途问司机/me某个景点天经地义;但是不要从上车的第一秒开始,时刻或每隔5秒问司机到了打的的目的地没有,这也太烦人了。

好莱坞原则的核心:以通知替代轮询。

服务端推送即『好莱坞原则』的体现。

参考文档