Đừng để Event Loop bị chặn: Mở rộng Node.js với BullMQ và Redis

Programming tutorial - IT technology blog
Programming tutorial - IT technology blog

Cuộc gọi đánh thức lúc 2:14 sáng

PagerDuty của tôi không chỉ kêu bíp; nó như đang thét lên. Khi tôi dụi mắt và mở bảng điều khiển Grafana, thời gian phản hồi API đã vượt mức cho phép. Bình thường trung bình là 200ms, nhưng giờ chúng tôi thấy những đỉnh nhọn lên tới 8.5 giây. Người dùng nhấn ‘Đăng ký’, chờ đợi mòn mỏi, và cuối cùng nhận được lỗi 504 Gateway Timeout. Đáng ngạc nhiên là CPU của cơ sở dữ liệu chỉ ở mức 12% và mức sử dụng bộ nhớ vẫn ổn định. Hệ thống không bị sập—nó chỉ bị kẹt.

Phân tích log cho thấy một nút thắt cổ chai đơn giản nhưng chết người. Một đợt tăng trưởng marketing đột ngột đã làm tốc độ đăng ký tăng gấp ba lần.

Mỗi lượt đăng ký mới kích hoạt một loạt các tác vụ nặng: tạo bộ tài liệu chào mừng PDF tùy chỉnh 5MB, thay đổi kích thước ảnh đại diện thành bốn kích cỡ khác nhau, và gọi đến một máy chủ SMTP bên thứ ba chậm chạp. Vì Node.js là đơn luồng (single-threaded), các tác vụ nặng về CPU này đã chiếm dụng event loop. Mọi người dùng khác, ngay cả những người chỉ đang tải một trang hồ sơ đơn giản, đều phải xếp hàng chờ đợi sau trình tạo PDF.

Event Loop không phải là một công cụ đa năng

Chúng ta thường quên rằng Node.js rất giỏi về I/O nhưng lại gặp khó khăn với các tính toán nặng. Khi bạn ép một web server xử lý hình ảnh hoặc chờ đợi một API bên ngoài chậm chạp trước khi gửi phản hồi, bạn đang bắt kết nối của người dùng làm con tin. Bạn không chỉ làm chậm yêu cầu đó; bạn đang ngăn cản event loop tiếp nhận bất kỳ yêu cầu mới nào.

Trong kịch bản thảm họa của chúng tôi, lệnh gọi await mailer.send(...) mất từ 3 đến 5 giây cho mỗi yêu cầu. Vì mã nguồn phải đợi lệnh này hoàn tất trước khi trả về 201 Created, toàn bộ quy trình đã bị đình trệ. Chúng tôi đã cố gắng thực hiện các tác vụ nặng theo kiểu đồng bộ trong một môi trường vốn được thiết kế để chuyển đổi bất đồng bộ cực nhanh.

Chọn đúng công cụ cho công việc

Mục tiêu rất đơn giản: xác nhận yêu cầu của người dùng ngay lập tức và xử lý các công việc nặng ở nơi khác. Tôi đã cân nhắc ba chiến lược phổ biến:

  • setTimeout hoặc setImmediate: Đây là cách sửa lỗi “nhanh nhưng ẩu”. Mặc dù nó đẩy tác vụ sang chu kỳ (tick) tiếp theo, nhưng nó rất nguy hiểm. Nếu máy chủ của bạn khởi động lại hoặc bị sập, các tác vụ đang chờ đó sẽ biến mất. Không có logic thử lại (retry), không có giám sát và không có lưới an toàn.
  • RabbitMQ: Một trình môi giới tin nhắn (message broker) mạnh mẽ, cấp doanh nghiệp. Tuy mạnh, nhưng nó có cảm giác hơi quá mức cần thiết (overkill) cho hệ thống của chúng tôi. Nó yêu cầu nhiều mã mẫu (boilerplate) và phải tìm hiểu sâu về giao thức AMQP chỉ để gửi một email cơ bản.
  • BullMQ với Redis: Đây là lựa chọn chiến thắng. BullMQ tận dụng Redis để xử lý hàng đợi tin nhắn, thử lại và lưu trữ công việc (job persistence). Vì Redis đã có sẵn trong hệ thống của chúng tôi để làm cache, chúng tôi có thể triển khai trong vài phút mà không cần thêm hạ tầng mới.

Kiến trúc với BullMQ

BullMQ hoạt động theo mô hình Producer-Consumer. API của bạn đóng vai trò là Producer, chuyển giao một “job” cho hàng đợi được hỗ trợ bởi Redis. Một tiến trình Worker riêng biệt—Consumer—sẽ tiếp nhận nó bất cứ khi nào có khả năng. Nếu một worker thất bại, job đó không bị mất; nó vẫn nằm trong Redis để được thử lại dựa trên các quy tắc cụ thể của bạn. Tôi thấy thiết lập này cực kỳ vững chắc để xử lý hàng triệu job mỗi tháng.

Chuẩn bị môi trường

Khởi chạy một instance Redis là bước đầu tiên. Nếu bạn sử dụng Docker, chỉ cần một câu lệnh để có môi trường sẵn sàng cho sản xuất:

docker run -d -p 6379:6379 redis:alpine

Sau đó, cài đặt các thư viện cần thiết vào dự án của bạn:

npm install bullmq ioredis

Bước 1: Định nghĩa Producer

Công việc duy nhất của Producer là đưa một tin nhắn vào hàng đợi và rút lui ngay lập tức. Điều này giúp các route API của bạn luôn nhanh chóng.

import { Queue } from 'bullmq';
import Redis from 'ioredis';

const connection = new Redis({ host: 'localhost', port: 6379 });
const emailQueue = new Queue('email-tasks', { connection });

async function addWelcomeEmailJob(userData) {
  // Giảm tải công việc và phản hồi ngay lập tức
  await emailQueue.add('send-welcome-email', {
    email: userData.email,
    name: userData.name,
  }, {
    attempts: 3,
    backoff: {
      type: 'exponential',
      delay: 5000, // Chờ 5 giây, sau đó 10 giây, rồi 20 giây nếu thất bại
    },
  });
}

Bước 2: Xây dựng Worker

Worker là một tiến trình riêng biệt. Bạn thậm chí có thể chạy nó trên một instance riêng, rẻ hơn để giữ tài nguyên của máy chủ API chính tập trung vào việc xử lý lưu lượng truy cập.

import { Worker } from 'bullmq';
import Redis from 'ioredis';

const connection = new Redis({ host: 'localhost', port: 6379 });

const worker = new Worker('email-tasks', async (job) => {
  if (job.name === 'send-welcome-email') {
    const { email, name } = job.data;
    
    // Mô phỏng tác vụ nặng như tạo PDF hoặc gọi SMTP
    await new Promise((resolve) => setTimeout(resolve, 2000));
    console.log(`Thành công: Bộ tài liệu chào mừng đã được gửi đến ${email}`);
  }
}, { connection });

worker.on('failed', (job, err) => {
  console.error(`Job ${job.id} thất bại: ${err.message}`);
});

Tác động thực tế

Sau khi chuyển logic email và hình ảnh sang BullMQ, kết quả khác biệt rõ rệt. Thời gian phản hồi của endpoint đăng ký đã giảm mạnh từ 4.2 giây xuống còn 45ms mượt mà. Người dùng nhận được xác nhận ngay lập tức, trong khi các công việc “nặng” diễn ra ở chế độ nền. Nếu nhà cung cấp SMTP của chúng tôi ngừng hoạt động trong mười phút, BullMQ chỉ đơn giản là tạm dừng và thử lại sau. Không có dữ liệu nào bị mất và không có người dùng nào cảm thấy khó chịu.

Các tính năng nâng cao

Khi đã nắm vững các kiến thức cơ bản, bạn có thể khai thác các khả năng nâng cao hơn của BullMQ:

  • Delayed Jobs: Lên lịch gửi email “Check-in” chính xác 48 giờ sau khi người dùng tham gia.
  • Priority Levels: Đảm bảo các job “Đặt lại mật khẩu” được ưu tiên lên đầu hàng đợi, ngay cả khi có 10.000 job “Bản tin” đang chờ.
  • Concurrency Control: Tinh chỉnh các worker để xử lý 10 hoặc 20 job cùng lúc, tối đa hóa việc sử dụng CPU mà không làm quá tải hệ thống.

Xây dựng sự bền bỉ

Chuyển sang các worker chạy nền thay đổi cách bạn giám sát ứng dụng. Vì lỗi không còn xảy ra bên trong chu kỳ yêu cầu/phản hồi, bạn sẽ không thấy chúng trong log API thông thường. Tôi thực sự khuyên bạn nên cài đặt BullBoard. Đó là một bảng điều khiển nhỏ giúp bạn có cái nhìn tổng quan trực quan về các hàng đợi, cho phép bạn thử lại các job thất bại một cách thủ công chỉ với một cú nhấp chuột.

API của bạn nên hoạt động như một người điều phối giao thông tinh gọn, chứ không phải một công nhân nhà máy. Bằng cách ủy thác các công việc nặng nhọc cho BullMQ và Redis, bạn đảm bảo ứng dụng của mình luôn phản hồi nhanh nhạy cho dù bạn có mười hay mười nghìn người dùng.

Share: