🔥 Node.js服务器并发控制实战:让你的服务器不被流量冲垮!

1,035 阅读8分钟

💡 为什么需要限制并发连接?

首先,我们需要明白一个残酷的现实:服务器资源是有限的!就像一个小餐厅一样,如果同时涌入太多客人,服务质量必然下降,甚至可能导致混乱。对于Web服务器来说也是如此:

  • 🎯 内存资源有限
  • 🎯 CPU处理能力有限
  • 🎯 网络带宽有限
  • 🎯 数据库连接数有限

如果不对并发连接数进行限制,可能会导致:

  • 😱 服务器响应变慢
  • 😱 内存溢出
  • 😱 服务完全宕机
  • 😱 其他用户无法访问

举个例子,我们写了一个服务:

  • 分配 2G 内存
  • 没有对请求量做限制
  • 每个请求都会消耗一部分内存

结合这3个条件,请求量一多,内存一超过限制,服务直接崩溃,让我们模拟一下

const http = require('http');
const { promisify } = require('util');
const fs = require('fs');

const readFileAsync = promisify(fs.readFile);

async function loadLargeFileIntoMemory() {
    try {
        const data = await readFileAsync('./largefile');
        return data;
    } catch (error) {
        console.error('读取大文件出错:', error);
        return null;
    }
}

const server = http.createServer(async (req, res) => {
    const largeFileData = await loadLargeFileIntoMemory();
    if (largeFileData) {
        res.writeHead(200, { 'Content-Type': 'text/plain' });
        res.end('Request processed');
    } else {
        res.writeHead(500, { 'Content-Type': 'text/plain' });
        res.end('Internal Server Error');
    }
});

const PORT = process.env.PORT || 3000;
server.listen(PORT, () => {
    console.log(`Server listening on port ${PORT}`);
});

在发送请求之前,我们的程序只占了 35.1 M 的内存。

image.png

用 ab 模拟了 200 并发

image.png

这时我们的 Node.js 程序占用内存已经达到了巅峰!27G!

image.png

下面让我们限制一下内存,让他爆炸。

node --max-old-space-size=2048 server.js

这样可以设置 Node.js 内存占用量吗? 可以又不可以,可以参考这篇:

你有关心过你的 Node.js 程序内存占用情况吗

这里简单提一下V8堆内存进程总内存的区别:
--max-old-space-size 只限制V8引擎的堆内存(heap memory)但是Node.js进程的总内存还包括:

  • 堆外内存(Buffer、线程池等)
  • 系统内存(系统调用、文件操作等)
  • Native内存(C++层面的内存分配)

比如我们如果使用 Buffer,Buffer是在V8堆外分配的内存,举个例子

const randomString = crypto.randomBytes(1024).toString('hex');

那么crypto.randomBytes()创建的Buffer不受--max-old-space-size限制。

那在我们的程序中以fs.readFile为例,它用于异步读取文件的全部内容。当读取文件时,文件内容会被存储在一个Buffer对象中, 那么也不会受堆内存分配的限制。

那我们该如何限制呢?

  • 系统级别的内存限制(ulimit或Docker)
  • 进程管理工具(PM2)
  • 代码层面的内存监控和控制

感兴趣的可以自己尝试,上面的例子说明了请求并发量不控制,会对我们的程序产生灾难性影响,所以开发完程序后,需要预估我们会有多少访问者,才能准备什么样的资源来部署。

🛠️ 实现方案

我们先做限制并发最基础的功能

1. 使用队列控制并发

来看一个简单但实用的实现方案,我们使用队列来控制并发请求:

const express = require('express');
const app = express();

// 创建一个简单的队列类
class RequestQueue {
  constructor(maxConcurrent) {
    this.maxConcurrent = maxConcurrent;
    this.currentRequests = 0;
    this.queue = [];
  }

  // 添加请求到队列
  enqueue(req, res, next) {
    if (this.currentRequests < this.maxConcurrent) {
      this.currentRequests++;
      next();
    } else {
      this.queue.push({ req, res, next });
    }
  }

  // 处理完成后释放资源
  dequeue() {
    this.currentRequests--;
    if (this.queue.length > 0) {
      const { req, res, next } = this.queue.shift();
      this.currentRequests++;
      next();
    }
  }
}

// 创建队列实例,最大并发数设为100
const requestQueue = new RequestQueue(100);

// 中间件:限制并发
const limitConcurrent = (req, res, next) => {
  requestQueue.enqueue(req, res, next);
};

// 使用中间件
app.use(limitConcurrent);

// 在请求结束时释放资源
app.use((req, res, next) => {
  res.on('finish', () => {
    requestQueue.dequeue();
  });
  next();
});

// 示例路由
app.get('/api/test', async (req, res) => {
  // 模拟耗时操作
  await new Promise(resolve => setTimeout(resolve, 1000));
  res.json({ message: '请求处理成功!' });
});

app.listen(3000, () => {
  console.log('服务器启动在端口3000');
});

我们将并发请求处理限制为10个,通过 ab 测试一下

ab -n 100 -c 20  http://127.0.0.1:3000/api/test

效果非常好,结果如下,每秒可以处理 9 个请求。

image.png

我们对代码做一个简单的解释,先建一个队列用来存放请求排队,当前请求数如果已经超过了比如10,那我们就把这些请求放到队列里

 if (this.currentRequests < this.maxConcurrent) {
      this.currentRequests++;
      next();
    } else {
      this.queue.push({ req, res, next });
    }

当前面请求处理完了,我们再把这些排队请求捞出来继续处理

  res.on('finish', () => {
    requestQueue.dequeue();
  });

2. 使用第三方库实现

有了上面的原理基础,我们可以看一看成熟的库,比如 bottleneck,他是怎么实现并发限流的,大差不差。

const Bottleneck = require('bottleneck');

// 创建并发控制器
const limiter = new Bottleneck({
  maxConcurrent: 100, // 最大并发数
  minTime: 100 // 每个请求的最小间隔时间
});

// 应用限流中间件
app.use(limiter);

🎨 优化我们程序的并发控制

1. 实现优雅降级 🎯

我们当前的实现的让用户排队,这样会消耗着服务端资源一直等,另一种场景的做法是服务降级。让请求直接返回,这样用户也不会卡着不动,体验比较好,服务单也减少了负载。只需要在并发数受限时返回即可。

// 当并发达到阈值时返回友好提示
if (requestQueue.currentRequests >= requestQueue.maxConcurrent) {
  return res.status(503).json({
    message: '服务器繁忙,请稍后再试'
  });
}

修改后可以看到程序的并发处理能力上来了,每秒可以处理50个请求。 只会真正处理10个请求的业务逻辑,其他请求会直接返回。

image.png

2. 监控告警机制 📊

程序部署上线后,我们需要一个完整的监控和告警机制,比如实现如下目的:

  • 我可以有数据可观察 最近2天的并发量情况怎么样
  • 当并发量大于某个值要通知我

这时我们需要:

  • 暴露当前程序的连接情况
  • 采集连接情况并且定制告警规则

在 Node.js 生态中,prom-client 是一款常用的用于创建和暴露监控指标的库,它与监控系统(如 Prometheus)配合良好,方便我们收集和展示应用的各项指标数据。

const prometheus = require('prom-client');
const counter = new prometheus.Counter({
  name: 'concurrent_requests',
  help: '当前并发请求数'
});

// 记录并发数
app.use((req, res, next) => {
  counter.inc();
  res.on('finish', () => counter.dec());
  next();
});

通过这个集成我们就将程序的连接数暴露到了 /metric 路径。下一步需要配置采集器来采集以及在 Prometheus 平台上观察和定制告警规则了。

3. 动态调整并发限制 🔄

我们前面设置的并发限制,比如10,100的, 都不太智能。虽然我们可以通过环境变量配合部署资源进行动态调整以面对未知的流量 Volume。但是有没有一种更智能的方式帮我判断限制该设置为几呢? 有!

动态并发限制则能够根据系统的实时负载状况,智能地调整并发请求的最大数量。在系统负载较轻时,适当提高并发限制,充分利用闲置资源,提高应用的处理能力

而当系统负载较重时,及时降低并发限制,避免资源过度竞争和耗尽,保障服务的基本响应能力和稳定性。

要获取系统当前负载能力我们需要用到 os 库,os.loadavg()方法返回一个包含 1 分钟、5 分钟和 15 分钟平均负载的数组:

[ 3.521484375, 3.57373046875, 3.6845703125 ]

这个值和CPU核心数有关,比如单核返回1 那就是满负载状况。以单核为例,我们对程序做一个动态调整并发数限制

const os = require('os');

function startMonitoring() {
  setInterval(() => {
    const load = os.loadavg()[0];
    if (load > 0.7) {
      requestQueue.maxConcurrent = Math.max(50, requestQueue.maxConcurrent - 10);
    } else if (load < 0.3) {
      requestQueue.maxConcurrent = Math.min(200, requestQueue.maxConcurrent + 10);
    }
  }, 60000);
}

const server = http.createServer((req, res) => {
  handleRequest(req, res);
});

server.listen(3000, () => {
  startMonitoring();
  console.log('Server listening on port 3000');
});

通过实时监测系统负载,并根据负载情况灵活调整并发请求的最大数量,我们可以避免资源浪费和服务故障。然而,实现这个动态并发限制机制需要综合我们的实际场景测试。本例中的 loadavg 给了我们一个很好的参考:

例如,一个 Node.js 应用可能会频繁地进行磁盘 I/O 操作(如读取或写入文件)或者网络 I/O 操作(如发送 HTTP 请求等待响应)。这些 I/O 操作可能会使进程进入等待状态,就会被计入系统负载。所以,即使 CPU 使用率不高,但如果有大量的进程在等待 I/O 完成,loadavg的值也可能会很高。

💝 实战建议

  1. 🎯 根据服务器配置合理设置并发数即对自己程序能力心里有数
  2. 🎯 考虑业务特点,可能需要针对不同API设置不同的限制,本文只设置了最大并发限制,并没有考虑公平性
  3. 🎯 定期监控并分析性能数据,线上不做瞎子
  4. 🎯 制定完善的降级方案

🌈 总结

通过合理的并发控制,我们可以:

  • 🌟 保护服务器不被过载
  • 🌟 提供更稳定的服务
  • 🌟 优化资源使用
  • 🌟 提升用户体验

与其等服务器被打垮再去救火,不如提前做好防护!希望这篇文章对大家有帮助!如果觉得有用的话,别忘了点赞关注哦~ 💖