Abort-Controller

January 17, 2020 by Sylvenas

应用场景

现在前端主流的技术方案都是服务端渲染,那就面临着一个问题,Node.js需要承担接口转发的任务,所以原来的客户端渲染http请求的路线(brower -> server),需要调整为(Node.js -> server);看上去没有什么本质的区别,无非是原来是又浏览器发起请求,现在改为Node.js发起请求。

但是高并发呢?举个例子,假如有1000000个请求,原来从browser请求到server,server端如果没有做限流处理,那么部分请求就会一直处于pendding状态;但是1000000个pendding是分布在每个客户端上的,所以并没有什么压力;但是如果是Node.js发起的请求,则会出现很大一部分请求在Node.js端处于pendding状态,则会导致Node.js的CPU和内存占用压力剧增。

Promise timeout方案

前端http请求的代码一般会添加一个timeout的时间限制,配合Promise.race大概的代码思路是:

function timeout() {
    const ms = 2000;
    return new Promise((resolve, reject) => {
        setTimeout(() => {
            reject({
                code: 999,
                msg: ` 调用接口超时: >${ms}ms`,
            });
        }, ms);
    });
}

Promise.race([fetch(url, option), timeout()])
    .then(
        res => res.json(),
        err => {
            console.log('请求失败')
            return Promise.reject({
                code: 0,
                msg: '加载失败',
            });
        }
    )

上面的代码很简单,借助Promise.race,如果期望的fetch请求在2000ms内没有返回,则timeout中的promise会进入rejected状态,导致整个promise reject,相当于正常的fetch请求我们不会继续处理,直接抛弃了。

但是事实如此吗?这个被我们抛弃的Promise实际上依然处于pendding状态,直到http请求结束,promise进入resolve or reject状态,下图可见,错误日志已经打印出来了,但是http依然处于pendding的状态;可见Promise.race并不能真正的"抛弃请求"。内存占用,cpu占用依然存在。

promise-timeout

上面的例子是发生在browser,问题不大,因为通常一个页面请求不会超过10个,对于单个用户机器来说,性能完全没有什么问题。

但是如果上述事件是发生在Node.js请求server的过程中呢?大量的请求堆积在Node.js端,导致机器cpu或者内存占满,别忘了我们是服务端渲染,这个时候,用户打开页面将会异常缓慢(肯本没有机会继续处理页面渲染了),或者长时间的等待之后502

上面的例子是在云音乐项目中实际案例。

所以我们需要找到另外一种解决方案(能真正取消fetch请求的方案),此时[AbortController](https://developer.mozilla.org/en-US/docs/Web/API/AbortController)映入眼帘。

AbortController使用方法

使用方法很简单,直接看文档。 MDN文档
Google Developers: abortable-fetch

现在我们已经确定AbortController可以真正的取消请求,但是对性能的影响呢?下面我们做个实验:

使用AbortController取消 Node.js -> java server的请求

测试代码:

const fetch = require('node-fetch');
const AbortController = require('abort-controller');

function App() {
    const controller = new AbortController();
    const signal = controller.signal;
    Promise.race([fetchInterface(signal), timeout(controller)]).then(res => {
        // console.log('请求成功')
    }, err => {
        //console.log('请求失败')
    })

}
function timeout(controller) {
    const ms = 2000
    return new Promise((resolve, reject) => {
        setTimeout(() => {
            // 是否取消请求
            controller.abort();
            reject({
                code: 999,
                msg: ` 调用接口超时: >${ms}ms`,
            });
        }, ms);
    });
}

function fetchInterface(signal) {
    return fetch('http://xx.xx.xx.xx:xxxx/fiveseconds', {
        signal
    })
        .then(function (response) {
            // handle success
        })
        .catch(function (error) {
            // handle error
        })
}


for (let index = 0; index < 10000; index++) {
    App()
}

// 全部延迟到6s之后关闭进程
setTimeout(() => {
    console.log('测试结束')
}, 6000)

Node.js 进程性能监控

监控工具

Node.js 版本 - v10.16.3
node-clinic

测试命令:clinic doctor -- node abort.js

测试方法

在Node.js端连续发起10000次(再多电脑散热器要揭竿而起)fetch请求,请求随机延迟0-5秒之后响应;

设置2s的超时期限;分别测试2s之后,取消请求与不取消请求的差别下Node.js进程的性能对比;

最后采用setTimeout 6s的原因是全部延迟Node.js进程到6s之后结束,取消请求会导致2s后进程关闭,后面的性能数据无法继续采集

测试结果
  • 不取消请求的结果

    • 相同点:2s之后Promise.race返回的promise毫无疑问处于rejected状态
    • 不同点:2s之后,原来发起的请求,依然处于pending状态,Node.js进程并未结束,需等待所有异步请求结束之后才会结束进程
  • 取消请求的结果

    • 相同点:2s之后Promise.race返回的promise毫无疑问处于rejected状态
    • 不同点:2s之后,原来发起的请求,处于canceled状态,Node.js进程结束
  • 性能图对比:

    • 不取消请求
      • cpu持续占用,最高600%
      • 内存逐渐小幅度增加,最高252MB
      • active handles到结束逐渐减少

    不取消请求

    • 取消请求
      • 2s内cpu持续占用,最高355%,2s后极速降低为0
      • 2s内内存逐渐小幅度增加,最高248MB,2后极速降低到133MB
      • active handles 2s后极速降低到0

    取消请求

CPU占用超过100%的原因JS为单线程,但是Node.js为多线程,Node.js会启用其他线程进行垃圾回收,性能优化等等,所以CPU占用是一个综合之后的数据

node-fetch timeout

现在在Node.js端做转发请求一般会使用node-fetch库,node-fetch本身实现了timeout的option,node-fetch timeout的实现原理如下:

if (request.timeout) {
    req.once('socket', socket => {
        reqTimeout = setTimeout(() => {
            reject(new FetchError(`network timeout at: ${request.url}`, 'request-timeout'));
            finalize();
        }, request.timeout);
    });
}

node-fetch并不推荐使用timeout,而是建议使用标准的AbortController来控制超时:

结论

引入abort-controller polyfill包 3kb,还是值得的,周下载量超过200万,应该是挺多人在使用。