Mọi lập trình viên Node.js sớm muộn gì cũng sẽ vấp phải cùng một trở ngại. Một người dùng nhấn nút, trình xử lý route của bạn bắt đầu xử lý một tác vụ nặng nề, và yêu cầu HTTP cứ thế treo lơ lửng. Có thể bạn đang gửi email hàng loạt, đồng bộ hóa bản ghi với một CRM bên thứ ba, hoặc tạo báo cáo PDF. Trình duyệt cứ xoay vòng. Ứng dụng di động thì hết thời gian chờ (timeout). Người dùng của bạn trở nên khó chịu, và máy chủ của bạn thì tiêu tốn các slot kết nối mà nó không thể để mất. Cách khắc phục là đưa công việc đó ra khỏi luồng xử lý yêu cầu và đưa vào một hàng đợi công việc chạy ngầm (background job queue) được hỗ trợ bởi Redis. Trong hệ sinh thái Node.js, có hai thư viện thống trị không gian này: Bull và BullMQ. Việc lựa chọn giữa chúng không hẳn là chọn xem ai thắng, mà là hiểu rõ dự án của bạn đang ở đâu và đang hướng tới đâu.
Lựa chọn truyền thống đáng tin cậy
Bull đã là tiêu chuẩn cho việc xử lý chạy ngầm trong Node.js trong nhiều năm qua. Nó ổn định, đã được kiểm chứng qua thực tế và đang chạy trong vô số ứng dụng production. Nếu bạn cần lập lịch cho một công việc sau đó, tự động thử lại một lượt nhập dữ liệu thất bại, hoặc gán các mức ưu tiên nghiêm ngặt để các webhook thanh toán được chạy trước các đợt gửi bản tin (newsletter), Bull sẽ xử lý mọi thứ một cách trơn tru. API của nó theo hướng callback, nghĩa là nó phù hợp hoàn hảo với các mã nguồn cũ nơi mà promise vẫn còn là một khái niệm mới mẻ. Những đội ngũ đã tin dùng Bull trong thời gian dài biết chính xác những gì họ có thể mong đợi. Thư viện này lưu trữ trạng thái trong Redis, vì vậy nếu tiến trình Node của bạn khởi động lại, các công việc vẫn sẽ tồn tại. Sự tin cậy đó là lý do tại sao rất nhiều doanh nghiệp chưa bao giờ cảm thấy áp lực phải thay đổi một hệ thống vốn đã đang hoạt động tốt.
BullMQ thay đổi điều gì
BullMQ là người kế nhiệm. Nó được xây dựng lại hoàn toàn từ đầu bằng TypeScript, và toàn bộ bề mặt API của nó được xây dựng xoay quanh async/await. Nếu bạn đã dành vài năm qua để viết mã Node.js hiện đại, cú pháp này sẽ mang lại cảm giác quen thuộc ngay lập tức. Nhưng sự khác biệt còn sâu sắc hơn cả các định nghĩa kiểu (type definitions) và các chuỗi promise. BullMQ thực thi sự phân tách rõ ràng giữa hàng đợi (queues) và các trình xử lý (workers). Trong Bull, hàng đợi thường đóng vai trò kép là trình chạy worker. Trong BullMQ, bạn định nghĩa một hàng đợi trong một tệp và một worker trong một tệp khác. Sự phân tách đó phản ánh cách các hệ thống production thực tế mở rộng (scale). Bạn có thể triển khai một dàn các container worker chỉ để xử lý các công việc, trong khi các máy chủ API của bạn chỉ làm nhiệm vụ thêm công việc vào hàng đợi. Kiến trúc này vẫn giữ được sự mạch lạc khi hệ thống phát triển lớn mạnh.
Những tính năng tạo nên sự khác biệt
Nơi BullMQ thực sự bứt phá chính là ở những tính năng mà Bull đơn giản là không cung cấp. Có ba bổ sung quan trọng nhất trong các ứng dụng thực tế.
Luồng công việc (Job Flows)
Các quy trình làm việc phức tạp hiếm khi vừa vặn trong một hàm chạy ngầm duy nhất. Hãy tưởng tượng bạn đang xây dựng một quy trình xử lý hình ảnh. Một người dùng tải lên một bức ảnh thô, và backend của bạn cần tạo ảnh thu nhỏ (thumbnail), tạo bản xem trước đã nén, chạy quét OCR, và sau đó thông báo cho frontend rằng mọi thứ đã sẵn sàng. Với Bull, bạn có khả năng sẽ phải nhồi nhét tất cả các bước đó vào một trình xử lý lớn và dễ lỗi. BullMQ giới thiệu các luồng công việc (job flows), cho phép bạn liên kết các công việc cha và con một cách rõ ràng. Bạn có thể định nghĩa các phụ thuộc để bước thông báo chỉ kích hoạt sau khi cả hai công việc tạo thumbnail và OCR đều thành công. Nếu OCR thất bại, bạn có thể chỉ thử lại riêng phần đó mà không cần xử lý lại ảnh thumbnail. Logic trở nên có tính mô-đun, có thể quan sát được và dễ dàng gỡ lỗi hơn nhiều khi có sự cố xảy ra vào lúc ba giờ sáng.
Giới hạn tốc độ theo nhóm (Group Rate Limiting)
Nếu bạn đang vận hành một ứng dụng SaaS đa người dùng (multi-tenant), có lẽ bạn đã từng lo lắng về việc một khách hàng làm tràn ngập các worker của mình. Một khách hàng duy nhất có thể đưa mười nghìn công việc xuất dữ liệu vào hàng đợi và làm nghẽn tất cả những người khác. BullMQ bổ sung tính năng giới hạn tốc độ theo nhóm, cho phép bạn điều tiết (throttle) việc xử lý theo từng khách hàng (tenant) hoặc theo từng API key. Ví dụ, bạn có thể cho phép Khách hàng A kích hoạt năm mươi lượt gọi API bên ngoài mỗi phút trong khi Khách hàng B cũng nhận được hạn mức tương tự một cách độc lập. Hàng đợi sẽ tôn trọng các giới hạn này trên phạm vi toàn cầu trên tất cả các thực thể worker, chứ không chỉ cục bộ trên một máy duy nhất. Đó là loại van an toàn mà bạn sẽ không thấy quý giá cho đến khi đột nhiên cần đến nó.
Một bề mặt API hiện đại
BullMQ loại bỏ các chữ ký callback cũ kỹ và đón nhận một API hiện đại. Việc xử lý lỗi tuân theo các mẫu promise tiêu chuẩn. Các định nghĩa TypeScript là thành phần cốt lõi, chứ không phải là phần bổ sung sau này từ một gói cộng đồng riêng biệt. Nếu bạn đang bắt đầu một dự án mới hoàn toàn (greenfield project), trải nghiệm lập trình viên sẽ mượt mà hơn đáng kể. Trình soạn thảo của bạn sẽ tự động hoàn thành các tùy chọn hàng đợi. Trình linter sẽ bắt lỗi khi thiếu tên công việc. Gánh nặng về mặt tư duy sẽ giảm xuống.
Sự hiện diện không đổi của Redis
Một điểm nhẹ nhõm thực tế trong quyết định này chính là hạ tầng. Cả Bull và BullMQ đều lưu trữ trạng thái công việc (job state), siêu dữ liệu (metadata) và lịch trình (schedules) trong Redis. Chúng sử dụng các cấu trúc khóa (key) nội bộ khác nhau, nhưng công nghệ nền tảng là giống hệt nhau. Nếu bạn đã đang chạy Redis cho Bull, bạn không cần phải thay thế bằng một cơ sở dữ liệu mới hay tính toán lại cấu trúc triển khai (deployment topology) để chuyển sang BullMQ. Thách thức của việc di chuyển nằm ở mã nguồn ứng dụng, chứ không phải ở hóa đơn máy chủ.
Thực tế của việc di chuyển
Tuy nhiên, việc chuyển từ Bull sang BullMQ không phải là một sự thay thế tức thì (drop-in replacement). Các lời gọi API sẽ thay đổi. Tên các sự kiện (event names) cũng khác nhau. Cách bạn định nghĩa các bộ xử lý (processors) và xử lý tính đồng thời (concurrency) được viết lại đủ nhiều để bạn sẽ phải chỉnh sửa mọi tệp tin có tương tác với hàng đợi (queue). Quan trọng hơn, bạn không thể chỉ đơn giản là "bật công tắc" và hy vọng các công việc cũ sẽ hoàn thành trong hệ thống mới. Bạn phải giải quyết dứt điểm (drain) các hàng đợi Bull hiện có trước khi khởi chạy các worker của BullMQ trên cùng một instance Redis đó. Nếu không, bạn sẽ đối mặt với rủi ro xung đột giữa hai định dạng khác nhau trong cùng một không gian khóa (keyspace). Hãy lên kế hoạch cho một khoảng thời gian bảo trì hoặc chuyển đổi theo mô hình blue-green. Việc này đòi hỏi công sức thực sự, và công sức đó cần phải mang lại giá trị xứng đáng.
Nên lựa chọn phương án nào
Nếu thiết lập Bull hiện tại của bạn vẫn đang hoạt động trơn tru mà không có phàn nàn gì, hãy cứ để yên nó. Sự ổn định luôn có giá trị. Một hàng đợi chạy ngầm là hạ tầng, không phải là một món đồ thời trang. Nếu đội ngũ của bạn đang phải vật lộn với kiến trúc hiện tại vì bạn cực kỳ cần các luồng công việc cha-con (parent-child workflows) hoặc giới hạn tốc độ theo từng khách hàng (per-tenant rate limits), thì việc di chuyển là hợp lý. Sự tách biệt các mối quan tâm (separation of concerns) rõ ràng hơn và API hiện đại sẽ bù đắp lại công sức bỏ ra theo thời gian.
Đối với bất kỳ dự án mới nào, lựa chọn sẽ đơn giản hơn. Hãy bắt đầu với BullMQ. Nó nhận được các bản cập nhật thường xuyên, hỗ trợ sẵn các tiêu chuẩn JavaScript hiện hành và mang lại cho bạn không gian để xây dựng các luồng công việc phức tạp mà không lo bị vượt quá khả năng của thư viện chỉ sau sáu tháng. Bạn sẽ tránh được việc xây dựng nợ kỹ thuật (technical debt) trên một API mà những người duy trì đã không còn tập trung phát triển nữa.
Kết luận thực tế
Một hàng đợi công việc tồn tại để giữ cho các phản hồi HTTP của bạn nhanh chóng và người dùng của bạn kiên nhẫn. Bull vẫn đang thực hiện tốt công việc đó một cách đáng ngưỡng mộ. BullMQ thực hiện điều đó với một cấu trúc phù hợp với cách các ứng dụng Node.js hiện đại được xây dựng và mở rộng. Câu hỏi không phải là thư viện nào tốt hơn trong một môi trường lý tưởng. Câu hỏi là liệu những khó khăn hiện tại của bạn có xứng đáng để thực hiện một cuộc di chuyển hay không, và liệu dự án tiếp theo của bạn có xứng đáng với một nền tảng mà sẽ không cần phải thay thế trước vòng gọi vốn hoặc đợt ra mắt sản phẩm tiếp theo hay không.
