Ngăn chặn Crash: Khắc phục lỗi Node.js [ERR_UNHANDLED_ERROR]

intermediate💚 Node.js2026-07-27| Node.js (Mọi phiên bản), Linux/macOS/Windows, CommonJS và ES Modules.

Error Message

Error [ERR_UNHANDLED_ERROR]: Unhandled error.
#nodejs#eventemitter#backend#javascript#debugging

Bối cảnh

Tuần trước, một background worker trong môi trường production của chúng tôi đã dừng hoạt động đột ngột mà không có cảnh báo. Các bản log không cung cấp nhiều thông tin, chỉ có một dòng duy nhất gây ức chế: Error [ERR_UNHANDLED_ERROR]: Unhandled error.

Trong Node.js, sự kiện 'error' là một trường hợp đặc biệt. Khi một đối tượng kế thừa từ EventEmitter—như net socket, stream hoặc các class tùy chỉnh—nó kỳ vọng sẽ có ai đó đang lắng nghe. Nếu bạn phát ra (emit) một lỗi và không có listener nào được đăng ký, Node.js sẽ kích hoạt hành vi mặc định của nó. Nó sẽ ném ra một ngoại lệ, in ra stack trace và chấm dứt tiến trình với mã thoát (exit code) là 1.

Cơ chế fail-fast này giúp ngăn chặn các lỗi bị bỏ qua một cách âm thầm. Tuy nhiên, nó có thể là một cơn ác mộng khi chỉ một trường hợp biên (edge case) không được xử lý cũng có thể làm sập toàn bộ dịch vụ API hoặc worker của bạn.

Quy trình Debug

Việc tái hiện lỗi này rất đơn giản. Bạn chỉ cần kích hoạt một lỗi trên một emitter thiếu trình xử lý .on('error'). Đây là một đoạn script 5 dòng sẽ làm sập terminal của bạn ngay lập tức:

const EventEmitter = require('events');
const myEmitter = new EventEmitter();

// Điều này gây ra crash vì không có listener nào tồn tại
myEmitter.emit('error', new Error('Database connection failed!'));

Khi tôi tìm kiếm lỗi này trong một codebase lớn, tôi tập trung vào ba khu vực cụ thể:

  • Emitter tùy chỉnh: Bất kỳ class nào mở rộng từ EventEmitter mà phát ra lỗi trong các tác vụ bất đồng bộ.
  • File Stream: Các chuỗi pipe nơi nguồn dữ liệu bị lỗi, chẳng hạn như fs.createReadStream cố gắng mở một tệp .env không tồn tại.
  • Kết nối Socket: Các yêu cầu mạng bị hết thời gian chờ (timeout) hoặc gặp lỗi reset nhưng thiếu trình xử lý lỗi tổng quát.

Nếu stack trace quá ngắn để có thể hữu ích, hãy thử chạy ứng dụng của bạn với cờ --trace-uncaught. Nó cung cấp cái nhìn chi tiết hơn về nơi emitter được khởi tạo lần đầu tiên.

Các giải pháp để khắc phục ERR_UNHANDLED_ERROR

1. Thêm một Listener 'error' rõ ràng

Tuyến phòng thủ đầu tiên của bạn là đảm bảo mọi emitter đều có một listener. Thậm chí một trình ghi log đơn giản cũng có thể ngăn tiến trình thoát đột ngột. Nó giữ cho ứng dụng tiếp tục chạy trong khi bạn điều tra nguyên nhân gốc rễ.

const EventEmitter = require('events');
const myEmitter = new EventEmitter();

// Xử lý sự kiện error để ngăn chặn crash
myEmitter.on('error', (err) => {
  console.error('Caught the error properly:', err.message);
});

myEmitter.emit('error', new Error('Something went wrong'));
console.log('The process is still running!');

2. Xử lý lỗi trong Stream

Stream thường xuyên gặp lỗi này. Nhiều nhà phát triển lầm tưởng rằng .pipe() sẽ chuyển tiếp lỗi xuống các mắt xích tiếp theo, nhưng thực tế không phải vậy. Nếu nguồn dữ liệu thất bại, toàn bộ tiến trình sẽ dừng lại. Kể từ Node.js 10.0.0, giải pháp tốt nhất là sử dụng stream.pipeline.

const fs = require('fs');
const { pipeline } = require('stream');

// pipeline xử lý việc dọn dẹp và hợp nhất việc xử lý lỗi
pipeline(
  fs.createReadStream('missing-file.txt'),
  fs.createWriteStream('output.txt'),
  (err) => {
    if (err) {
      console.error('Pipeline failed gracefully:', err.message);
    }
  }
);

3. Sử dụng tùy chọn 'captureRejections'

Node.js 12.6.0 đã giới thiệu một cách để thu hẹp khoảng cách giữa Promise và event. Bằng cách thiết lập captureRejections: true, bạn có thể sử dụng các hàm async làm listener mà không lo lắng về việc các rejection không được xử lý làm sập emitter.

const EventEmitter = require('events');
const myEmitter = new EventEmitter({ captureRejections: true });

myEmitter.on('event', async (value) => {
  throw new Error('Async failure');
});

// Rejection sẽ tự động được chuyển đổi thành sự kiện 'error'
myEmitter.on('error', (err) => {
  console.log('Captured async error:', err.message);
});

myEmitter.emit('event');

4. Sử dụng events.once với try-catch

Nếu bạn thích cú pháp async/await hơn là callback, hãy sử dụng events.once. Công cụ tiện ích này bao bọc một sự kiện trong một Promise. Chỉ cần nhớ rằng nếu emitter phát ra 'error' trong khi bạn đang chờ đợi, Promise sẽ bị reject.

const { once, EventEmitter } = require('events');

async function run() {
  const ee = new EventEmitter();
  
  try {
    const promise = once(ee, 'finish');
    ee.emit('error', new Error('Instant fail'));
    await promise;
  } catch (err) {
    console.error('Caught via try-catch:', err.message);
  }
}

run();

Các bước xác minh

Để xác nhận bản sửa lỗi, tôi sử dụng quy trình xác minh ba bước:

  • Mô phỏng lỗi: Kích hoạt lỗi thủ công bằng cách cung cấp đường dẫn tệp sai hoặc một địa chỉ 127.0.0.1 không đang lắng nghe.
  • Kiểm tra Exit Code: Chạy script trong terminal và chạy ngay lệnh echo $?. Mã 0 nghĩa là thành công; mã 1 nghĩa là nó vẫn bị crash.
  • Kiểm tra Log: Xác minh rằng trình ghi log của bạn (như Pino hoặc Winston) đã ghi lại toàn bộ stack trace thay vì chỉ một thông báo lỗi chung chung.

Những bài học kinh nghiệm

  • Kỷ luật về listener: Nếu bạn xây dựng một class sử dụng EventEmitter, hãy tài liệu hóa rằng người dùng phải xử lý sự kiện 'error'.
  • Ưu tiên pipeline: Sử dụng stream.pipeline thay vì .pipe() cho hầu hết các thao tác stream trong môi trường production. Nó an toàn và sạch sẽ hơn.
  • Ghi log tập trung: Luôn ghi lại toàn bộ đối tượng lỗi. Việc mất stack trace trong môi trường production khiến việc debug khó khăn gấp 10 lần.
  • Tránh các trình xử lý toàn cục: process.on('uncaughtException') chỉ là một giải pháp tạm thời. Nó có thể để lại ứng dụng của bạn trong trạng thái bị lỗi dữ liệu. Thay vào đó, hãy sửa lỗi tại chính emitter cụ thể đó.

Related Error Notes