Khi bạn xây dựng ứng dụng với Node.js, việc xử lý lỗi ban đầu có vẻ cực kỳ dễ dàng. Bạn bao bọc một route trong khối try-catch, gửi mã trạng thái 500, và phía client sẽ quyết định bước tiếp theo. Mô hình đó hoạt động tốt với HTTP, nhưng nó sẽ đổ vỡ ngay khi bạn chuyển sang các tác vụ chạy ngầm (background jobs). Trong một hệ thống hàng đợi (queue system), không có client nào đang chờ đợi. Chỉ có một worker, một payload, và một bộ đếm thử lại (retry counter) đang tăng dần ở đâu đó trong Redis, RabbitMQ, hoặc SQS. Nếu bạn xử lý lỗi giống như cách bạn xử lý các yêu cầu web thất bại, bạn sẽ không chỉ làm mất một giao dịch. Bạn sẽ làm đình trệ toàn bộ quy trình (pipeline), tiêu tốn tài nguyên tính toán, hoặc làm sập các worker liên tục vì cùng một "tin nhắn độc hại" (poisoned message).

Tư duy HTTP sẽ thất bại trong các tác vụ chạy ngầm

Trong một chu kỳ yêu cầu-phản hồi (request-response cycle), vòng lặp phản hồi diễn ra ngay lập tức. Người dùng nhấn một nút, máy chủ ném ra một lỗi, và người dùng thấy màn hình báo lỗi. Việc dọn dẹp (cleanup) rất đơn giản. Một queue worker hoạt động độc lập. Nó lấy một công việc (job), xử lý trong vài giây hoặc vài phút, rồi xác nhận thành công (acknowledge success). Nếu có gì đó trục trặc ở giữa quá trình, hàng đợi sẽ không biết lý do tại sao. Nó chỉ biết rằng tín hiệu xác nhận chưa bao giờ được gửi đến. Tùy thuộc vào cấu hình, nó sẽ thử lại, có thể là mãi mãi. Một payload sai định dạng duy nhất có thể nhảy qua lại giữa các worker hàng trăm lần, gây lãng phí CPU và ẩn mình sau các công việc hợp lệ thực sự cần được xử lý.

Hai loại lỗi

Quy tắc đầu tiên của các hàng đợi có khả năng phục hồi (resilient queues) là ngừng xử lý mọi lỗi theo cùng một cách. Bạn cần phân loại các lỗi thành hai nhóm ngay khi chúng vừa xảy ra.

Lỗi có thể thử lại (Retryable failures) là lỗi tạm thời (transient). Hãy nghĩ đến việc hết thời gian chờ mạng (network timeout), giới hạn tốc độ (rate limit) từ một API bên thứ ba, hoặc kết nối cơ sở dữ liệu bị reset do pool bị cạn kiệt tạm thời. Đây là những triệu chứng của một hệ thống đang hoạt động dưới áp lực. Chúng có thể thành công trong lần thử tiếp theo sau hai phút nữa.

Lỗi vĩnh viễn (Permanent failures) là những "viên thuốc độc" (poison pills). Chúng bao gồm các payload sai định dạng, lỗi xác thực schema, hoặc thiếu một trường bắt buộc do một dịch vụ thượng nguồn (upstream service) đã thay đổi hợp đồng (contract) của họ. Việc thử lại những lỗi này là hoàn toàn lãng phí. Chúng sẽ thất bại y hệt như vậy ở lần thử thứ một trăm.

Nếu khối catch của bạn không thể phân biệt được hai loại này, hàng đợi của bạn đang hoạt động trong tình trạng "mù quáng".

Pattern 1: Phân loại lỗi tại khối Catch

Khối catch của worker nên là phần mã được tính toán kỹ lưỡng nhất trong file. Khi một lỗi xuất hiện, hãy kiểm tra nó ngay lập tức. Mã lỗi là ECONNRESET hay là timeout? Hãy đưa nó vào hàng đợi để thử lại. Đó là SyntaxError, một lỗi từ Joi validation, hay thiếu ràng buộc khóa ngoại (foreign key constraint)? Hãy chuyển thẳng nó đến hàng đợi thư chết (dead-letter queue, hay DLQ) và đừng tính nó vào giới hạn thử lại của bạn.

Hầu hết các thư viện hàng đợi Node.js, bao gồm BullMQ và Bee Queue, đều cho phép bạn định nghĩa các chiến lược backoff tùy chỉnh và các error hooks. Hãy sử dụng chúng. Một lỗi vĩnh viễn không bao giờ nên được để ở chế độ chờ (sleep) rồi thử lại ba lần theo mặc định. Nó nên được di dời khỏi hàng đợi chính để các công việc khác có thể tiếp tục trôi qua. DLQ sẽ lưu giữ chính xác payload và ngữ cảnh lỗi (error context), cho phép bạn chạy lại công việc đó sau khi bạn đã vá lỗi hoặc sửa schema.

Pattern 2: Exponential Backoff với Jitter

Thử lại ngay lập tức là một hành động quá quyết liệt. Nếu một cơ sở dữ liệu hạ nguồn (downstream database) đang phải gồng mình dưới tải trọng lớn, việc tấn công nó liên tục mỗi hai giây từ năm mươi worker sẽ khiến nó sụp đổ hoàn toàn. Bạn cần lùi lại và cho hệ thống không gian để phục hồi.

Hãy sử dụng exponential backoff (lùi bước lũy thừa). Ở lần thất bại đầu tiên, hãy đợi một giây. Ở lần thứ hai, đợi hai giây. Sau đó là bốn, rồi tám, cho đến một mức trần hợp lý như năm phút. Nhưng chỉ thời gian thôi là chưa đủ. Nếu mọi công việc thất bại đều sử dụng cùng một khoảng thời gian chính xác, chúng sẽ va chạm với nhau khi thời gian backoff kết thúc. Làn sóng đồng bộ đó, đôi khi được gọi là "hiệu ứng bầy đàn" (thundering herd), có thể làm quá tải một dịch vụ đang trong quá trình phục hồi.

Thêm jitter. Hãy lấy khoảng thời gian trễ đã tính toán và làm nhiễu nó bằng một tỷ lệ phần trăm ngẫu nhiên, có thể là mười đến hai mươi phần trăm. Bốn giây sẽ trở thành 4.2 hoặc 4.7. Sự ngẫu nhiên đơn giản này sẽ làm dàn trải các đỉnh thử lại (retry spike) và giúp hạ tầng của bạn không bị tấn công theo từng đợt sóng.

Pattern 3: Thiết kế để đạt tính Idempotency

Đây là nơi hạ tầng hàng đợi giao thoa với logic nghiệp vụ. Hãy tưởng tượng một công việc thực hiện trừ tiền khách hàng thông qua một nhà cung cấp thanh toán. Worker đã thực hiện lệnh trừ tiền thành công, nhưng kết nối bị ngắt trước khi nó có thể ghi nhận thành công vào cơ sở dữ liệu của bạn hoặc xác nhận công việc. Hàng đợi thấy một lỗi. Nó thử lại. Kết quả là khách hàng bị trừ tiền hai lần.

Trong Node.js, hãy ngăn chặn điều này bằng cách làm cho mọi tác dụng phụ (side effect) trở nên idempotent. Hãy tạo một khóa idempotency từ job ID hoặc một định danh đặc thù của nghiệp vụ. Trước khi bạn thực hiện thanh toán, gửi email hoặc điều chỉnh kho hàng, hãy kiểm tra xem công việc đó đã được thực hiện hay chưa. Hãy truyền khóa đó xuyên suốt vào cơ sở dữ liệu và đến bất kỳ API bên thứ ba nào có hỗ trợ. Hãy cấu trúc các công việc của bạn sao cho việc chạy cùng một payload mười lần cũng cho ra kết quả giống như khi chỉ chạy một lần. Thói quen duy nhất này sẽ loại bỏ hoàn toàn một nhóm các lỗi liên quan đến tài chính và toàn vẹn dữ liệu.

Pattern 4: Hãy coi Dead-Letter Queue như một Dashboard

DLQ không phải là một nghĩa địa nơi các công việc lỗi bị bỏ quên. Nó là một công cụ vận hành, và nó nên là một trong những bề mặt được giám sát chặt chẽ nhất của bạn.

Thiết lập các cảnh báo sẽ kích hoạt khi độ sâu của DLQ tăng lên. Thậm chí chỉ một tin nhắn duy nhất trong DLQ cũng thường có nghĩa là logic xác thực của bạn đã bị lỗi, một schema phía thượng nguồn đã thay đổi, hoặc một dịch vụ phía hạ nguồn đang gửi dữ liệu rác mà bạn không còn nhận diện được nữa. Đây chính xác là những tín hiệu mà bạn muốn nắm bắt trước khi khách hàng bắt đầu phàn nàn. Hãy xây dựng một dashboard cho phép bạn kiểm tra payload thô, stack trace và timestamp. Hãy chuẩn bị sẵn một runbook: kiểm tra lỗi, vá mã nguồn, sau đó chạy lại các tin nhắn theo đúng thứ tự. Nếu triển khai hàng đợi của bạn có hỗ trợ, hãy cảnh báo về cả tốc độ tăng trưởng cũng như số lượng tuyệt đối, bởi vì một bản deploy lỗi có thể làm tràn ngập DLQ chỉ trong vài phút.

Pattern 5: Khi còn nghi ngờ, hãy để tiến trình bị crash

Node.js chạy trên một event loop duy nhất bên trong một V8 isolate. Một lỗi unhandled promise rejection hoặc một