YSS

Write Less & Do More

防范node.js服务瞬间大流量请求

起因

事情的源头从一次抢购活动开始,

某次,在开发不知情的情况下,运营对外推广,使用了抢购模式。

然后,当天晚上的运维群里报出了大量5xx的情况。而且都是我们的node.js服务。

排查

接着,就开始排查。

  1. 首先,看日志,发现并没有大面积的5xx报错。
  2. 接着,排查的是不是有出现服务重启的情况。

说明

之所以排查重启,是因为很早之前也发生过大面积502的情况。

最后排查出结果是因为程序中有一个未捕获或者说未处理的错误,导致触发了node.js的uncaughtException事件。致使服务重启。一段时间内,整个服务不可用。

为此还专门做了服务重启优化策略。这里就不细讲。

异常

但是,在排查过程中,发现今天的访问日志量,明显比其他时间都多。

之后,让运维值班再帮忙看一下具体的Nginx日志,发现是stream连接超时了。。。

再查了一下流量情况,发现那个时间点,流量瞬间高涨,达到了惊人的数值。

结论

最后,和运维值班确定,初步确定是因为流量暴涨,导致请求阻塞,无法被正常处理。

解决

像这种情况,最简单的方式就是加机器或者扩容。

或者,当有瓶颈限制时,也会直接使用限流的方式。

但是,加机器和限流,其实都不是一个很好的做法。

加机器

加机器的话,固然可以解决,这个应该加多少,上限在哪里。

这个时候就需要你评估最大峰值会是多少,然后需要打压现有环境下服务能承受的最大请求量,最后确定加多少。

大部分时候是很难预测峰值会是多少。然后,可能很多时候,机器基本处于空闲状态。

扩容

扩容,主要是加CPU或内存。但由于本身也受限于系统和硬件的一些限制,并不是一个主流方案。

效果呢,类似正态分布图,属于先升后降。

就是说,如果你本身内存小,加内存和CPU是非常有效果的,但到达一个峰值(最佳配置)时,再加就基本没有太大作用了。

限流

然后是限流,这一般是受限于某个环节处理缓慢才会考虑的。

一般是由于数据库导致的,主要可能是由于用到了锁。

这个其实更不好,毕竟我们是有能力承受更大的请求的,但是我们却没有去做。

那么,我们有没有其他方式可以解决呢?

另一种方式

主要是我自己有一个想法,就是动态扩容。

动态扩容这个概念虽然经常被提起,但是目前看依旧不是很成熟。

刚好想通过这次问题来验证。

为什么会有这个想法呢?

首先,我们知道node.js是单进程,单线程的服务。

然后,本身提供了一个叫cluster的东西,也就是集群的概念。

最后,我们也一直在使用。

现状

先来分析一下我们现在的情况。

  1. 我们现在线上是有两台服务,每台服务内存是3G+2G(Docker预留)。
  2. 每台服务是1 Master + 2 worker(默认情况下1worker最大占用1.5G)
  3. Node本身是最善于处理高并发的请求。基于现在的情况,我认为远没有达到node的瓶颈。
  4. 通过falcon,查看内存的使用情况,发现大部分时候内存使用率非常低,5G的服务内存,只用不到2G,这就意味着有大概3G是处于长期空闲状态。
  5. 我们现在的服务,大部分情况只是做中转和路由之用。现有的Docker模式,默认是不限定使用CPU的个数(当然,实际情况是最多能用8个)。

那么,我们能不能做到,当遇到突发的瞬间大流量迅速进行扩容,也就是增加Worker呢?

答案是肯定的。

但是,怎么去做呢?

第一种方案

通过 Master去做,就是在 Master上检测是否有请求堆积。

大致过程:

  1. Master记录所有请求当前的请求堆积数量。
  2. 超过阈值时,触发扩容处理。

第二种方案

通过 Worker去做,在 Worker里检测到请求堆积,然后通知Master去派生新的 Worker。

目前从文档看,这个是可以做到的。

大致的过程就应该是:

  1. Worker本身去记录当前有多少个请求堆积。
  2. 当Worker发现请求堆积超过设定的最大阈值时,触发Master的扩容事件。

确定方案

确定是用第二种方案。

因为第一种方案目前做不到。

原因看Cluster的运行模式:

Cluster运行模式

Cluster的运行模式,跟nginx是类似的。

Master负责对外,通过监听端口,一旦有请求过来会自动分发给底下的 Worker,最后 Worker处理完后直接响应(Response)。

具体选择哪个worker是由内部调用机制实现的,默认是用的RR(round-robin)模式,即轮询。

来一张详情的请求和转发图:

cluster时序图

简述一下大致过程就是:

  1. 用户来了一个请求,被Master接收到了。
  2. Master询问Worker是否可以处理。
  3. Worker回答可以处理。
  4. Worker告诉Master我开始正式处理这个请求。
  5. Master告诉Worker说,我知道了。
  6. Worker处理完后直接返回给用户。

具体实现

方案确定了,那么就差实现。

真正落实到实践就发现了很多问题:

  1. 首先有扩容操作,就应该有缩容的操作。
  2. 如何保证扩容操作稳定?
  3. 扩容的阈值在哪里?

在解决这些问题之前,我们先要搞清楚,Cluster是如何运行的。

第一个问题

首先有扩容操作,就应该有缩容的操作。

这个问题的解决就是,没触发一次扩容操作,就触发一个延迟的缩容操作。

具体一点就是,使用 debounce 的概念,每当一个扩容操作过来,然后就取消上一次的延迟缩容操作,开启新的延迟缩容操作。

我把这个时间定为1分钟。具体代码如下:

// 缩容,避免资源浪费
const runShrink = function () {
    if (runShrink.timer) {
        clearTimeout(runShrink.timer);
    }

    // 如果一分钟之后,没有再发生扩容,就开始删除多余的节点
    runShrink.timer = setTimeout(function () {
        const workerIds = Object.keys(Cluster.workers);
        Cluster.workers[workerIds[0]].kill('SIGINT');
        Logger.error('[WWW] run shrink. Current workers count is ' + (workerIds.length - 1));
        // 小于4个不考虑,最低保证有2个
        if (workerIds.length < 4) {
            return;
        }
        runShrink();
    }, 60000);
};

总结,逐步缩容,保证系统整体稳定。

第二个问题

如何保证扩容操作稳定?

前面说了,node.js是单进程单线程的。

如果同一时处理很多扩容操作极易影响正常的请求转发和调用。

然后,就设定,某一段时间内只能调用一次,也就是throttle的概念。

并且限定最多能启用7个。

// 扩容,预防突发的大量请求
const runScale = function () {
    // 最多7个
    if (Object.keys(Cluster.workers).length > 6) {
        Logger.fatal('[Master] too many workers...');
        runShrink();
        return;
    }

    Cluster.fork().on('message', handleMessage);

    Logger.error('[WWW] run scale. Current workers count is ' + Object.keys(Cluster.workers).length);

    runShrink();
};

throttle的处理放到了发送的时候处理:

let isScaling = false;

// 限制3秒才可以再次触发一次
function sendScale () {
    if (isScaling) {
        return;
    }
    isScaling = true;
    Cluster.worker.send({
        act: 'scale'
    });
    setTimeout(function () {
        isScaling = false;
    }, 3000).unref();
}

第三个问题

扩容的阈值在哪里?

这是一个很棘手的问题。打压一般很难做到一个很精确的值。因为我们有很多页面,很多请求,不是单纯的一个或几个接口。

现实情况是很难知道当前状态下大概会有哪些个请求。

那么,我们是怎么去做的呢?

这是一个建立在用户无法访问的情况下一个个试出来的。

直接告诉大家,是76。

这个值跟我最初想象的差异实在是太大了,以至于在试验期间,悲惨的看着群里5XX的错误铺面而来。

具体代码,分享出来:

/**
 * created by yss on 2018/07/09
 */
const Cluster = require('cluster');

const EVENT_FINISH = 'finish';
const EVENT_CLOSE = 'close';
const MAX_REQUEST = 72;
let requestCount = 0; // 积压的请求数
let isScaling = false;

// 限制3秒才可以再次触发一次
function sendScale () {
    if (isScaling) {
        return;
    }
    isScaling = true;
    Cluster.worker.send({
        act: 'scale'
    });
    setTimeout(function () {
        isScaling = false;
    }, 3000).unref();
}

module.exports = async function scaleOut(ctx, next) {
    if (++requestCount > MAX_REQUEST && Cluster.isWorker) {
        sendScale();
    }
    const res = ctx.res;

    const done = function () {
        --requestCount;
        res.removeListener(EVENT_FINISH, done);
        res.removeListener(EVENT_CLOSE, done);
    };

    res.once(EVENT_FINISH, done);
    res.once(EVENT_CLOSE, done);

    await next();
};

如果大家也想尝试的话,告诉大家一个应急方法就是,提供一个http入口,可以去动态改变这个阈值。

最后

一切脱离业务场景去谈应用都是耍流氓。

我这个方法只适用于当前我这边所使用的这种类型项目。

不代表其他项目一定可以用,主要还是提供一下思考和借鉴,希望对大家有用。