Hầu hết các hướng dẫn Node.js đều coi việc xử lý lỗi là một phần phụ. Bạn bao bọc một trình xử lý route trong một khối try/catch, ghi lại stack trace và trả về lỗi 500. Tư duy đó tồn tại vì có một người thật đang chờ đợi ở đầu bên kia của một yêu cầu HTTP. Các tác vụ chạy ngầm (background jobs) thì khác. Trong một hệ thống hàng đợi, không có khách hàng thiếu kiên nhẫn để phản hồi, cũng không có việc tự động làm mới trình duyệt. Chỉ có một worker, một payload và một bộ đếm số lần thử lại (retry counter) đang âm thầm tăng lên. Khi có vấn đề xảy ra, chúng thường diễn ra chậm chạp, rồi sau đó ập đến cùng một lúc. Một lỗi bị phân loại sai có thể làm đình trệ toàn bộ pipeline hoặc gọi khẩn cấp cho một kỹ sư vào lúc ba giờ sáng.
Sự khác biệt rất đơn giản. Các chu kỳ request-response thường thất bại nhanh và gây ra tiếng vang lớn. Một lỗi hàng đợi thì lại diễn ra trong im lặng. Một worker có thể xử lý hàng trăm công việc trước khi kết nối cơ sở dữ liệu bị ngắt. Nếu không có các quy tắc xử lý rõ ràng, worker sẽ thử lại ngay lập tức, dồn dập tấn công cơ sở dữ liệu vốn đã đang gặp khó khăn, và cuối cùng là bị sập. Vì không có ai trực tiếp theo dõi worker, dấu hiệu rắc rối đầu tiên thường là sự ùn tắc dây chuyền hoặc ổ đĩa đầy tệp nhật ký (log files). Bạn cần nhiều hơn là các khối catch. Bạn cần một chiến lược xử lý các loại lỗi khác nhau theo những cách khác nhau và bảo vệ phần còn lại của hệ thống khỏi một tác vụ lỗi duy nhất.
Hai loại lỗi
Hãy bắt đầu bằng cách chia mọi lỗi vào một trong hai nhóm.
Các lỗi có thể thử lại (retryable errors) là lỗi tạm thời. Một lỗi timeout mạng tới API bên thứ ba, một phản hồi giới hạn tốc độ (rate-limit) 429, hoặc một bản sao cơ sở dữ liệu (database replica) tạm thời bị trễ so với bản chính. Đây là những triệu chứng của sự quá tải, không phải là lỗi (bug). Hệ thống có thể tự phục hồi trong vòng ba mươi giây. Các tác vụ có thể thử lại xứng đáng có thêm một cơ hội, nhưng chỉ trong các điều kiện được kiểm soát.
Các lỗi vĩnh viễn (permanent errors) là những sai sót. JSON không hợp lệ trong payload, thiếu ID người dùng, hoặc một tệp bắt buộc không tồn tại trong bộ lưu trữ. Những lỗi này sẽ thất bại ở lần thử thứ một trăm y hệt như lần đầu tiên. Việc thử lại chúng sẽ làm tiêu tốn chu kỳ CPU, lãng phí các slot trong hàng đợi và tạo ra áp lực ngược (back-pressure) độc hại làm trì hoãn các tác vụ bình thường. Nơi hữu ích duy nhất cho một lỗi vĩnh viễn là nhật ký (log), một cảnh báo (alert), hoặc một hàng đợi thư chết (dead-letter queue). Nó không thuộc về vòng lặp thử lại.
Xây dựng một bộ máy quyết định
Hãy phân loại ngay lập tức. Đừng để quyết định này cho framework hàng đợi. Ngay khi bạn bắt được một lỗi, hãy quyết định số phận của nó.
Trong thực tế, điều này có nghĩa là tạo ra các lớp lỗi tùy chỉnh (custom error classes) hoặc các hàm bao (wrapper functions) để kiểm tra lỗi trước khi đẩy nó lên trên (bubbling it up). Nếu một database driver ném ra lỗi reset kết nối, trình xử lý của bạn nên đánh dấu đó là lỗi có thể thử lại. Nếu một trình xác thực payload ném ra lỗi không khớp schema, hãy đánh dấu nó là lỗi vĩnh viễn. Nhiều bộ xử lý tác vụ mặc định là thử lại mọi thứ, và đó là lựa chọn tốn kém nhất mà bạn có thể thực hiện. Hãy từ chối các tác vụ vĩnh viễn ngay lập tức. Hoặc loại bỏ chúng, hoặc chuyển hướng chúng đến một dead-letter queue nơi chúng không thể làm "nhiễm độc" pipeline chính. Thói quen đơn giản này giúp ngăn chặn hiệu ứng hòn tuyết lăn một cách đáng tin cậy hơn bất kỳ thay đổi hạ tầng nào.
Lùi lại, nhưng thông minh hơn
Khi bạn thực hiện thử lại, đừng bao giờ làm điều đó ngay lập tức. Nếu một cơ sở dữ liệu đang bị sập, một loạt các worker dồn dập tấn công nó mỗi giây sẽ trông giống như một cuộc tấn công từ chối dịch vụ (DoS) từ bên trong. Hãy sử dụng exponential backoff. Đợi một phút, sau đó là năm phút, rồi mười lăm phút. Hãy cho hệ thống thượng nguồn (upstream system) không gian để phục hồi.
Nhưng chỉ mình exponential backoff là không đủ. Nếu một nghìn tác vụ thất bại cùng một lúc vì một dịch vụ vừa khởi động lại, lịch trình thử lại của chúng sẽ trùng khớp nhau. Chúng sẽ tấn công dịch vụ đó cùng một lúc khi nó trực tuyến trở lại, có khả năng làm nó sập lần nữa. Hãy thêm jitter: một độ lệch ngẫu nhiên nhỏ cho mỗi khoảng thời gian chờ. Việc rải các lần thử lại trong vài giây sẽ ngăn chặn các cuộc "giẫm đạp" đồng bộ. Công thức toán học thì đơn giản, nhưng sự ổn định mà nó mang lại là vô cùng lớn.
Bảo tồn bằng chứng
Một dead-letter queue là dấu vết kiểm toán (audit trail) của bạn, không phải là thùng rác. Khi một tác vụ đã hết số lần thử lại cuối cùng, đừng chỉ đơn giản là xóa nó. Hãy chuyển toàn bộ payload, cùng với ngữ cảnh lỗi và lịch sử thử lại, vào một DLQ.
Điều này giúp bảo tồn bằng chứng. Con người có thể kiểm tra tác vụ, vá lỗi và chạy lại nó một cách thủ công nếu cần thiết. Quan trọng hơn, hãy giám sát độ sâu của DLQ. Sự gia tăng đột ngột của các tác vụ trong dead-letter thường là cảnh báo sớm nhất về một đợt triển khai (deploy) lỗi, một thay đổi schema không thành công, hoặc một nhà cung cấp bên ngoài vi phạm cam kết. Hãy coi sự gia tăng của DLQ là một chỉ số dẫn dắt (leading indicator), chứ không phải chỉ số trễ (trailing indicator). Nếu DLQ của bạn đang đầy lên, điều gì đó ở thượng nguồn đã thay đổi và đội ngũ của bạn cần biết trước khi tình trạng tồn đọng lan rộng.
Thiết kế cho việc thử lại
Hãy thiết kế mọi công việc (job) như thể nó sẽ chạy hai lần, vì thực tế có thể là như vậy. Một worker có thể gặp lỗi khi đang xử lý dở dang, được lập lịch lại và thực thi lần nữa. Nếu công việc của bạn là tính phí khách hàng, gửi email hoặc tăng số lượng tồn kho, việc thử lại một cách ngây thơ sẽ tạo ra các bản ghi trùng lặp.
Giải pháp chính là tính idempotent (tính lũy đẳng). Trước khi thực hiện một tác vụ gây ra tác động phụ (side effect), hãy kiểm tra xem nó đã được thực hiện hay chưa. Sử dụng một mã định danh duy nhất từ payload của job làm idempotency key. Lưu trữ key đó trong một cache ngắn hạn hoặc một bảng cơ sở dữ liệu có ràng buộc duy nhất (uniqueness constraint). Nếu key đã tồn tại, hãy bỏ qua công việc và trả về kết quả thành công. Điều này biến việc thử lại từ một rủi ro thành một thao tác vô hại (no-op). Nó chỉ tốn thêm vài dòng code, nhưng sẽ giúp bạn không phải giải thích với bộ phận tài chính tại sao doanh thu lại tăng gấp đôi chỉ sau một đêm.
Bảo vệ tiến trình
Các lỗi promise rejection không được kiểm soát và các ngoại lệ (exception) lạc lõng có thể làm chết một tiến trình Node.js mà không có cảnh báo trước. Đối với một worker, điều đó đồng nghĩa với việc các job bị mất và bộ điều phối (orchestrator) phải cuống cuồng khởi động lại container.
Hãy đăng ký các trình xử lý toàn cục (global handlers) cho unhandledRejection và uncaughtException. Nhiệm vụ của chúng không phải là để cứu vãn ứng dụng. Nhiệm vụ là thực hiện các bước dọn dẹp tối thiểu cần thiết, sau đó thoát ra. Hãy để Docker, Kubernetes hoặc systemd khởi động lại worker với trạng thái bộ nhớ sạch sẽ. Việc cố gắng duy trì hoạt động một cách chập chờn sau khi một global handler được kích hoạt sẽ dẫn đến rò rỉ bộ nhớ (memory leaks) và trạng thái bị lỗi (corrupted state). Một cái chết nhanh chóng và sạch sẽ sẽ an toàn hơn là một tiến trình "zombie" chậm chạp xử lý các job không chính xác. Hãy tin tưởng vào bộ điều phối của bạn để khôi phục lại; đừng cố gắng vượt mặt một runtime đã bị lỗi.
Tôn trọng các tín hiệu
Các worker sẽ bị tắt trong quá trình triển khai (deployment), các sự kiện mở rộng (scaling) và luân chuyển node. Nếu tiến trình của bạn chết ngay lập tức khi nhận được SIGTERM, bạn sẽ hủy bỏ bất kỳ job nào đang được xử lý dở dang. Job đó có thể sẽ không bao giờ hoàn thành, và bộ đếm thử lại của nó thậm chí còn chưa kịp tăng lên.
Hãy lắng nghe các tín hiệu SIGTERM và SIGINT. Khi có tín hiệu đến, hãy ngừng lấy các job mới từ hàng đợi (queue). Hãy hoàn thành job hiện tại nếu có thể. Thiết lập một thời gian chờ tối đa (hard timeout), có thể là ba mươi giây, sau đó bạn sẽ thoát ra bất kể tình trạng thế nào. Việc tắt máy một cách êm ái (graceful shutdown) này sẽ tôn trọng hàng đợi và tránh các lỗi giả. Quy trình triển khai (deployment pipeline) của bạn nên coi một worker thoát ra một cách sạch sẽ là đang hoạt động tốt (healthy), trong khi một worker bị crash nên kích hoạt cảnh báo.
Bài học cốt lõi
Xử lý hàng đợi một cách đáng tin cậy không phải là bắt mọi lỗi. Đó là việc đưa ra các quyết định có tính toán cho từng chế độ lỗi (failure mode). Hãy kiên nhẫn thử lại với những lỗi tạm thời (transient errors). Hãy xử lý dứt điểm các lỗi vĩnh viễn một cách nhanh chóng. Hãy bảo vệ worker của bạn khỏi tình trạng quá tải đột ngột (stampedes), bảo vệ dữ liệu bằng các idempotency key, và để các tiến trình đang chết thoát ra một cách sạch sẽ. Khi mọi lỗi đều có một lộ trình được xác định trước, 3 giờ sáng cũng chỉ là một giờ bình thường khác. Quy trình của bạn vẫn tiếp tục vận hành, và đội ngũ của bạn vẫn có thể ngủ ngon.
