PAYATHON 2026

Express/Node.js 服务器频繁出现 read ECONNRESET

排障赵师傅

结论

堆栈显示 MySQL TCP 连接已经失效,Express Router 只是触发了这次数据库查询。

ECONNRESET 表示连接被对端或中间网络设备重置;ETIMEDOUT 表示读取数据时长时间未收到响应。常见原因有:

  • MySQL/RDS 关闭了长期空闲的连接。
  • 本机休眠、Wi-Fi 切换、VPN 或网络中断,导致原有 TCP 连接失效。
  • RDS 重启、故障切换或短时负载过高。
  • 应用长期复用由 mysql.createConnection() 创建的单连接,连接断开后仍在同一个对象上执行查询。

错误中的 fatal: true 说明当前 MySQL 连接已无法继续使用,但整个 Express 进程不一定要退出。随后出现 PROTOCOL_ENQUEUE_AFTER_FATAL_ERROR,是因为代码仍在复用这个失效连接。

建议改用连接池。每次请求从池中获取可用连接,连接失效后再创建新连接。每次查询也要处理错误,避免错误从回调或 Promise 中漏出。

堆栈中为什么会出现 Router

调用顺序大致如下:

HTTP request
  -> Express Router
  -> routes/meProfileImageData.js
  -> Connection.query()
  -> TCP read
  -> ECONNRESET / ETIMEDOUT

express/lib/router 出现在堆栈中,说明数据库查询由某个路由触发。需要重点检查的是:

routes/meProfileImageData.js:10:12

这一行很可能使用了一个长期存在的全局连接,例如:

connection.query(sql, callback);

如果 connection 只在程序启动时创建一次,断开后没有替换,后续请求就会收到 PROTOCOL_ENQUEUE_AFTER_FATAL_ERROR。

改用连接池

下面的示例适用于使用回调的 mysql 包。连接配置应放在单独的数据库模块中,避免每个路由重复创建连接。

// db.js
const mysql = require('mysql');

const pool = mysql.createPool({
  host: process.env.DB_HOST,
  port: Number(process.env.DB_PORT || 3306),
  user: process.env.DB_USER,
  password: process.env.DB_PASSWORD,
  database: process.env.DB_NAME,

  connectionLimit: 10,
  waitForConnections: true,
  queueLimit: 0,
  connectTimeout: 10000
});

pool.on('connection', (connection) => {
  console.log('MySQL connection created:', connection.threadId);

  connection.on('error', (err) => {
    console.error('MySQL connection error', {
      threadId: connection.threadId,
      code: err.code,
      fatal: err.fatal,
      message: err.message
    });
  });
});

module.exports = pool;

在路由中直接调用 pool.query():

// routes/meProfileImageData.js
const express = require('express');
const pool = require('../db');

const router = express.Router();

router.get('/', (req, res, next) => {
  const sql = `
    SELECT id, image_url
    FROM profile_images
    WHERE user_id = ?
  `;

  pool.query(sql, [req.user.id], (err, rows) => {
    if (err) {
      console.error('Query failed', {
        route: req.originalUrl,
        code: err.code,
        fatal: err.fatal,
        message: err.message
      });

      return next(err);
    }

    res.json(rows);
  });
});

module.exports = router;

pool.query() 会临时获取一个连接,并在查询结束后自动归还。连接发生 fatal error 时,连接池会移除该连接。后续请求可以使用池中的其他连接,也可以创建新连接。

如果一个操作包含事务,或者多条 SQL 必须使用同一会话,则需要显式调用 pool.getConnection():

pool.getConnection((err, connection) => {
  if (err) {
    return next(err);
  }

  connection.query(
    'SELECT id, image_url FROM profile_images WHERE user_id = ?',
    [req.user.id],
    (queryError, rows) => {
      connection.release();

      if (queryError) {
        return next(queryError);
      }

      res.json(rows);
    }
  );
});

所有成功和失败路径都要释放连接。使用 pool.query() 时则不需要手动调用 release()。

添加 Express 错误处理中间件

查询失败后,应把错误传给 Express 错误处理中间件,并向客户端返回明确的失败响应:

app.use((err, req, res, next) => {
  console.error('Request failed', {
    method: req.method,
    url: req.originalUrl,
    code: err.code,
    fatal: err.fatal,
    message: err.message,
    stack: err.stack
  });

  const databaseUnavailable = [
    'ECONNRESET',
    'ETIMEDOUT',
    'PROTOCOL_CONNECTION_LOST',
    'PROTOCOL_ENQUEUE_AFTER_FATAL_ERROR'
  ].includes(err.code);

  res.status(databaseUnavailable ? 503 : 500).json({
    error: databaseUnavailable
      ? 'Database temporarily unavailable'
      : 'Internal server error'
  });
});

这里有三层错误处理。连接上的 error listener 负责记录连接级错误,以免遗漏后台断开;查询回调处理当前 SQL 的失败;Express 错误中间件负责结束当前 HTTP 请求。

只在 meProfileImageData.js 中添加 listener 还不够,因为其他路由也可能共享这个连接。连接级监听应放在创建连接池或连接对象的模块中,路由只需处理查询错误。

空闲连接超时是否会引发问题

有这种可能,但现有日志不足以证明原因就是 RDS 的空闲超时。

可以先检查数据库配置:

SHOW VARIABLES
WHERE Variable_name IN (
  'wait_timeout',
  'interactive_timeout',
  'max_connections'
);

再查看当前会话:

SHOW FULL PROCESSLIST;

wait_timeout 决定 MySQL 在非交互连接空闲多久后将其关闭。如果应用一直持有一个单连接,空闲时间超过该值后再次查询,就可能遇到连接断开。

RDS 控制面板显示 3 个 active connections,并不能直接证明存在连接泄漏。这些连接可能来自应用连接池、数据库管理工具或监控程序,需要结合 SHOW FULL PROCESSLIST 中的用户、来源地址、状态和空闲时间来判断。

如果 Node.js 服务运行在个人电脑上,也要检查本机是否在夜间休眠。电脑唤醒后,内存中的连接对象仍然存在,底层 TCP 会话却可能已经失效。连接池可以降低影响,同时还应记录断开时间,并与系统休眠、网络切换和 RDS 事件对照。

进一步定位问题

建议为每次数据库错误记录以下信息:

console.error({
  time: new Date().toISOString(),
  route: req.originalUrl,
  method: req.method,
  code: err.code,
  errno: err.errno,
  syscall: err.syscall,
  fatal: err.fatal,
  threadId: err.threadId,
  message: err.message
});

然后按错误发生时间检查:

  1. RDS 是否发生过重启、维护或故障切换。
  2. CloudWatch 中的连接数、CPU、内存和数据库负载是否异常。
  3. 本机是否休眠、断网,或者切换过 Wi-Fi 或 VPN。
  4. 错误发生前连接空闲了多久,是否接近 wait_timeout。
  5. 最近三个月是否将连接池改成了单连接,或者删除了查询错误处理。
  6. 是否有请求启动查询后未调用回调、未结束响应或未释放连接。

还可以在测试环境中暂时调低当前会话的空闲超时:

SET SESSION wait_timeout = 30;

让连接保持空闲超过 30 秒,再次执行查询,观察应用能否正确返回错误,以及后续请求能否取得新连接。不要为了实验直接修改生产环境的全局超时。

是否需要自动重试

连接池可以让后续查询恢复,但已经报错的那次查询仍会失败。普通只读查询确认属于短暂网络故障后,可以有限重试一次。

写操作不应盲目重试。连接断开时,客户端可能无法确定数据库是否已经执行并提交语句。直接重试可能造成重复插入、重复扣款或重复更新,因此写操作还需要幂等键、唯一约束或事务设计配合。

也不要通过 process.on('uncaughtException') 捕获错误后让进程长期运行。这个监听器适合记录日志并优雅退出,再由进程管理器重新启动服务。生产环境可以使用 systemd、PM2 或容器编排平台管理进程。nodemon 主要用于开发时监听文件变化,无法代替数据库连接管理。

备注:内容仅供参考。