记一次接口服务器网络请求的优化过程

最近做了一个提供给中学生使用的背单词的小型WEB项目
部署在一个1G CPU 2G内存 2M宽带的 腾讯云容器服务上
接口所在的服务,分了两个实例,各分配了0.1个CPU,196M的内存
人数不多的情况下,一切都正常,CPU内存并没有吃紧的情况,相反还很宽裕
但是同时上百个人在线的时候,发现部分学生出现加载卡顿的情况,初步分析瓶颈卡在网络带宽上

目前项目中用到的一些静态图片,比如单词配图,单词发音等文件,分离放到了对象存储服务器COS中.
主域名通过腾讯的CDN做了加速.因为前端工程中还有相当多的静态文件(css,js,html等)
预期的策略是,全部不缓存,但静态文件做30天缓存.

错误的缓存过期配置

通过CDN的日志分析,发现大量的静态文件也没有HIT,回过头查看发现,CDN的缓存优先级弄反了
根据CDN文档说明
匹配是从上一条到最后一条,下层覆盖上层,而不是匹配即中止的模式
所以我们应该把全部不缓存这一大条件先设到最上,然后是针对静态文件做30天的缓存
值得注意的是,这里的静态文件,即前端工程的JS,CSS文件等,在前端工程的编译设置里,务必处理成hash结尾的,如果不带hash区别内容的变化就加CDN缓存,很容易就陷入缓存陷阱而无法正常刷新变更了的内容。

修改后的缓存过期配置

通过这一优化,回源的数量大量减少

缓存顺序调整前后的回源请求变化

通过上边的优化,把静态文件从源服务器上松绑了。很大程度上解决了2MB小水管带来的宽带瓶颈问题。通过CDN日志的进一步分析发现,很多接口在重复请求的时候,依然从源服务器上处理并返回了,即使那些接口的结果内容并没有什么改变,这里就要引入ETag缓存机制了,来专门处理这种内容没发生变化的请求的优化。

项目的接口服务框架采用的是基于 Expressnest.js(也可以基于 Fastify ),而Express默认有开启弱类型的etag,这样根据每次的返回内容,会用md5方式计算一个字段,通过响应头返回来

// express\lib\application.js#75行

app.defaultConfiguration = function defaultConfiguration() {
  var env = process.env.NODE_ENV || 'development';

  // default settings
  this.enable('x-powered-by');
  this.set('etag', 'weak');
  ...

// express\lib\application.js#366行

app.set = function set(setting, val) {
  if (arguments.length === 1) {
    // app.get(setting)
    return this.settings[setting];
  }

  debug('set "%s" to %o', setting, val);

  // set value
  this.settings[setting] = val;

  // trigger matched settings
  switch (setting) {
    case 'etag':
      this.set('etag fn', compileETag(val));
      ...


// express\lib\request.js#463行
var fresh = require('fresh');
...463行
defineGetter(req, 'fresh', function(){
var method = this.method;
var res = this.res
var status = res.statusCode
// GET or HEAD for weak freshness validation only
if ('GET' !== method && 'HEAD' !== method) return false;
// 2xx or 304 as per rfc2616 14.26
if ((status >= 200 && status < 300) || 304 === status) {
return fresh(this.headers, {
'etag': res.get('ETag'),
'last-modified': res.get('Last-Modified')
})
}
return false;
});

虽然说服务端因为nestjs基于express,express又通过fresh实现了ETag的if-not-match请求头的比较功能
但是使用axios的前端,并没有天然的实现etag的数据缓存,以及if-not-match的请求头的自动添加
因此有必要扩展一下axios,使其能够读取服务端返回的ETag头,并缓存返回的内容,然后请求的时候,根据请求的地址,查找ETag值,并添加到请求头if-not-match中。

//AxiosEtagCache.ts
import * as localForage from "localforage";
import axios, {AxiosError, AxiosRequestConfig, AxiosResponse} from 'axios';

function isCacheableMethod(config: AxiosRequestConfig) {
  return ~['GET', 'HEAD'].indexOf(config.method.toUpperCase());
}
const stripSlash = (str) => str[str.length - 1] === '/' ? str.slice(0, str.length - 1) : str;
function getUUIDByAxiosConfig(config: AxiosRequestConfig) {
  var paramStr = "";
  var prefix = "";
  if (config.url.indexOf(config.baseURL) == -1) {
    prefix = stripSlash(config.baseURL)
  }
  if (config.params) {
    Object.keys(config.params).forEach(k => {
      paramStr += k + '=' + config.params[k] + '&';
    })
  }
  return prefix + config.url + "?" + paramStr;
}

async function getCacheByAxiosConfig(config: AxiosRequestConfig) {
  return await localForage.getItem(getUUIDByAxiosConfig(config));
}

async function requestInterceptor(config: AxiosRequestConfig) {
  if (isCacheableMethod(config)) {
    const uuid = getUUIDByAxiosConfig(config);
    const lastCachedResult: any = await localForage.getItem(uuid);
    if (lastCachedResult) {
      config.headers = {...config.headers, 'If-None-Match': lastCachedResult.etag};
    }
  }
  return config;
}

async function responseInterceptor(response: AxiosResponse) {
  if (isCacheableMethod(response.config)) {
    const responseETAG = response.headers.etag;
    if (responseETAG) {
      await localForage.setItem(getUUIDByAxiosConfig(response.config), {etag: responseETAG, value: response.data});
    }
  }
  return response;
}

async function responseErrorInterceptor(error: AxiosError) {
  if (error.response.status === 304) {
    const getCachedResult: any = await getCacheByAxiosConfig(error.response.config);
    if (!getCachedResult) {
      return Promise.reject(error);
    }
    const newResponse = error.response;
    newResponse.status = 200;
    newResponse.data = getCachedResult.value;
    return Promise.resolve(newResponse);
  }
  return Promise.reject(error);
}

export function resetCache() {
  localForage.clear();
}

export default function axiosETAGCache(config?: AxiosRequestConfig) {
  const instance = axios.create(config);
  instance.interceptors.request.use(requestInterceptor);
  instance.interceptors.response.use(responseInterceptor, responseErrorInterceptor);

  return instance;
}
//request.js
import axiosETAGCache from './AxiosEtagCache.ts';
// 通过axiosEtagCache创建axios实例
const service = axiosETAGCache({
  baseURL: process.env.VUE_APP_API_PREFIX',//请求头变量,方便在不同的环境切换
  timeout: 10000, // 请求超时时间  
})
//然后就可以通过service.get()这样的方式来正常请求接口,这时接口就会自动带上if-not-match请求头了

其中保存数据用到了localforage这个库,这是一个很强大的客户端缓存解决方案

这里的原理就是为axio做一个请求和响应的拦截器,并根据页面地址(含参数)为索引,将ETag以及返回的数据,存在本地缓存中
后续请求的时候,就先查找一下本地有没有缓存,如果有,就带上if-not-match请求头字段,服务端用来比较字段和内容生成的etag是否一致,如果一致就直接304返回。
虽然依然需要请求服务端,但是通过ETag比对的机制,可以有效的减少没有必要的数据传输,节省带宽进而提升用户体验。

通过ETag实现了单个client与server之间的缓存问题,但如果出现大量用户的访问,服务器还是会一个一个地返回数据
针对这种情况,对于一些公用的变动频度不高的接口数据(实时性要求不强的排名数据,可能会更新的单词教材),可以考虑把缓存前移到CDN级,让CDN去缓存接口请求的结果,就可以避免每个用户都去回源问服务器要数据的问题
但是想借由CDN的路径匹配来建立缓存策略,却发现腾讯云CDN的全路径匹配必需要有一个明确的文件后缀,以匹配静态文件

腾讯云CDN全路径文件不支持通配符结尾

因此我计划将现有的各个接口统一都加上一个/data.json这样的后缀,用来模拟静态文件,以符合腾讯云CDN的规则。
比如 /user/login » /user/login/data.json
要通篇逐个去在数百个接口后添加/data.json是很不方便的一件事情,因此,考虑用点hack的办法来解决这个问题
在nestjs中,通过@Get这样的装饰器,顺藤摸瓜,找到了关键词PATH_METADATA,再通过PATH_METADATA进一步找到了最后应用路由的关键方法
@nestjs\core\router\router-explorer.js中的 applyCallbackToRouter(router, pathProperties, instanceWrapper, moduleKey, basePath)
再进一步在构造函数上打断点,查找route-explorer实例是通过何种方式建立的,最后发现一个改写applyCallbackToRouter方法的路径
在main.ts中 先从router-explorer.js中把原本的方法COPY过来,并做一点点小修改,为每个路由加上一个“小尾巴”

//main.ts中把原本@nestjs\core\router\router-explorer.js中的 applyCallbackToRouter方法拷贝过来,做一点小修改
import {createContextId} from '@nestjs/core/helpers/context-id-factory'
function applyCallbackToRouter(router, pathProperties, instanceWrapper, moduleKey, basePath) {
  const {path: paths, requestMethod, targetCallback, methodName, } = pathProperties;
  const {instance} = instanceWrapper;
  const routerMethod = this.routerMethodFactory
    .get(router, requestMethod)
    .bind(router);
  const stripSlash = (str) => str[str.length - 1] === '/' ? str.slice(0, str.length - 1) : str;
  const isRequestScoped = !instanceWrapper.isDependencyTreeStatic();
  const module = this.container.getModuleByKey(moduleKey);
  const collection = module.controllers;
  if (isRequestScoped) {
    const handler = async (req, res, next) => {
      const contextId = createContextId();
      this.registerRequestProvider(req, contextId);
      const contextInstance = await this.injector.loadPerContext(instance, module, collection, contextId);
      this.createCallbackProxy(contextInstance, contextInstance[methodName], methodName, moduleKey, requestMethod, contextId, instanceWrapper.id)(req, res, next);
    };
    paths.forEach(path => {
      const fullPath = stripSlash(basePath) + path;
      routerMethod(stripSlash(fullPath) || '/', handler);
      routerMethod(stripSlash(fullPath) + '/data.json' || '/', handler);
    });
    return;
  }
  const proxy = this.createCallbackProxy(instance, targetCallback, methodName, moduleKey, requestMethod);
  paths.forEach(path => {
    const fullPath = stripSlash(basePath) + path;
    routerMethod(stripSlash(fullPath) || '/', proxy);
    routerMethod(stripSlash(fullPath) + '/data.json' || '/', proxy);
  });
}

//然后在建立app的地方,将这个修改后方法替换原本正常的方法
async function bootstrap() {
    const server = express();
    const app = await NestFactory.create(AppModule, new ExpressAdapter(server));
    //把原本的实例中的方法,指向修改后的方法,移花接木
    app['routesResolver'].routerBuilder.applyCallbackToRouter = applyCallbackToRouter;
    ...
    app.use((req, res, next) => {
        res.header('Access-Control-Allow-Origin', '*');
        res.header('Access-Control-Allow-Credentials', 'true');
        res.header('Access-Control-Expose-Headers', 'Authorization,Content-Disposition,ETag'); //注意要把ETag在这里Expose出来,这样客户端才能在response.headers里读到Etag的值
        res.header('Access-Control-Allow-Methods', 'OPTIONS,GET,PUT,POST,DELETE');
        res.header(
          'Access-Control-Allow-Headers',
          'Content-Type, Accept,Authorization,Content-Encoding',
        );
        next();
  });

测试接口,发现一切OK。即可以用原本的接口地址访问,也可以用加个小尾巴的地址来访问。

返回CDN的缓存设置处,加上新的缓存规则。

添加了带小尾巴的接口缓存策略

因为很多接口是伴随着请求参数来的,比如说某些分页参数的接口。
list?page=1&size=10和list?page=2&size=10
应该被设为不同的缓存对象,因此,在访问过滤处,将过滤参数关闭,好让这种参数不同的路径,能被分别缓存

关闭过滤参数

做了上边一些优化操作后,2MB的小水管也一点无压力了。

再之后的排查中,发现存放在COS上的一些静态文件cache-control设成了no-cache。
然而并没有一个方便的办法直接批量去把某个文件夹下的内容都加个统一的头,搜索了一圈,发现了一个同步工具
它的操作是拿本地的文件夹里的内容和服务端文件夹做比较,有文件才会去设置服务端的,所以我只好先从COS把文件拉到本地来。
如此操作了一番后,cache-control被设成了no-cache这个问题得以解决。

Leave a comment

Your email address will not be published. Required fields are marked *