首页 / 文章 / 20种可避免生产环境服务器宕机的Node.js模式

20种可避免生产环境服务器宕机的Node.js模式

学习20种实用的Node.js模式——从错误处理到优雅关闭及连接池管理——这些方法能在不得不重启之前避免程序崩溃。

4280 词

服务器停机几乎从来都不是由某一起灾难性事件引发的。

通常,触发因素是一些微不足道、不起眼的小问题。

与数据库的连接始终无法返回到连接池中。

内存使用量逐渐上升,直到进程达到上限。

原本只需200毫秒就能响应的某个接口现在需要5秒钟才能处理。

部署操作切断了那些仍然处于活跃状态的连接。

某个异步任务失败了,却没人发现这个错误。

突然的流量激增迫使每个传入的请求都不得不发起另一次成本高昂的数据库查询。

起初,这些情况看起来都并不令人担忧。

接着响应时间开始变慢。

然后请求开始超时。

最终整个进程彻底崩溃。

就在这时,各种问题开始出现:

“为什么服务器会停机?”

令人沮丧的是,这些情况大多本是可以避免的。

在许多生产系统中,有一组反复出现的 Node.js 解决方案一直被认为非常有效,原因正是它们针对根本问题进行处理,而不仅仅是让服务器在崩溃后重新启动。

本文将介绍其中最重要的几种解决方案。

1. 避免让一个异常请求导致整个进程崩溃

在 Node.js 代码库中,最危险的习惯之一就是让抛出的异常或未处理的承诺拒绝一直传递到进程本身。

以这个例子为例:

app.get("/users/:id", async (req, res) => {
  const user = await User.findByPk(req.params.id);

  res.json(user);
});

乍看之下似乎没什么问题。

但如果在请求过程中数据库连接断开会怎样?

或者 ORM 抛出错误呢?

又或者某个意外的输入值破坏了处理函数中的逻辑呢?

如果没有一致的错误处理机制,进程可能会陷入故障状态;根据具体的错误类型以及运行时配置的不同,甚至可能完全停止运行。

更稳健的做法是明确地处理错误,而不是让它们自行扩散:

app.get("/users/:id", async (req, res, next) => {
  try {
    const user = await User.findByPk(req.params.id);

    res.json(user);
  } catch (error) {
    next(error);
  }
});

之后将所有错误引导至一个集中的处理函数中:

app.use((err, req, res, next) => {
  console.error(err);

  res.status(500).json({
    error: "Internal server error"
  });
});

这并非为了掩盖问题。

而是要明确决定错误最终会流向何处。

这样,整个应用就能遵循统一且可预测的处理流程,而无需每个路由都自行临时想办法应对故障。

2. 不要将进程重启视为解决方案

当 Node.js 进程出现故障时,人们通常会本能地选择:

npm start

对于使用 PM2 的团队来说,则是:

pm2 restart app

这样就能让服务尽快恢复在线。

它并未解决导致故障的根本原因。

如果由于内存泄漏,您的应用每六小时就崩溃一次,按固定时间重新启动它并不能构成稳定的系统——这只是权宜之计。

像 PM2、Docker、Kubernetes 或托管平台运行时这样的工具能够自动重启崩溃的进程,这确实非常有用。

以 PM2 为例:

pm2 start app.js --name api
pm2 save

但自动重启应被视为应急措施,而非解决根本问题的办法。

您仍然需要回答以下问题:

Why did the process crash?
What request triggered it?
How much memory was being used?
What error occurred?
Was there an unhandled rejection?
Was the database unavailable?

重启可以恢复为用户提供的服务。

只有具备完善的可观测性,才能了解服务最初为何中断。

这两者都是必不可少的。

3. 在内存溢出崩溃发生之前修复内存泄漏

Node.js 依赖垃圾回收机制,但这并不能保证内存会自动被正确清理。

你的代码很可能会继续保留那些实际上已不再需要的对象的引用。

以下是一个简单的示例:

const cache = [];


app.get("/users", async (req, res) => {
  const users = await getUsers();


  cache.push(users);

  res.json(users);
});

每个传入的请求都会向 cache 中添加更多数据。

由于应用程序仍持有这些条目的引用,垃圾回收器就无法回收它们。

随着时间推移,情况会变成这样:

Memory usage
100 MB
200 MB
400 MB
800 MB
1.2 GB
2 GB
💥

具体数值会因你的配置及设定的限制而有所不同,但核心模式才是关键。

需注意以下问题:

  • 不断增长的全局数组
  • 没有大小限制的缓存
  • 仍保留在内存中的大型对象
  • 从未被移除的事件监听器
  • 从未被清除的定时器
  • 未关闭的流
  • 闭包中保留的大量数据
  • 没有上限的队列
  • 如果您的应用程序在内存中缓存数据,那么该缓存需要明确的策略。

    例如:

    Maximum entries: 10,000
    TTL: 10 minutes
    Eviction: LRU
    

    没有限制的缓存实际上并不能发挥缓存的作用。

    这只会引发内存问题。

    4. 为外部请求设置超时时间

    在调用其他服务时不设置超时时间是导致API挂起的最快方式之一。

    想象一下这样的调用:

    const response = await fetch("https://external-service.com/api");
    

    如果那个外部服务突然变得响应极慢会怎样?

    您自己的请求可能会被迫等待比预期长得多的时间。

    现在把这种情况扩展到1,000名同时访问同一端点的用户。

    突然之间,就会有成百上千个请求因为依赖服务未响应而一直处于等待状态。

    这些积压的任务最终也可能让你的服务器不堪重负。

    务必实现明确的超时处理机制。

    例如,可以使用 AbortController:

    const controller = new AbortController();
    
    const timeout = setTimeout(() => {
      controller.abort();
    }, 5000);
    
    try {
      const response = await fetch(
        "https://external-service.com/api",
        {
          signal: controller.signal
        }
      );
    
      return await response.json();
    } finally {
      clearTimeout(timeout);
    }
    

    这样你的应用就不会无限期地等待下去。

    基本原则很简单:

    绝不能让外部依赖决定服务器的等待时间,且不得设置任何限制。

    无论你是与支付网关、邮件发送服务、普通HTTP接口、Redis、自定义数据库层、第三方API,还是基础设施中的其他内部微服务交互,这一点都同样重要。

    5. 解决数据库连接耗尽问题

    虽然Node.js API本身看起来运行正常,但真正的瓶颈可能在于数据库连接层。

    想象一下您的应用程序面临以下情况:

    500 concurrent requests
    

    每一个请求都试图建立自己的全新数据库连接。

    最终,数据库会达到预设的连接上限。

    从那以后,新的请求就会直接失败。

    API 可能会返回如下错误:

    Too many connections
    Connection refused
    Connection timeout
    

    连接池的存在正是为了防止每个传入的请求都自行创建新的连接。

    从视觉上来看,它大致如下所示:

    ┌── Connection 1
                  ├── Connection 2
    API ── Pool ──┼── Connection 3 ── Database
                  ├── Connection 4
                  └── Connection 5
    

    连接池充当守门人,限制任何时刻可以向数据库打开的连接数量。

    但正确设置连接池的大小与拥有连接池本身同样重要。

    谨慎配置连接池。

    设置得过小:

    Requests
       ↓
    Waiting for connections
       ↓
    High latency
    

    设置得过大:

    Application
       ↓
    Too many connections
       ↓
    Database overloaded
    

    合适的数值取决于数据库实际能处理的负载量、应用程序的并发程度、查询语句的结构以及基础设施的部署方式。

    无论采取何种措施,都不要通过无限制地不断提高最大连接池大小来应对连接错误。

    6. 消除N+1查询

    团队们常常无意中给数据库带来巨大压力的另一种方式就是N+1查询模式。

    想象一个返回帖子列表的接口:

    const posts = await Post.findAll();
    
    
    for (const post of posts) {
      post.author = await User.findByPk(post.authorId);
    }
    

    当表中包含1,000篇帖子时,情况就会变成这样:

    1 query → retrieve posts
    
    1,000 queries → retrieve authors
    
    Total: 1,001 queries
    

    当只有10篇帖子时,该接口的响应速度很快,没人会察觉到异常。

    随后数据量逐渐增加。

    同一个接口的响应速度就会变得极其缓慢。

    最终它可能会占用过多的数据库资源,进而影响其他无关的接口性能。

    一些常见的解决方案:

    JOIN操作

    SELECT
      posts.id,
      posts.title,
      users.name
    FROM posts
    JOIN users
      ON users.id = posts.author_id;
    

    ORM预加载功能

    大多数ORM都提供了在单次可控操作中一次性获取相关记录的功能,而无需为每行数据单独发起查询。

    批量查询

    不必一次发送一个请求,而是:

    Get user 1
    Get user 2
    Get user 3
    ...
    

    通过一个联合查询获取所有需要的用户数据。

    在负载测试时,值得提出一个简单的诊断问题:

    当该接口返回10,000条记录时,会发起多少次独立的数据库查询?

    如果真实的答案是:

    "10,001次。」

    那就说明确实存在需要修复的问题。

    7. 在数据库数据量过大之前添加分页功能

    这段代码简直就是定时炸弹:

    const users = await User.findAll();
    
    res.json(users);
    

    在开发环境中它的表现非常好,此时该表中存储的内容为:

    37 users
    

    六个月后,该表中的内容变成了:

    2,400,000 users
    

    那个未作更改的接口现在可能会:

    • 从数据库中读取数百万行数据
    • 消耗大量数据库资源
    • 占用Node.js进程的大量内存
    • 序列化庞大的JSON数据
    • 耗尽带宽
    • 大幅增加响应时间
    • 存在超时风险

    解决办法是采用分页机制。

    例如,接收如下格式的请求:

    GET /users?page=1&limit=50
    

    并按以下方式处理它:

    const limit = Math.min(
      Number(req.query.limit) || 50,
      100
    );
    
    const page = Number(req.query.page) || 1;
    
    const offset = (page - 1) * limit;
    

    同样重要的是要限制客户端可请求的数据量上限。

    不要让客户端发送:

    ?limit=100000000
    

    并迫使API尝试执行如此大规模的查询。

    8. 停止顺序执行独立的操作

    假设某个接口需要收集三份数据:

    User
    Settings
    Notifications
    

    一种简单的实现方式可能如下:

    const user = await getUser();
    const settings = await getSettings();
    const notifications = await getNotifications();
    

    如果每次调用需要200毫秒,依次等待的话总耗时大约为:

    200 + 200 + 200 = 600 ms
    

    当这些调用之间不存在依赖关系时,应改为同时执行:

    const [
      user,
      settings,
      notifications
    ] = await Promise.all([
      getUser(),
      getSettings(),
      getNotifications()
    ]);
    

    这样三个请求就会同时发起。

    总耗时会接近其中最慢的单次调用时间:

    max(200, 200, 200) ≈ 200 ms
    

    这不仅仅是为了减少响应的毫秒数。

    在大规模应用中,每次请求额外的延迟意味着服务器资源会被占用更长时间。

    完成得越快的请求,释放资源的速度也会越快。

    尽管如此,也并非在所有情况下都应直接使用Promise.all。

    如果某个调用确实需要另一个调用的结果,例如:

    const user = await getUser();
    const orders = await getOrders(user.id);
    

    那么这些调用本质上必须按顺序执行。

    9. 添加优雅关闭功能

    在部署过程中造成不必要的停机时间的最简单方法之一就是突然终止进程。

    考虑以下流程:

    User → POST /checkout
                  ↓
           Processing payment
                  ↓
             Deployment starts
                  ↓
              Process killed
    

    在这种情况下,用户可能只会看到:

    Connection reset
    

    尽管底层操作可能已经部分完成。

    优雅关闭功能通过让服务器停止接受新连接,同时允许正在处理的请求自然完成来解决这个问题。

    最简版本如下:

    const server = app.listen(PORT);
    
    process.on("SIGTERM", () => {
      console.log("SIGTERM received");
    
    server.close(() => {
        console.log("HTTP server closed");
    
        process.exit(0);
      });
    });
    

    在生产系统中,优雅关闭通常还意味着要关闭:

    • 数据库连接池
    • Redis 连接
    • 消息队列消费者
  • 后台工作进程
  • 已打开的 WebSocket 连接
  • 其他所有占用的资源
  • 总体目标可以概括为:

    Stop receiving new work
            ↓
    Finish current work
            ↓
    Close resources
            ↓
    Exit
    

    部署过程不应让人感觉像是有人拔掉了电源线。

    10. 使用健康状态与就绪性检查

    即使其背后的应用程序已实际停止运行,Node.js 进程仍有可能处于存活状态。

    想象一下这种状况:

    Node.js process → Running
    Database → DOWN
    API → Practically unusable
    

    如果负载均衡器的唯一判断标准是 TCP 端口是否有响应,它可能会持续将流量路由到无法提供有效服务的实例。

    就绪性端点则能为你提供可实际检查的信号:

    app.get("/ready", async (req, res) => {
      try {
        await db.query("SELECT 1");
    
       res.status(200).json({
          status: "ready"
        });
      } catch (error) {
        res.status(503).json({
          status: "not_ready"
        });
      }
    });
    

    这能让你的基础设施区分两种截然不同的状况:

    Process is alive
    

    与……相比:

    Process is ready to serve traffic
    

    在部署新代码或从故障中恢复时,这种差异非常重要。

    11. 保护服务器免受流量激增的影响

    假设您的服务能够轻松处理:

    100 requests/second
    

    那么您的产品就会被分享到拥有大量用户的平台上。

    突然之间,您将面临:

    2,000 requests/second
    

    如果没有相应的防护措施,这种流量激增可能会让 API 完全不堪重负。

    一些常见的应对流量激增的方法:

    • 限制客户端在指定时间窗口内可发起的请求数量
    • 在应用程序服务器前部署反向代理
    • 将进入的流量分配到多个实例上
    • 缓存响应以避免重复处理相同任务
    • 通过 CDN 提供静态内容
    • 将任务放入队列中处理,而非立即执行
  • 限制允许的同时数据库连接数量
  • 随着需求增长自动扩展实例数量
  • 拦截处理时间过长的请求
  • 并非必须具备列表中的所有功能。

    但任何生产系统都应能明确回答以下问题:

    如果流量突然增加十倍会怎样?

    12. 将耗时操作移出HTTP请求

    以一个在同一个请求中创建用户并发送欢迎邮件的接口为例:

    await createUser();
    await sendWelcomeEmail();
    res.json({ success: true });
    

    如果邮件服务需要三秒才能响应,用户就必须等待这三秒。

    更糟糕的是,邮件发送环节出现故障会导致整个请求失败,即便用户账户已成功创建。

    基于队列的设计可以避免这种耦合:

    HTTP Request
         ↓
    Create User
         ↓
    Return response
         ↓
    Queue email job
         ↓
    Background Worker
         ↓
    Send Email
    

    API不再需要等待耗时的后台任务完成。

    这种模式在以下场景中尤为有用:

    • 发送确认邮件或欢迎邮件
    • 按需生成报告
    • 调整上传图片的大小或格式
    • 对视频进行编码或转码
    • 推送通知
    • 生成批量数据导出文件
    • 处理传入的webhook数据
    • 执行复杂的数值计算或分析运算

    通常,HTTP请求处理程序只需执行生成响应所需的最少工作量。

    13. 确保后台任务能够安全重试

    将任务放入队列会引出一个新的问题:

    如果处理任务的工作进程在运行过程中出现故障会怎样?

    请考虑以下流程:

    Worker receives payment notification
            ↓
    Starts processing
            ↓
    Crashes
    

    队列系统很可能会自动重试该任务。

    这通常是一件好事。

    但如果该任务不具备幂等性,重试就会再次引发相同的副作用。

    例如:

    Send payment
    Send payment again
    

    或者:

    Create order
    Create duplicate order
    

    正因如此,后台任务需要具备以下功能:

    • 重试机制
    • 退避策略
    • 死信处理
    • 幂等性
    • 最大重试次数限制
    • 监控功能

    如果没有幂等性,重试机制可能会将一次故障转化为多个不必要的副作用。

    14. 停止记录敏感信息

    在生产环境中排查故障时,人们往往会倾向于记录所有能获取的信息。

    一个常见的例子是:

    console.log(req.body);
    

    该请求体很可能包含以下内容:

    Password
    Access token
    Credit-card information
    Personal information
    

    一旦出现这种情况,您的日志系统就会存储敏感数据。

    更安全的方法是仅记录实际需要的字段:

    logger.info({
      userId,
      requestId,
      endpoint: req.path
    }, "Login attempt");
    

    不要陷入这样的误区:

    “这些数据只存在于日志中。”

    日志通常会保存数周、数月甚至更长时间,而且还常常会被复制到以下地方:

    • 云日志系统
    • 监控平台
    • SIEM系统
    • 备份存储
    • 开发者控制面板

    调试生产环境中的问题不应引发新的安全事件。

    15. 在需要之前就添加请求ID

    当生产环境出现故障时,问题往往涉及多个系统:

    Client
      ↓
    Nginx
      ↓
    Node.js API
      ↓
    Redis
      ↓
    Database
      ↓
    Payment API
    

    如果没有办法将这些事件关联起来,重建事件经过的过程将会十分缓慢且繁琐。

    解决办法是在早期生成一个请求ID:

    request_id = 7f4e9c21
    

    然后将其附加到每一条相关的日志记录或响应中:

    API log
    Database-related log
    Payment log
    Error response
    

    例如:

    {
      "requestId": "7f4e9c21",
      "method": "POST",
      "path": "/orders",
      "status": 500
    }
    

    有了这个机制,就可以追踪某个请求在系统中的完整流转过程。

    因此当客户反馈:

    “我的订单失败了。”

    你只需询问他们的请求ID,而无需在数百万条日志中逐条查找时间戳进行匹配。

    16. 防止重复请求生成重复数据

    网络故障随时可能发生,客户端需要具备处理这些情况的能力。

    想象以下流程:

    Client
      ↓
    POST /orders
      ↓
    Server creates order
      ↓
    Response lost
    

    客户端无法得知请求是否真正成功。

    因此,它自然会重新尝试:

    POST /orders
      ↓
    Server creates another order
    

    结果如下:

    Order #1001 → original request
    Order #1002 → retry
    

    这正是幂等键所要解决的场景。

    客户端会附加类似这样的请求头:

    Idempotency-Key: 5e7b2d91-...
    

    服务器会记住与该键相关的处理结果。如果再次出现相同的键,它只会返回之前的结果,而不会创建新的资源。

    这对于以下类型的操作尤为重要:

    Payments
    Orders
    Subscriptions
    Account creation
    Resource provisioning
    

    任何生产环境中的 API 都应基于“**客户端会重新发送请求**”这一假设进行设计,有时甚至会多次重试。

    17. 当数据需要保持一致性时使用事务

    想象这样一个流程:

    Create order
    Decrease inventory
    Create payment record
    

    现在假设这些步骤中的某一步在执行过程中失败了:

    Create order → SUCCESS
    Decrease inventory → SUCCESS
    Create payment record → FAIL
    

    您的数据库中现在存在一个未完成的操作。

    每当有多个数据库写入操作需要作为一个整体成功执行时,应将其封装在事务中。

    其原理大致如下:

    await db.transaction(async (transaction) => {
      const order = await createOrder(transaction);
    
      await decreaseInventory(transaction);
    
      await createPaymentRecord(transaction);
    });
    

    如果其中任何一步出现错误:

    Rollback
    

    所有操作都会被回滚,而不会留下不一致的记录。

    事务虽不能解决分布式系统中遇到的所有问题,但在单个数据库中,它们是保持数据一致性的最可靠工具之一。

    18. 别让一个慢查询拖垮整个数据库

    一个编写不当的查询可能会占用过大的数据库资源。

    请看这个例子:

    SELECT *
    FROM orders
    WHERE customer_id = ?
    ORDER BY created_at DESC;
    

    如果customer_id没有索引,且表中包含数百万行数据,此查询可能会迫使数据库执行远超必要的操作。

    请使用

    EXPLAIN
    

    来检查那些重要的查询。

    注意以下警示信号:

    Full table scan
    Huge number of rows examined
    Unexpected joins
    Poor index usage
    Expensive sorting
    

    然后根据实际数据情况来制定优化方案。

    不要依赖猜测。

    在本地环境中50毫秒就能返回结果的查询,在处理大规模生产数据时可能需要数秒时间。

    19. 设置资源限制

    生产服务器需要明确的界限。

    如果没有这些限制,一个恶意的或格式错误的请求,或是某个失控的进程,都可能耗尽服务器的所有可用资源。

    考虑对以下方面设置限制:

    Request body size
    File upload size
    Database connections
    HTTP request duration
    Cache size
    Queue size
    Worker concurrency
    Pagination size
    Memory usage
    

    例如:

    app.use(express.json({
      limit: "1mb"
    }));
    

    以及:

    const limit = Math.min(
      Number(req.query.limit) || 50,
      100
    );
    

    并不存在一种适用于所有情况的通用数值。

    但基本原则始终适用。

    每个资源都应设有合理的上限。

    在生产环境中,让某些指标无限制地增长几乎从来都不是明智之举。

    20. 在问题发生前进行监控,而非事后

    你无法防止所有可能发生的故障。

    你能控制的是发现故障的速度以及恢复的速度。

    至少要关注以下内容:

    HTTP 5xx rate
    Request latency
    CPU
    Memory
    Database connections
    Database latency
    Event-loop health
    Queue depth
    External API failures
    Restart count
    

    例如:

    09:00 → 0.2% errors
    09:10 → 0.3%
    09:20 → 0.5%
    09:30 → 4.8% 🚨
    

    如果没人监控这些数值,往往要等到用户开始投诉才会得知问题存在。

    完善的警报机制能让情况从如下状态改变:

    "客户告诉我们API已宕机。"

    变为:

    “我们的5xx错误率在五分钟前超过了阈值。”

    响应时间上的这种差异确实会产生很大影响。

    这些解决方案背后的共同规律

    回顾所有这些解决方案,可以发现一个共同的脉络。

    可靠的Node.js系统并非基于一切都会顺利进行的假设来运行。

    相反,它们的设计是基于以下前提:

    Requests will fail.
    Databases will become slow.
    Networks will timeout.
    Clients will retry.
    Dependencies will go offline.
    Traffic will spike.
    Deployments will restart processes.
    Developers will make mistakes.
    

    并且会在每个这样的前提下设置相应的防护措施。

    由此形成的架构通常看起来像这样:

                        ┌───────────────┐
                        │     Client    │
                        └───────┬───────┘
                                │
                                ▼
                        ┌───────────────┐
                        │ Reverse Proxy │
                        │ Rate Limiting │
                        └───────┬───────┘
                                │
                                ▼
                      ┌───────────────────┐
                      │    Node.js API    │
                      │                   │
                      │ Validation        │
                      │ Timeouts          │
                      │ Error Handling    │
                      │ Request IDs       │
                      └───────┬───────────┘
                              │
                 ┌────────────┼────────────┐
                 ▼            ▼            ▼
            ┌────────┐   ┌────────┐   ┌─────────┐
            │Database│   │ Redis  │   │ Queue   │
            └────────┘   └────────┘   └────┬────┘
                                          │
                                          ▼
                                   ┌──────────────┐
                                   │Worker / Jobs │
                                   └──────────────┘
    

    每一层都有明确的功能职责。

    每一个外部依赖都有既定的故障处理策略。

    每一项高成本操作都受到一定的限制。

    每一个有意义的操作都可以被观察和衡量。

    正是这种组合让系统具备了韧性。

    最重要的教训

    可靠性的最大提升并不总是来自增加更多基础设施。

    有时解决方案仅仅是修改一行代码。

    有时是添加缺失的索引。

    有时是将外部 API 调用封装在超时处理中。

    有时是将 1,001 次数据库查询合并为两次。

    有时是将一系列写操作封装在事务中。

    有时只是确保进程能够正常关闭。

    一个脆弱 API 与一个可靠 API 的区别往往在于一系列细微的工程决策。

    单独来看,这些决策似乎并无特别之处。

    但综合起来,它们才能避免生产环境中的普通日常操作演变成紧急状况。

    所以下次你的 Node.js API 在开发环境中表现完全如预期时,不要只停留在问这个问题:

    “它能正常工作吗?”

    而应该问:

    “如果它不能正常工作会怎样?”

    这个问题才是确保生产环境可靠性的真正起点。

    最理想的停机情况是用户根本察觉不到,因为你已在故障演变成全面中断之前就发现了并解决了它。