Skip to content

后端接口的错误处理与优雅退出:别让一个异常拖垮整个服务

更新: 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。

这类错误需要:

  1. 记录日志;
  2. 返回统一 500;
  3. 避免暴露内部堆栈;
  4. 必要时触发告警;
  5. 如果进程状态已经不可信,要优雅退出并重启。

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.js

3. 自定义业务错误类

业务错误不应该都用 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,都会进入统一错误中间件。

Express 同步与异步错误捕获

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
  });
});

这里有几个关键点:

  1. 对前端返回稳定结构

    • 前端可以统一处理 codemessagerequestId
  2. 不要把堆栈返回给用户

    • err.stack 应该写入日志,而不是暴露给客户端。
  3. 500 错误文案要模糊

    • 不要返回数据库表名、SQL、内部路径等敏感信息。
  4. 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'));

这类错误可能进入进程级事件:

  • uncaughtException
  • unhandledRejection

1. 为什么不能简单 catch 后继续跑?

如果出现未捕获异常,说明程序可能进入了未知状态。比如:

  • 某个全局对象被改坏;
  • 某个连接池状态异常;
  • 某个关键任务执行了一半;
  • 内存状态不可信。

因此更推荐的策略是:

记录日志,停止接收新请求,释放资源,然后退出进程,让进程管理器重启。

这就叫优雅退出。

2. 优雅退出要做什么?

优雅退出不是立刻 process.exit(1),而是按顺序收尾:

Node.js 进程级异常与优雅退出

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_PARAMS
  • UNAUTHORIZED
  • FORBIDDEN
  • NOT_FOUND
  • CONFLICT
  • RATE_LIMITED
  • INTERNAL_ERROR
  • SERVICE_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 的错误处理可以按层理解:

对前端开发者来说,理解这些机制有三个直接价值:

  1. 联调更高效

    • 知道哪些是业务错误,哪些是系统错误。
  2. 交互更准确

    • 能根据错误码设计不同提示,而不是统一“网络异常”。
  3. 协作更成熟

    • 能和后端讨论 requestId、幂等、重试、超时、降级,而不只关注字段格式。

最后记住一句话:

后端错误处理不是把异常藏起来,而是让每一种异常都有明确归宿:能恢复的返回给用户,不能恢复的记录下来、释放资源、退出重启。