likes
comments
collection
share

nodejs 错误异常的捕获

作者站长头像
站长
· 阅读数 60

在 web 开发中,程序运行出现 bug 后都会把错误打印在浏览器的控制台上,其中,大部分的错误并不会导致 web 页面打不开,或者影响用户使用,所以很多前端开发人员对错误的捕获并不是很在意。

但是,如果用 nodejs 开发后台服务,就不能对错误不闻不问了,因为这直接会导致服务崩溃,用户访问不了应用。所以,对于开发后台服务就必须要对异常提前做预防处理,尽量保证在异常出现时,给用户一个友好的提示,不至于服务挂起导致请求超时,并且能将异常信息做记录上报,方便后期排查解决

同步与异步代码的错误捕获

先看一段同步代码:

try {
  throw new Error('这是一个错误')
} catch (e) {
  console.error(e.message) // 这是一个错误
}

可以看到控制台打印了这是一个错误,说明同步代码的错误被try/catch捕获了。

如果把同步代码改为异步代码会怎么样呢?

try {
  setTimeout(() => {
    throw new Error('这是一个错误')
  })
} catch (e) {
  console.error(e.message)
}

nodejs 错误异常的捕获

console.error(e.message)不会执行,即异步代码中的错误没有被捕获。

uncaughtException捕获错误

那异步错误该怎么处理呢?

可以这么理解,异常并不是事先准备好的,不能控制其到底在哪儿发生,所以需要站在更高的角度,如监听整个应用进程的错误异常,从而捕获不能预料的错误异常,保证应用不至于崩溃。

try {
  setTimeout(() => {
    throw new Error('这是一个错误')
  })
} catch (e) {
  console.error(e.message)
}

process.on('uncaughtException', e => {
  console.error('uncaughtException:', e.message)
})

nodejs 错误异常的捕获

可以看到在uncaughtException事件中就能捕获到这个错误了,它可以捕获到整个进程包含异步中的错误信息。

但是,uncaughtException 事件捕获错误存在两个重大问题。

第一,uncaughtException 错误会导致当前的所有的用户连接都被中断,甚至不能返回一个正常的 HTTP 错误码,用户只能等到浏览器超时才能看到一个no data received 错误。

这是由于uncaughtException 丢失了当前环境的上下文,无法拿到res,执行res.send来结束此次请求。最终,出错的用户只能等待浏览器超时,这是非常不友好的。

app.get('/', function (req, res) {
    setTimeout(function () {
        throw new Error('async error'); 
        // uncaughtException, 导致 res 的引用丢失
        res.send(200);
    }, 1000);
});

process.on('uncaughtException', function (err) {
    // 拿不到当前请求的 res 对象
    res.send(500); 
});

第二,uncaughtException 会影响整个进程的健康运转。当 Node 抛出uncaughtException 异常时就会丢失当前环境的堆栈,导致 Node 不能正常进行内存回收。也就是说,每一次 uncaughtException 都有可能导致内存泄露。

uncaughtException捕获错误是一种非常野蛮粗暴的异常处理机制,任何线上服务都不应该因为 uncaughtException 导致服务器崩溃。一个友好的错误处理机制应该满足三个条件:

  1. 对于引发异常的用户,返回 500 页面
  2. 其他用户不受影响,可以正常访问
  3. 不影响整个进程的正常运行

既然如此,退而求其次,我们可以在满足前两个条件的情况下退出进程,然后重启服务,这个时候就需要采用多进程部署。

domain模块捕获异步错误

为了解决这个问题,Node 0.8 之后的版本新增了 domain 模块,它可以用来捕获回调函数中抛出的异常。

domain 主要的 API 有 domain.run 和 error 事件。简单的说,通过 domain.run 执行的函数中引发的异常都可以通过 domain 的 error 事件捕获,例如:

var domain = require('domain');
var d = domain.create();
d.run(function () {
    setTimeout(function () {
        throw new Error('async error'); // 抛出一个异步异常
    }, 1000);
});

d.on('error', function (err) {
    console.log('catch err:', err); // 这里可以捕获异步异常
});

通过 domain 模块,可以很轻易的为引发异常的用户返回 500 页面。以 express 为例:

const express = require('express')
var app = express()
var domain = require('domain')

app.use(function (req, res, next) {
  var reqDomain = domain.create()
  reqDomain.on('error', function (err) {
    // 下面抛出的异常在这里被捕获,使用闭包,保留了对res的访问
    res.status(500).send(err.stack) // 成功给用户返回了 500
  })
  
  reqDomain.run(next)
})

app.get('/', function () {
  setTimeout(function () {
    throw new Error('async exception') // 抛出一个异步异常
  }, 1000)
})

app.listen('9000', () => {
  console.log('server run at 9000')
})

上面的代码将 domain 作为一个中间件来使用,保证之后 express 所有的中间件都在 domain.run函数内部执行。这些中间件内的异常都可以通过 error 事件来捕获。

domain.on('error')捕获到错误,同时给用户一个友好的提示,结束本次请求,这样错误就不会被uncaughtException事件捕获。

打印结果:

Error: async exception at Timeout._onTimeout  (/Users/pengchangjun/Documents/node/express-middleware/domain.js:39:11) at listOnTimeout (node:internal/timers:569:17) at process.processTimers (node:internal/timers:512:7)

尽管借助于闭包,我们可以拿到res对象,正常的给用户返回 500 错误,但是 domain 捕获到错误时依然会丢失堆栈信息,此时已经无法保证程序的健康运行,必须退出。因此,需要执行process.exit(1) 来结束进程。

另外,domain 有个最大的问题,它不能捕获所有的异步异常。也就是说,即使用了 domain,程序依然有因为 uncaughtException 崩溃的可能。

在官方文档上,domain 模块处于废弃状态,但是现在也没有其他方案可以完全代替domain模块

多进程模式 + domain + uncaughtException

如果在单进程单实例的部署下,杀掉进程后然后重启这一段时间内服务是不能访问的,这显然是不合理的。

因此,部署的时候需要采用多进程(cluster)的模式去部署应用,当某一个进程被异常捕获后,可以做一下打点上报后,开始重启释放内存,此时其他请求被接受后,其他进程依旧可以对外提供服务。

const cluster = require('cluster');
const os = require('os');
const http = require('http');
const domain = require('domain');

const d = domain.create();

if (cluster.isMaster) {
  const cpuNum = os.cpus().length;
  for (let i = 0; i < cpuNum; ++i) {
    cluster.fork()
  };
  // fork work log
  cluster.on('fork', worker=>{
    console.info(`${new Date()} worker${worker.process.pid}进程启动成功`);
  });
  // 监听异常退出进程,并重新fork
  cluster.on('exit',(worker,code,signal)=>{
    console.info(`${new Date()} worker${worker.process.pid}进程启动异常退出`);
    cluster.fork();
  })
} else {
  http.createServer((req, res)=>{
    d.add(res);
    d.on('error', (err) => {
      console.log('记录的err信息', err.message);
      console.log('出错的 work id:', process.pid);
      // uploadError(err)  // 上报错误信息至监控
      res.end('服务器异常, 请稍后再试');
      // 将异常子进程杀死
      cluster.worker.kill(process.pid);
    });
    d.run(handle.bind(null, req, res));
  }).listen(8080);
}

function handle(req, res) {
  if (process.pid % 2 === 0) {
    throw new Error(`出错了`);
  }
  res.end(`response by worker: ${process.pid}`);
};

express 中异常的处理

使用 express 时记住一定不要在 controller 的异步回调中抛出异常,例如:

app.get('/', function (req, res, next) {
    mysql.query('SELECT * FROM users', function (err, results) {
        // 不要这样做
        if (err) throw err;

        // 应该将 err 传递给 errorHandler 处理
        if (err) return next(err);
    });
});

app.use(function (err, req, res, next) {
 // 带有四个参数的 middleware 专门用来处理异常
    res.render(500, err.stack);
});

总结

nodejs 在异步错误的捕获是无能为力的,对于大部分的一部错误可以使用domain模块来处理,但是它并不能捕获所有的一部错误,因此,还需要在整个应用进程的层面上对错误进行兜底,但是uncaughtException事件捕获会导致整个服务无法接受新的请求而崩溃。

所以,最后采用多进程+domain+uncaughtException的方式来进行错误的捕获。

转载自:https://juejin.cn/post/7249991926307799077
评论
请登录