Appearance
后端接口的错误处理与优雅退出:别让一个异常拖垮整个服务
更新: 8/8/2026 字数: 0 字 时长: 0 分钟
一、引言:前端报错和后端报错,严重程度不一样
前端开发者很熟悉这样的场景:
ts
try {
await submitForm();
} catch (err) {
showToast('提交失败,请稍后重试');
}在前端,一个组件报错,最坏可能是页面白屏、按钮失效、某个交互不可用。只要用户刷新页面,很多状态可以重新开始。
但后端不一样。服务端接口面对的是持续不断的请求:
text
用户 A 正在下单
用户 B 正在登录
用户 C 正在上传文件
用户 D 正在查询订单如果一个异常没有被正确处理,轻则这个请求返回 500,重则整个 Node.js 进程退出,所有请求都受影响。
所以后端错误处理的核心目标不是“让报错消失”,而是:
让错误被正确分类、被及时捕获、被稳定响应,并在不可恢复时安全退出。
对于 Node.js + Express 服务来说,这个能力尤其重要,因为 Node.js 通常以单进程事件循环处理大量请求。一个未捕获异常如果直接打穿进程,就可能让服务瞬间不可用。

二、核心概念:后端错误处理到底在处理什么?
1. 可预期错误:业务上允许发生
这类错误不是系统坏了,而是业务规则不允许继续执行。
例如:
- 参数缺失;
- 用户未登录;
- 权限不足;
- 商品不存在;
- 库存不足;
- 验证码错误;
- 请求过于频繁。
这类错误应该被转换成清晰的业务响应:
json
{
"code": "ORDER_STOCK_NOT_ENOUGH",
"message": "库存不足",
"requestId": "req_123"
}它们不应该打崩服务。
2. 非预期错误:代码、依赖或环境异常
这类错误通常说明系统出现了问题:
- 空指针;
- 数据库连接失败;
- Redis 超时;
- JSON 解析异常;
- 第三方服务返回异常;
- 程序逻辑 Bug;
- Promise 未处理 reject。
这类错误需要:
- 记录日志;
- 返回统一 500;
- 避免暴露内部堆栈;
- 必要时触发告警;
- 如果进程状态已经不可信,要优雅退出并重启。
3. 前端和后端错误处理的最大差异
前端更关注:
text
用户看到什么提示?
页面状态如何恢复?
按钮是否需要重置?后端更关注:
text
请求是否被安全终止?
数据是否已经写了一半?
资源是否释放?
日志是否可追踪?
进程是否还能继续服务?比如下单接口里,如果扣了库存但创建订单失败,这不是简单返回“失败”就完了,还要考虑数据一致性。后端错误处理往往和事务、幂等、重试、补偿机制绑定在一起。
三、Express 实操:从路由错误到优雅退出
下面用一个简洁可运行的 Express 示例讲清核心落地方式。
1. 初始化项目
bash
mkdir express-error-demo
cd express-error-demo
npm init -y
npm install express如果使用 Node.js 18+,可以直接运行以下代码。
2. 基础版服务
js
// app.js
const express = require('express');
const app = express();
app.use(express.json());
app.get('/health', (req, res) => {
res.json({ ok: true });
});
const server = app.listen(3000, () => {
console.log('Server listening on http://localhost:3000');
});运行:
bash
node app.js3. 自定义业务错误类
业务错误不应该都用 500。我们可以定义一个 AppError:
js
// app.js
class AppError extends Error {
constructor(message, statusCode = 500, code = 'INTERNAL_ERROR') {
super(message);
this.statusCode = statusCode;
this.code = code;
this.isOperational = true;
}
}其中:
statusCode:HTTP 状态码;code:给前端识别的业务错误码;isOperational:表示这是可预期错误。
4. 同步错误捕获
Express 可以自动捕获同步路由里抛出的错误。
js
app.get('/sync-error', (req, res) => {
throw new AppError('参数不合法', 400, 'INVALID_PARAMS');
});只要后面有错误中间件,这个错误就会被接住。
5. 异步错误捕获
在 Express 4 中,异步函数里的异常需要显式传给 next(err),否则容易出现未处理 Promise reject。
推荐写一个 asyncHandler:
js
function asyncHandler(fn) {
return function wrappedHandler(req, res, next) {
Promise.resolve(fn(req, res, next)).catch(next);
};
}然后这样使用:
js
app.get('/user/:id', asyncHandler(async (req, res) => {
const { id } = req.params;
if (id === '0') {
throw new AppError('用户不存在', 404, 'USER_NOT_FOUND');
}
const user = await fakeQueryUser(id);
res.json({
code: 'OK',
data: user
});
}));
function fakeQueryUser(id) {
return new Promise((resolve) => {
setTimeout(() => {
resolve({ id, name: 'Alice' });
}, 100);
});
}这样无论异步函数里是 throw,还是 Promise reject,都会进入统一错误中间件。

6. 全局错误中间件
Express 错误中间件有一个特点:它必须有 4 个参数。
js
app.use((err, req, res, next) => {
const statusCode = err.statusCode || 500;
const code = err.code || 'INTERNAL_ERROR';
const requestId = req.headers['x-request-id'] || `req_${Date.now()}`;
console.error('[ERROR]', {
requestId,
path: req.path,
method: req.method,
code,
message: err.message,
stack: err.stack
});
res.status(statusCode).json({
code,
message: statusCode >= 500 ? '服务暂时不可用,请稍后重试' : err.message,
requestId
});
});这里有几个关键点:
对前端返回稳定结构
- 前端可以统一处理
code、message、requestId。
- 前端可以统一处理
不要把堆栈返回给用户
err.stack应该写入日志,而不是暴露给客户端。
500 错误文案要模糊
- 不要返回数据库表名、SQL、内部路径等敏感信息。
requestId 很重要
- 前端反馈“这个接口报错了”时,可以带上 requestId,后端通过日志快速定位。
7. 完整 Express 示例
js
// app.js
const express = require('express');
const app = express();
app.use(express.json());
class AppError extends Error {
constructor(message, statusCode = 500, code = 'INTERNAL_ERROR') {
super(message);
this.statusCode = statusCode;
this.code = code;
this.isOperational = true;
}
}
function asyncHandler(fn) {
return function wrappedHandler(req, res, next) {
Promise.resolve(fn(req, res, next)).catch(next);
};
}
function fakeQueryUser(id) {
return new Promise((resolve) => {
setTimeout(() => {
resolve({ id, name: 'Alice' });
}, 100);
});
}
app.get('/health', (req, res) => {
res.json({ ok: true });
});
app.get('/sync-error', (req, res) => {
throw new AppError('参数不合法', 400, 'INVALID_PARAMS');
});
app.get('/user/:id', asyncHandler(async (req, res) => {
const { id } = req.params;
if (id === '0') {
throw new AppError('用户不存在', 404, 'USER_NOT_FOUND');
}
const user = await fakeQueryUser(id);
res.json({
code: 'OK',
data: user
});
}));
app.use((err, req, res, next) => {
const statusCode = err.statusCode || 500;
const code = err.code || 'INTERNAL_ERROR';
const requestId = req.headers['x-request-id'] || `req_${Date.now()}`;
console.error('[ERROR]', {
requestId,
path: req.path,
method: req.method,
code,
message: err.message,
stack: err.stack
});
res.status(statusCode).json({
code,
message: statusCode >= 500 ? '服务暂时不可用,请稍后重试' : err.message,
requestId
});
});
const server = app.listen(3000, () => {
console.log('Server listening on http://localhost:3000');
});测试:
bash
curl http://localhost:3000/health
curl http://localhost:3000/sync-error
curl http://localhost:3000/user/0
curl http://localhost:3000/user/123四、进程级异常处理与优雅退出
路由错误中间件能处理请求链路里的错误,但有些异常已经逃出了 Express。
比如:
js
setTimeout(() => {
throw new Error('定时器里的未知异常');
}, 1000);或者:
js
Promise.reject(new Error('未处理的 Promise reject'));这类错误可能进入进程级事件:
uncaughtExceptionunhandledRejection
1. 为什么不能简单 catch 后继续跑?
如果出现未捕获异常,说明程序可能进入了未知状态。比如:
- 某个全局对象被改坏;
- 某个连接池状态异常;
- 某个关键任务执行了一半;
- 内存状态不可信。
因此更推荐的策略是:
记录日志,停止接收新请求,释放资源,然后退出进程,让进程管理器重启。
这就叫优雅退出。
2. 优雅退出要做什么?
优雅退出不是立刻 process.exit(1),而是按顺序收尾:

3. 添加优雅退出代码
把下面代码接在 server 创建之后:
js
let isShuttingDown = false;
async function closeResources() {
// 示例:这里可以关闭真实项目中的资源
// await db.close();
// await redis.quit();
// await mq.close();
console.log('Resources closed');
}
async function gracefulShutdown(reason, exitCode = 0) {
if (isShuttingDown) return;
isShuttingDown = true;
console.log(`Graceful shutdown started: ${reason}`);
server.close(async () => {
console.log('HTTP server closed');
try {
await closeResources();
process.exit(exitCode);
} catch (err) {
console.error('Error while closing resources', err);
process.exit(1);
}
});
setTimeout(() => {
console.error('Force shutdown after timeout');
process.exit(1);
}, 10000).unref();
}
process.on('SIGTERM', () => {
gracefulShutdown('SIGTERM', 0);
});
process.on('SIGINT', () => {
gracefulShutdown('SIGINT', 0);
});
process.on('uncaughtException', (err) => {
console.error('uncaughtException', err);
gracefulShutdown('uncaughtException', 1);
});
process.on('unhandledRejection', (reason) => {
console.error('unhandledRejection', reason);
gracefulShutdown('unhandledRejection', 1);
});解释一下:
SIGTERM:常见于容器、部署平台要求进程退出;SIGINT:本地按Ctrl + C;server.close():停止接收新连接,并等待已有连接处理完成;setTimeout(...).unref():防止某些请求一直不结束,超过时间强制退出;exitCode = 1:表示异常退出,方便进程管理器感知并重启。
4. 健康检查也要配合退出
当服务准备退出时,健康检查应该返回失败,避免流量继续打进来。
js
app.get('/health', (req, res) => {
if (isShuttingDown) {
return res.status(503).json({ ok: false, status: 'shutting_down' });
}
res.json({ ok: true });
});这在容器部署、负载均衡、滚动发布中很重要。
五、技术拓展:从“能 catch”到“错误治理”
1. 错误码设计:让前端稳定处理异常
前端最怕后端错误返回不稳定:
json
{ "msg": "失败" }一会儿又变成:
json
{ "error": "invalid" }推荐统一格式:
json
{
"code": "USER_NOT_FOUND",
"message": "用户不存在",
"requestId": "req_123",
"details": {}
}前端可以基于 code 做明确处理:
ts
if (res.code === 'TOKEN_EXPIRED') {
redirectToLogin();
}
if (res.code === 'ORDER_STOCK_NOT_ENOUGH') {
showToast('库存不足,请重新选择');
}建议区分:
INVALID_PARAMSUNAUTHORIZEDFORBIDDENNOT_FOUNDCONFLICTRATE_LIMITEDINTERNAL_ERRORSERVICE_UNAVAILABLE
2. 全链路错误追踪:requestId 是排障入口
一次前端操作,后端可能经过:
如果没有统一追踪 ID,排查问题会非常痛苦。
推荐每个请求携带:
text
x-request-id后端日志统一打印它,前端错误上报也带上它。这样用户反馈问题时,可以快速定位整条链路。
3. 熔断与降级:不要让依赖故障拖垮主服务
假设订单接口依赖推荐服务。如果推荐服务挂了,是否应该让下单也失败?
多数情况下不应该。
这时可以降级:
熔断则像电路保险丝:当某个依赖连续失败,就暂时不再请求它,避免大量请求堆积拖垮整个系统。
4. 多进程与进程守护
Node.js 单进程如果退出,就需要有人把它拉起来。生产环境通常不会裸跑:
bash
node app.js而是使用:
- PM2;
- systemd;
- Docker / Kubernetes;
- Node.js cluster;
- 进程管理平台。
正确的思路是:
不要试图让一个已经进入未知状态的进程“硬撑着继续服务”。
5. 数据一致性:错误处理不只是返回 500
例如创建订单:
text
扣库存成功
创建订单失败如果没有事务或补偿机制,就会出现库存少了但订单不存在。
所以关键业务要考虑:
- 数据库事务;
- 幂等键;
- 重试策略;
- 补偿任务;
- 状态机流转;
- 操作日志。
这也是后端错误处理和前端错误处理最大的不同:后端异常可能已经改变了一部分真实业务状态。

六、总结:成熟的后端错误处理,是一套分层防线
Node.js + Express 的错误处理可以按层理解:
对前端开发者来说,理解这些机制有三个直接价值:
联调更高效
- 知道哪些是业务错误,哪些是系统错误。
交互更准确
- 能根据错误码设计不同提示,而不是统一“网络异常”。
协作更成熟
- 能和后端讨论 requestId、幂等、重试、超时、降级,而不只关注字段格式。
最后记住一句话:
后端错误处理不是把异常藏起来,而是让每一种异常都有明确归宿:能恢复的返回给用户,不能恢复的记录下来、释放资源、退出重启。