错误处理与调试

What — 是什么

错误处理与调试是 Node.js 生产级应用的核心能力,涵盖错误分类、异步错误捕获、全局错误兜底、调试工具和性能分析,确保应用在异常情况下优雅降级而非崩溃。

核心概念:

  • 错误类型Error(通用)、TypeError(类型错误)、RangeError(范围错误)、SyntaxError(语法错误)、ReferenceError(引用错误)、SystemError(系统调用错误)
  • 异步错误捕获:回调中的错误无法被 try/catch 捕获,需用 async/await + try/catch.catch()
  • 堆栈跟踪Error.stack 包含调用链信息,Error.captureStackTrace() 可自定义
  • debugger:Node.js 内置调试器,node inspect app.js 或通过 Chrome DevTools Protocol
  • 性能分析perf_hooks 模块监控事件循环延迟、HTTP 延迟、GC 情况
  • heapdump:堆内存快照,用于定位内存泄漏

关键特性:

  • process.on('uncaughtException') 捕获未处理的同步异常(最后防线,之后应退出)
  • process.on('unhandledRejection') 捕获未处理的 Promise 拒绝
  • error.code 是 Node.js 系统错误的字符串标识(如 ENOENT/ECONNREFUSED
  • V8--stack-trace-limit 控制堆栈深度(默认 10)
  • node --inspect 启用远程调试协议

Why — 为什么

适用场景:

  • 生产环境错误兜底:防止未捕获异常导致进程崩溃
  • 异步代码调试:追踪 Promise 链和 async/await 中的错误来源
  • 内存泄漏排查:通过 heapdump 定位持续增长的对象
  • 性能瓶颈分析:监控事件循环延迟和 CPU 占用

对比错误处理方式:

维度try/catch.catch()uncaughtException错误中间件
同步错误支持不支持兜底支持
异步回调不支持不支持兜底支持
Promise不支持支持兜底支持
async/await支持不支持兜底支持
适用层级函数级Promise 链级进程级框架级

优缺点:

  • ✅ 优点:
    • async/await + try/catch 是最直觉的错误处理方式
    • 全局兜底机制防止进程静默崩溃
    • Chrome DevTools 远程调试体验好
    • heapdump 可离线分析内存问题
  • ❌ 缺点:
    • 异步回调中的错误容易被遗漏
    • uncaughtException 后进程状态不可靠
    • 多层 try/catch 使代码冗余
    • 生产环境调试工具不如开发环境丰富

How — 怎么用

快速上手

// async/await 错误处理
async function fetchUser(id) {
    try {
        const user = await db.users.findById(id);
        if (!user) throw new NotFoundError('User not found');
        return user;
    } catch (err) {
        if (err instanceof NotFoundError) {
            throw err; // 重新抛出业务错误
        }
        throw new AppError('Database error', { cause: err });
    }
}

// 全局兜底
process.on('uncaughtException', (err) => {
    console.error('Uncaught Exception:', err);
    process.exit(1); // 必须退出,进程状态不可靠
});

process.on('unhandledRejection', (reason, promise) => {
    console.error('Unhandled Rejection:', reason);
    process.exit(1);
});

代码示例

自定义错误类 + 错误中间件:

// 自定义错误类
class AppError extends Error {
    constructor(message, statusCode = 500, options = {}) {
        super(message, options);
        this.name = this.constructor.name;
        this.statusCode = statusCode;
        this.isOperational = options.isOperational ?? true; // 可预期的业务错误
        Error.captureStackTrace(this, this.constructor);
    }
}

class NotFoundError extends AppError {
    constructor(resource) {
        super(`${resource} not found`, 404, { isOperational: true });
    }
}

class ValidationError extends AppError {
    constructor(details) {
        super('Validation failed', 400, { isOperational: true });
        this.details = details;
    }
}

class AuthError extends AppError {
    constructor(message = 'Unauthorized') {
        super(message, 401, { isOperational: true });
    }
}

// Express 错误中间件(必须 4 个参数)
app.use((err, req, res, next) => {
    // 已知的业务错误
    if (err.isOperational) {
        return res.status(err.statusCode).json({
            error: {
                code: err.name,
                message: err.message,
                ...(err.details && { details: err.details })
            }
        });
    }

    // 未知错误:记录日志,返回通用 500
    logger.error('Unexpected error', { err, req });
    res.status(500).json({
        error: { code: 'INTERNAL_ERROR', message: 'Internal server error' }
    });
});

// 包装 async 路由处理器
function asyncHandler(fn) {
    return (req, res, next) => Promise.resolve(fn(req, res, next)).catch(next);
}

app.get('/users/:id', asyncHandler(async (req, res) => {
    const user = await fetchUser(req.params.id);
    res.json(user);
}));

调试与性能分析:

const { monitorEventLoopDelay, performance } = require('perf_hooks');

// 监控事件循环延迟
const histogram = monitorEventLoopDelay({ resolution: 20 });
histogram.enable();

setInterval(() => {
    console.log('事件循环延迟:', {
        min: histogram.min,
        max: histogram.max,
        mean: histogram.mean,
        p99: histogram.percentile(99),
        p999: histogram.percentile(99.9)
    });
    histogram.reset();
}, 60000);

// HTTP 请求计时
const httpTimer = performance.timerify(async function handleRequest(req, res) {
    await processRequest(req, res);
});

// 性能观察器
const obs = new PerformanceObserver((list) => {
    const entries = list.getEntries();
    for (const entry of entries) {
        console.log(`${entry.name}: ${entry.duration.toFixed(2)}ms`);
    }
});
obs.observe({ type: 'function', buffered: true });

// heapdump 生成(需安装 heapdump 包或使用 v8 模块)
const v8 = require('v8');
function takeHeapSnapshot() {
    const stream = v8.getHeapSnapshot();
    const fileName = `heap-${Date.now()}.heapsnapshot`;
    const fs = require('fs');
    stream.pipe(fs.createWriteStream(fileName));
    console.log(`Heap snapshot: ${fileName}`);
}

// 信号触发 heapdump
process.on('SIGUSR2', takeHeapSnapshot);

// 内存泄漏检测
setInterval(() => {
    const usage = process.memoryUsage();
    if (usage.heapUsed > 500 * 1024 * 1024) { // 超过 500MB
        console.warn('内存使用过高:', usage);
        takeHeapSnapshot();
    }
}, 30000);

常见问题与踩坑

问题原因解决方案
try/catch 捕获不到异步错误setTimeout/Promise 回调不在 try 块内用 async/await,或 .catch() 处理 Promise
uncaughtException 后继续运行进程状态可能已损坏(文件句柄泄漏等)记录日志后 process.exit(1),由 PM2 重启
错误堆栈不完整--stack-trace-limit 默认 10 层启动参数 --stack-trace-limit=50
系统错误缺少有用信息SystemError 的 code/message 不够明确检查 err.code(如 ENOENT)和 err.path
heapdump 文件太大堆内存本身过大用 Chrome DevTools 对比两个快照找增长
调试时断点不生效代码被转译/打包确保 source map 正确,用 --enable-source-maps

最佳实践

  • 所有 async 函数用 try/catch 包裹或用 .catch() 处理
  • 自定义错误类区分业务错误(isOperational)和系统错误
  • Express 使用 asyncHandler 包装,避免遗漏 async 错误
  • uncaughtException 只做日志 + 退出,不做业务恢复
  • 生产环境监控 unhandledRejection,Node.js 15+ 默认导致进程退出
  • 使用 --enable-source-maps 让编译后代码的错误堆栈指向源码
  • 内存超阈值自动生成 heapdump 供离线分析

面试题

Q1: Node.js 中有哪些错误类型?

6 种内置错误类型:① Error——通用错误基类;② TypeError——值不是预期类型(如 undefined.foo);③ RangeError——值超出范围(如栈溢出、数组越界);④ SyntaxError——语法错误(通常在解析阶段);⑤ ReferenceError——引用未声明的变量;⑥ SystemError——Node.js 系统调用失败(如文件不存在 ENOENT、连接拒绝 ECONNREFUSED),有额外的 code/errno/syscall/path 属性。

Q2: 为什么 try/catch 捕获不到异步回调中的错误?

try/catch 只捕获同一执行上下文(调用栈帧)中的异常。异步回调(setTimeout/Promise.then)在新的调用栈帧中执行,此时 try 块已经退出。例如 try { setTimeout(() => throw new Error(), 0) } catch(e) {} 永远捕获不到,因为 throw 发生在 setTimeout 的回调栈中。解决方案:① 使用 async/await——await 暂停当前函数,错误在同一个 try 块中被捕获;② 对 Promise 链使用 .catch();③ 监听 unhandledRejection 兜底。

Q3: uncaughtException 和 unhandledRejection 有什么区别?处理原则是什么?

uncaughtException:同步代码中未被 try/catch 捕获的异常冒泡到事件循环时触发。unhandledRejection:Promise 被 reject 但没有 .catch()try/catch 处理时触发。处理原则:两者都是最后防线,触发后进程状态不可靠(可能有未完成的 I/O、文件句柄泄漏),应记录错误日志后 process.exit(1),由进程管理器(PM2/Systemd)重启。不建议在回调中继续处理请求。Node.js 15+ 中 unhandledRejection 默认导致进程退出。

Q4: 如何在 Express 中统一处理异步路由错误?

两种方式:① asyncHandler 包装函数——const asyncHandler = fn => (req, res, next) => Promise.resolve(fn(req, res, next)).catch(next),每个 async 路由用 app.get('/path', asyncHandler(async (req, res) => {...})) 包装;② Express 5.0 原生支持——路由处理器返回 rejected Promise 时自动调用 next(err)。无论哪种方式,错误最终由 4 参数错误中间件统一处理。

Q5: 如何调试 Node.js 应用?有哪些工具?

调试方式:① node inspect app.js——内置 CLI 调试器,支持断点/单步/变量查看;② node --inspect app.js——启用 Chrome DevTools Protocol,在 chrome://inspect 中打开 DevTools 调试,支持断点/调用栈/内存分析;③ VS Code 调试——配置 launch.json,断点调试体验最好;④ console.log + debug 包——简单有效,debug 库按命名空间开关日志;⑤ ndb——Node.js 调试增强工具(已归档);⑥ node --prof + --prof-process——CPU 性能分析。

Q6: 如何排查 Node.js 内存泄漏?

排查步骤:① 监控 process.memoryUsage(),确认 heapUsed 持续增长;② 使用 v8.getHeapSnapshot() 生成堆快照;③ 在不同时间点生成两个快照;④ 用 Chrome DevTools 的 Memory 面板加载两个快照,选择 “Comparison” 视图;⑤ 找到增长的构造函数和保留路径(Retainers);⑥ 根据保留路径定位代码中持有引用的对象。常见泄漏源:全局变量、闭包引用、未移除的事件监听器、未关闭的数据库连接、缓存无限增长。

Q7: Error.captureStackTrace 的作用是什么?

Error.captureStackTrace(targetObject, constructorOpt) 在目标对象上创建 .stack 属性,记录当前调用栈。constructorOpt 参数指定截断点——该函数及其以上的调用帧不会出现在堆栈中。自定义错误类中使用:Error.captureStackTrace(this, MyError),这样堆栈从 MyError 的调用者开始,不包含 MyError 构造函数本身,使堆栈更清晰。Node.js 内置错误类默认调用此方法。

Q8: 生产环境错误处理最佳实践?

① 分层处理:函数级 try/catch → 框架级错误中间件 → 进程级 uncaughtException/unhandledRejection;② 区分错误类型:业务错误(可操作,返回 4xx)vs 系统错误(不可操作,返回 5xx);③ 错误日志包含上下文(请求ID/用户ID/参数),用结构化日志(pino/winston);④ 不向客户端暴露内部错误详情(堆栈/SQL/文件路径);⑤ 设置 --enable-source-maps 让编译后代码的错误堆栈指向 TypeScript 源码;⑥ 监控错误率和错误类型,设置告警阈值。


相关链接: