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
});
然后按错误发生时间检查:
- RDS 是否发生过重启、维护或故障切换。
- CloudWatch 中的连接数、CPU、内存和数据库负载是否异常。
- 本机是否休眠、断网,或者切换过 Wi-Fi 或 VPN。
- 错误发生前连接空闲了多久,是否接近
wait_timeout。 - 最近三个月是否将连接池改成了单连接,或者删除了查询错误处理。
- 是否有请求启动查询后未调用回调、未结束响应或未释放连接。
还可以在测试环境中暂时调低当前会话的空闲超时:
SET SESSION wait_timeout = 30;
让连接保持空闲超过 30 秒,再次执行查询,观察应用能否正确返回错误,以及后续请求能否取得新连接。不要为了实验直接修改生产环境的全局超时。
是否需要自动重试
连接池可以让后续查询恢复,但已经报错的那次查询仍会失败。普通只读查询确认属于短暂网络故障后,可以有限重试一次。
写操作不应盲目重试。连接断开时,客户端可能无法确定数据库是否已经执行并提交语句。直接重试可能造成重复插入、重复扣款或重复更新,因此写操作还需要幂等键、唯一约束或事务设计配合。
也不要通过 process.on('uncaughtException') 捕获错误后让进程长期运行。这个监听器适合记录日志并优雅退出,再由进程管理器重新启动服务。生产环境可以使用 systemd、PM2 或容器编排平台管理进程。nodemon 主要用于开发时监听文件变化,无法代替数据库连接管理。
备注:内容仅供参考。
