腾讯课堂小程序性能极致优化——网络请求优化篇

253次阅读  |  发布于3年以前

近期,我们对腾讯课堂小程序做了一次全方位的性能优化,本篇文章将从网络请求的角度分享一种优化的思路。我们引入了一种请求排队的策略,通过控制不同优先级请求的发送顺序,保障影响页面渲染的关键请求能够及时发送,并迅速得到返回结果。由请求测速数据统计,我们的关键请求耗时实现了 50-100 ms,约 15% 的优化。

1 . 导火索

某个平淡无奇的工作日,我们的小程序在 iOS 上突然无比的卡顿。

紧急排查之后,发现我们使用的一个第三方上报服务异常,导致我们的上报请求一直处于 pending 状态。

这时,小程序官方文档的网络使用说明映入眼帘:

wx.request、wx.uploadFile、wx.downloadFile 的最大并发限制是 10 个。

也就是说,由于并发限制,当处于 pending 状态的请求数达到 10 个时,后续调用 wx.request 发送的请求可能会被阻塞。

时间较为久远,没有留下证物,我们通过模拟请求超时来还原一下当时的现场。

借助 whistle 的 resDelay 方法,可以将 reportlog 两种上报请求延迟 5000ms 返回:

可以看到由于上报请求一直处于 pending 状态,导致后续发送的业务请求 get_homepage_feeds_h5 也一直在 pending 中,页面加载变得卡顿起来。

排查到问题后,我们紧急下掉了异常请求,现网突发的卡顿问题得以解决。

但是,这次的异常引起了大家的注意:上报请求竟然会对业务请求造成如此大的影响。我们似乎应该通过一些方式,保障与用户体验相关的业务请求正常发送

2. 设想

网络请求的耗时与许多因素相关,用户的网络环境以及提供接口的服务质量,我们无法在前端控制。

但如果我们可以给小程序的网络请求设置优先级,当多个请求并发时,让低优先级的上报请求给高优先级的业务请求让路,是否也能让业务请求速度提升,优化用户体验呢?

3. 方案实现

我们设计的请求优先策略如下:

1 . 将请求分为高优先与低优先两种等级。

2 . 当并发请求数超过一定阈值时,仅发送高优先级请求,拦截低优先级请求的发送。

3 . 并发请求数量下降后,补发被拦截的低优先级请求。

4 . 设置最长等待时间,超时后主动发送低优先请求,避免过度延时。

小程序的 HTTPS 请求是通过 wx.request API 发送的,我们可以通过拦截这个 API 来实现对所有请求的发送顺序控制。整块逻辑我们封装到一个 排队请求模块 来实现,模块中提供一个与 wx.request 具有相同函数签名的方法,用来替换原始的请求 API。

3.1. 关键配置

threshold: 并发请求数的阈值。当正在进行的请求数超过这个阈值时,延迟发送低优先请求。

maxWaitingTime: 最长等待时间。等待队列中的请求等待超过该时间后,主动补发,避免过度延时。

lowPriority: 一组匹配方式(正则等)。用于判断请求是否属于低优先级。

目前我们设置 threshold 为 5,主要出于以下两方面考虑:

1 . 给后续可能到来的业务请求预留 5 个坑位,避免阻塞。

2 . 已有 5 个请求并发时,延迟上报请求发送,也可减少并发造成的网络争抢。

3.2. 初步设计

模块基本功能由 3 个类实现:

  1. 请求分发器 QueueRequest :对新的请求进行分发。

2 . 等待队列 WaitingQueue :维护需要延时发送的请求等待队列。在请求池空闲或请求超过最长等待时间时,补发等待请求。

3 . 请求池 RequestPool :发送请求并维护所有正在进行的请求的状态。对外暴露正在进行的请求数量,并在有请求完成时通知等待队列尝试补发。

这里简单通过各个类的接口来描述一下类之间的包含、调用关系:

QueueRequest 类

class QueueRequest {

  // 请求池
  private requestPool: RequestPool;

  // 等待队列
  private waitingQueue: WaitingQueue;

  // 用于替代wx.request的请求方法
  request(opts: WechatMiniprogram.RequestOption): WechatMiniprogram.RequestTask;
}

WaitingQueue 类

class WaitingQueue {

  // 等待发送的请求队列
  private queue: QueueRequestOption[];

  // 检查队列是否有超过最大等待时长的请求
  private checkQueue();

  // 将一个参数为opt的新请求加入等待队列
  public enqueue(opts: QueueRequestOption);

  // 发送等待队列的第一个的请求
  public dequeue();

  // 获取等待队列的长度
  public getWaitingNum():number;
}

RequestPool 类

interface RequestPoolConfig {
  onReqComplete?: () => void;
}

class RequestPool {

  // 真实的wx.request方法
  private originRequest = wx.request.bind(wx);

  // 请求池,记录正在进行中的请求
  private pool;

  // 将一个参数为opts的请求加入请求池并发送
  public add(opts: QueueRequestOption);

  // 请求完成时,将标识为seq的请求移出请求池,并触发onReqComplete事件
  private remove(seq: number);

  // 获取请求池内请求数量
  public getReqNum():number;
}

QueueRequest中维护了 WaitingQueue 和 RequestPool 两个类的实例。

通过 WaitingQueue.enqueue() 将请求加入等待队列;

通过 ReuqestPool.add() 将请求放入请求池,直接发送;

QueueRequest.request()方法用于替代 wx.request,在实际调用 wx.request 前执行请求分发逻辑。

此时,我们已经可以通过 QueueRequest.request() 方法,控制不同优先级请求的发送顺序了。

3.3. RequestTask维护

然而,根据 API 文档,调用 wx.request 会返回一个 RequestTask 对象。那么进入等待队列的请求,在没有调用 wx.request 时,要如何同步地返回该对象呢?

我们设计了 RequestTaskProxy 类来模拟真实的 RequestTask。

  1. 在请求未实际发送前先返回该对象代理,其实现了 RequestTask 的接口。
  2. 记录下各个接口的调用参数,保存到内部的 operations 队列中。
  3. 在请求实际发送后,将 operations 调用记录重放到真实的 RequestTask。

增加了 RequestTaskProxy 后,新的请求进入排队请求模块,形成了如下的调用链路:

RequestTaskProxy 类的大致实现如下:

export class RequestTaskProxy implements WechatMiniprogram.RequestTask {

  // 真实的RequestTask
  private task?: WechatMiniprogram.RequestTask;

  // 未绑定真实RequestTask时的操作记录
  private operations: [];

  /**
   * 设置真正发起请求后返回的requestTask
   * @param requestTask 实际wx.request返回对requestTask
   */
  public setRequestTask(requestTask: WechatMiniprogram.RequestTask) {
    this.task = requestTask;
    this.operations.forEach((op) => {
      // 将this.operations重放到requestTask上
      // ...
    });
    this.operations = [];
  }

  // 模拟RequestTask的接口
  public abort() {
    if (this.task) {
      this.task.abort();
    } else {
      this.operations.push({ type: 'abort' });
    }
  }
  // 模拟RequestTask的接口
  public offHeadersReceived(callback?: WechatMiniprogram.OffHeadersReceivedCallback) {
    if (this.task) {
      this.task.offHeadersReceived(callback);
    } else {
      this.operations.push({ type: 'off', cb: callback });
    }
  }
  // 模拟RequestTask的接口
  public onHeadersReceived(callback: WechatMiniprogram.OnHeadersReceivedCallback) {
    // 类似offHeadersReceived
    // ...
  }
}

3.4. 替换 wx.request

由于 wx.request 属性的 writable 被设置为 false,不能直接赋值,我们只能通过 Object.defineProperty 替换该属性的 value 值。

class QueueRequest {
  request(opts: WechatMiniprogram.RequestOption): RequestTask;

  // 替换wx.request方法
  make() {
    Object.defineProperty(wx, 'request', { value: this.request.bind(this) });
  }
}

/**
 * 对外暴露的方法,调用后注入排队请求逻辑
 * @param config 配置项
 */
export function useRequestQueue(config: QueueRequestConfig) {
  const queueRequest = new QueueRequest(config);
  queueRequest.make();
  return queueRequest;
}

在小程序中,调用 useRequestQueue() 方法,即可拦截 wx.request API,按照配置的优先级控制请求顺序了。

4. 优化效果

在后台服务没有任何改动的情况下,我们在前端干预了部分用户网络请求的顺序,并上报统计了从发起业务请求获得请求结果的耗时。

该耗时的计算近似于请求 API 触发 complete 回调的时间 - 调用注入后的 wx.request 的时间

我们随机选择了一批用户进行灰度,经过一段时间后,得到了对比数据:

4.1. 请求平均耗时对比

注:图表的纵轴为用户 业务请求(高优先) 的平均耗时,横轴为时间轴

绿色曲线来自不做干预的普通用户

黄色曲线来自应用请求优先策略的灰度用户

从图中可以看到,对网络请求顺序的干预效果明显,灰度用户业务请求耗时平均有 50-100ms,约 15% 的优化

4.2. 不同耗时请求的优化效果对比

注:图表的纵轴为用户 业务请求(高优先) 耗时,横轴为时间轴

曲线大致可分为三组,每组两条曲线,选取不同百分位的请求耗时对比,从上到下依次为:

  1. 请求耗时的 80 分位
  2. 请求耗时的 50 分位(中位数)
  3. 请求耗时的 20 分位

我们发现:对网络请求顺序的干预在耗时长的网络请求中效果更为明显。也就是说,这次的优化效果主要作用在弱网用户上

该表现是符合预期的,因为在弱网环境下,更容易发生请求堆积,对业务请求造成阻塞。

Copyright© 2013-2020

All Rights Reserved 京ICP备2023019179号-8