Node.js 错误处理最佳实践:从崩溃到优雅降级

By | 2026年7月18日

引言

在 Node.js 开发中,错误处理往往是容易被忽视的环节。许多新手开发者习惯于用 try/catch 包裹所有代码,或者干脆忽略错误,导致生产环境出现未捕获异常时进程崩溃,用户体验极差。实际上,Node.js 的错误处理有一套成熟的模式,遵循这些最佳实践可以让你的应用在出现意外时优雅降级,而不是直接挂掉。

本文将从基础到进阶,带你掌握 Node.js 错误处理的完整体系。

1. 理解 Node.js 中的错误类型

Node.js 中的错误主要分为两类:编程错误(程序员 Bug)和 操作错误(运行时环境问题)。

  • 编程错误:例如语法错误、类型错误、引用空值等。这类错误应该通过修复代码来避免。
  • 操作错误:例如文件不存在、网络超时、数据库连接失败等。这类错误需要正确处理,以保证应用继续运行。

最佳实践:始终区分这两种错误,对操作错误进行优雅处理,对编程错误则尽快失败(fail-fast)并修复。

2. 同步代码的错误处理

最简单的错误处理是 try/catch,但要注意:try/catch 只能捕获同步代码中的异常。


// 同步错误处理示例
function parseJSON(str) {
    try {
        return JSON.parse(str);
    } catch (err) {
        // 记录错误日志
        console.error('JSON 解析失败:', err.message);
        // 返回默认值或抛出自定义错误
        return null;
    }
}

const result = parseJSON('invalid json');
console.log(result); // null

注意:不要滥用 try/catch 捕获所有错误。只捕获你预期会发生的操作错误,让编程错误暴露出来。

3. 异步代码的错误处理:回调、Promise 和 async/await

3.1 回调风格 (callback)

早期的 Node.js 使用回调函数处理异步,错误优先是约定俗成的模式。


const fs = require('fs');

fs.readFile('/path/to/file', (err, data) => {
    if (err) {
        // 处理错误
        console.error('读取文件失败:', err.message);
        return;
    }
    // 处理数据
    console.log(data.toString());
});

坏味道:忘记检查 err 参数,或者回调嵌套过深导致难以追踪错误。

3.2 Promise 链

Promise 提供了 .catch() 方法统一处理错误,但要注意:如果 Promise 链中没有 .catch(),错误会被吞掉。


// 好的实践:始终在链尾添加 .catch()
fetchData()
    .then(processData)
    .then(saveData)
    .catch(err => {
        console.error('操作失败:', err);
        // 可以在这里进行重试或回滚
    });

常见坑:在 .then() 内部抛出的错误如果没有被捕获,会导致未处理的 Promise rejection。

3.3 async/await 与 try/catch

async/await 让异步代码看起来像同步,错误处理也回归 try/catch


async function processUserData(userId) {
    try {
        const user = await fetchUser(userId);
        const orders = await fetchOrders(user.id);
        return { user, orders };
    } catch (err) {
        // 统一处理网络错误或数据库错误
        console.error('获取用户数据失败:', err);
        throw new AppError('USER_DATA_FETCH_FAILED', '无法获取用户数据');
    }
}

// 调用方也需要处理
async function main() {
    try {
        const data = await processUserData(123);
        console.log(data);
    } catch (err) {
        // 这里捕获到自定义错误
        console.error(err.message);
    }
}

最佳实践:在 async 函数内部使用 try/catch 捕获预期错误,并转换为自定义错误类型(如 AppError),方便上层统一处理。

4. 全局未捕获异常处理

即使我们尽力捕获所有错误,仍然可能出现未捕获的异常。Node.js 提供了两个全局事件:uncaughtExceptionunhandledRejection

4.1 处理未捕获的同步异常


process.on('uncaughtException', (err) => {
    console.error('未捕获的异常:', err);
    // 记录日志后,优雅关闭应用
    gracefulShutdown();
    // 注意:不推荐在这里恢复运行,因为应用可能处于不一致状态
});

警告uncaughtException 应该仅用于记录日志和清理资源,然后退出进程。不要试图让应用继续运行,否则可能导致内存泄漏或其他不可预知问题。

4.2 处理未处理的 Promise rejection


process.on('unhandledRejection', (reason, promise) => {
    console.error('未处理的 Promise rejection:', reason);
    // 同样,记录日志后退出
    gracefulShutdown();
});

注意:从 Node.js 15 开始,未处理的 Promise rejection 会直接导致进程退出。因此务必确保所有 Promise 都有 .catch() 或 await 包裹。

5. 构建多层错误处理中间件(Express 示例)

在 Web 应用中,我们可以通过中间件分层处理错误。

5.1 自定义错误类


class AppError extends Error {
    constructor(statusCode, message, isOperational = true) {
        super(message);
        this.statusCode = statusCode;
        this.isOperational = isOperational; // 区分操作错误和编程错误
        Error.captureStackTrace(this, this.constructor);
    }
}

5.2 异步错误包装器

避免在每个 async 路由中重复写 try/catch,可以使用包装器。


const asyncHandler = (fn) => (req, res, next) => {
    Promise.resolve(fn(req, res, next)).catch(next);
};

// 使用
app.get('/user/:id', asyncHandler(async (req, res) => {
    const user = await getUserById(req.params.id);
    if (!user) {
        throw new AppError(404, '用户未找到');
    }
    res.json(user);
}));

5.3 全局错误处理中间件


app.use((err, req, res, next) => {
    // 判断是否是自定义操作错误
    if (err.isOperational) {
        // 记录日志
        console.warn('操作错误:', err.message);
        return res.status(err.statusCode).json({
            error: err.message
        });
    }
    // 编程错误:记录详细日志,返回通用错误信息
    console.error('编程错误:', err);
    res.status(500).json({
        error: '服务器内部错误'
    });
});

优势:所有错误集中处理,代码干净,易于维护。

6. 日志记录与监控

错误处理的关键是记录足够的信息以便调试。推荐使用 winstonpino 等日志库。


const winston = require('winston');

const logger = winston.createLogger({
    level: 'error',
    format: winston.format.json(),
    transports: [
        new winston.transports.File({ filename: 'error.log' }),
        new winston.transports.Console({ format: winston.format.simple() })
    ]
});

// 在错误处理中使用
logger.error('错误详情', { error: err.message, stack: err.stack, requestId: req.id });

注意:不要将敏感信息(如密码、Token)记录到日志中。

7. 重试与降级策略

对于临时性操作错误(如网络超时),可以实现重试机制。


async function fetchWithRetry(url, retries = 3) {
    for (let i = 0; i < retries; i++) {
        try {
            const response = await fetch(url);
            return response;
        } catch (err) {
            if (i === retries - 1) throw err; // 最后一次失败则向上抛出
            console.warn(`重试第 ${i+1} 次`);
            await sleep(1000 * (i+1)); // 指数退避
        }
    }
}

降级:当依赖服务不可用时,返回缓存数据或默认值。

总结

  1. 区分错误类型:编程错误尽快修复,操作错误优雅处理。
  2. 统一错误处理:使用自定义错误类和全局中间件。
  3. 捕获所有异常:包括 Promise rejection 和未捕获异常,但仅用于记录和退出。
  4. 记录日志:详细记录错误上下文,但避免敏感信息。
  5. 考虑重试与降级:提高系统韧性。

下一步,你可以探索如何结合 APM(如 Sentry)进行实时错误监控,或者学习分布式系统中的错误传播模式。

延伸阅读