Chạy việc thay đổi kích thước hình ảnh bên trong một route Express là một sai lầm tai hại. Một người dùng tải lên một bức ảnh mười megabyte, máy chủ của bạn bắt đầu xử lý các điểm ảnh, và ba mươi giây sau yêu cầu bị hết thời gian (timeout). Các hàng đợi công việc chạy ngầm (background job queues) tồn tại chính là để ngăn chặn loại rắc rối này. Trong hệ sinh thái Node.js, Bull và BullMQ đã trở thành hai cái tên nặng ký nhất để xử lý các công việc bất đồng bộ thông qua Redis. Chúng có chung nền tảng nhưng lại khác biệt rõ rệt về triết lý và tính tiện dụng trong quá trình lập trình hàng ngày. Việc chọn đúng thư viện là rất quan trọng vì việc chuyển đổi sau này không đơn giản chỉ là cập nhật gói (package).
Nền tảng chung
Cả hai thư viện đều sử dụng Redis làm xương sống. Redis xử lý các thao tác nguyên tử (atomic operations), các tập hợp có thứ tự (sorted sets) cho các công việc bị trì hoãn, và pub/sub cho các sự kiện. Nếu bạn đã chạy Redis để làm bộ nhớ đệm (caching) hoặc quản lý session, việc thêm một hàng đợi công việc sẽ không yêu cầu hạ tầng mới. Cả Bull và BullMQ đều hỗ trợ ưu tiên (priorities), thử lại với cơ chế backoff (retries with backoff), kiểm soát sự đồng thời (concurrency controls) và các công việc có thể lặp lại (repeatable jobs). Sự trùng lặp đó khiến việc lựa chọn trở nên khó khăn hơn chứ không hề dễ dàng. Bạn không thể chỉ dựa vào một danh sách các tính năng. Thay vào đó, bạn phải xem xét cách mỗi thư viện muốn bạn cấu trúc mã nguồn của mình.
Bull: Người kỳ cựu đã qua thử thách
Bull đã tồn tại nhiều năm và đang chạy trong hàng ngàn ứng dụng thực tế. Nó hoạt động rất tốt. API đóng gói mọi thứ vào một instance Queue duy nhất. Bạn khởi tạo nó, định nghĩa một hàm xử lý và lắng nghe các sự kiện, tất cả đều trên cùng một đối tượng. Thiết kế nguyên khối (monolithic) này mang lại cảm giác quen thuộc nếu bạn đến từ các pattern Node.js cũ hơn. Các mã nguồn có trước khi async/await trở nên phổ biến sẽ phù hợp với Bull một cách tự nhiên vì nó phát triển song hành cùng các callback và các client Redis đời đầu.
Nhược điểm là sự phụ thuộc chặt chẽ (tight coupling). Khi máy chủ API của bạn tạo một công việc, nó sẽ import chính đối tượng Queue chứa logic của worker. Trong thực tế, điều này có nghĩa là tiến trình web của bạn sẽ kéo theo các phụ thuộc (dependencies) mà nó không bao giờ thực thi. Đây không phải là một lỗi chí mạng, nhưng nó gây ảnh hưởng đến kiến trúc sạch (clean architecture). Với các khối lượng công việc đơn giản, bạn có thể không bao giờ nhận ra. Nhưng với các đội ngũ lớn với hàng chục module, sự bất tiện này sẽ tích tụ dần.
BullMQ: Một sự tái thiết từ đầu
BullMQ là người kế nhiệm chính thức. Nó được viết lại bằng TypeScript ngay từ đầu, vì vậy các kiểu dữ liệu (types) không phải là phần bổ sung sau đó được ghép vào mã nguồn JavaScript. API chia tách các trách nhiệm thành các class riêng biệt. Queue xử lý việc thêm công việc. Worker xử lý việc thực thi chúng. QueueEvents xử lý khả năng quan sát (observability). Sự phân tách này phản ánh cách các hệ thống phân tán hiện đại thực sự vận hành. Các pod API của bạn chỉ cần class Queue và một kết nối Redis. Các pod worker của bạn sẽ import class Worker. Ranh giới này mang tính vật lý, chứ không chỉ dừng lại ở mức khái niệm.
Sự thay đổi này mang lại hiệu quả lớn trong các đội ngũ lớn. Một lập trình viên khi triển khai một tính năng mới có thể đưa một công việc vào hàng đợi (enqueue) mà không cần biết file nào chứa bộ xử lý (processor). Trình biên dịch sẽ phát hiện sớm các lỗi không khớp kiểu dữ liệu giữa dữ liệu công việc và các handler thay vì đợi đến khi chạy (runtime). API async/await cũng mang lại cảm giác tự nhiên trong Node.js hiện đại. Bạn sẽ không phải vật lộn với các quy ước cũ kỹ.
Luồng công việc: Từ những giải pháp tạm bợ đến những công dân hạng nhất
Các quy trình làm việc nhiều bước bộc lộ khoảng cách lớn nhất giữa hai thư viện này.
Giả sử bạn đang xây dựng một quy trình lập hóa đơn thương mại điện tử. Một khách hàng thanh toán. Bạn cần giữ hàng trong kho, trừ tiền thẻ, tạo tệp PDF và gửi email. Với Bull, việc kết nối các bước này đồng nghĩa với việc phải quản lý thủ công. Bạn có thể có một bộ xử lý kích hoạt công việc tiếp theo, truyền trạng thái thông qua Redis hoặc các gói dữ liệu cồng kềnh. Bạn phải tự viết mã điều phối cha-con. Nó sẽ hoạt động cho đến khi gặp sự cố. Logic thử lại sẽ trở nên rắc rối. Nếu bước tạo PDF thất bại, việc hoàn tác giao dịch thanh toán sẽ đòi hỏi mã xử lý bù đắp (compensation code) tùy chỉnh mà rất dễ sai sót.
BullMQ giới thiệu FlowProducer. Bạn định nghĩa một cây các công việc, nơi các công việc cha sẽ tự động đợi các công việc con của chúng. Trong ví dụ lập hóa đơn, bạn tạo một công việc gốc gọi là finalize-order với ba công việc con: reserve-inventory, charge-payment, và generate-pdf. Bạn có thể đặt thông báo email là con của công việc PDF. Redis sẽ lưu trữ cấu trúc đồ thị này. Công việc cha chỉ được kích hoạt khi mọi phụ thuộc đều thành công. Nếu một công việc con thất bại, toàn bộ nhánh đó sẽ dừng lại. Bạn không cần phải viết các vòng lặp thăm dò (polling loops) hay các trình tạo công việc đệ quy. Đây không chỉ là cú pháp đường (syntactic sugar). Nó thay đổi cách bạn mô hình hóa logic nghiệp vụ.
Giới hạn tốc độ: Công cụ thô sơ đối đầu với Dao mổ
Cả hai thư viện đều có thể điều tiết lưu lượng (throttle throughput), nhưng độ chi tiết (granularity) lại khác biệt rất lớn.
Bull áp dụng giới hạn tốc độ theo từng hàng đợi. Nếu bạn thiết lập một hàng đợi để xử lý một trăm công việc mỗi giây, mức trần đó sẽ áp dụng cho mọi công việc trong hàng đợi một cách bình đẳng. Điều này ổn đối với các khối lượng công việc đồng nhất. Tuy nhiên, nó sẽ bộc lộ yếu điểm trong các nền tảng SaaS đa người dùng (multitenant). Hãy tưởng tượng một khách hàng gây nhiễu đổ hàng triệu webhook vào một hàng đợi dùng chung. Giới hạn cấp hàng đợi của Bull có nghĩa là bạn không thể làm chậm khách hàng đó mà không làm chậm tất cả những người khác. Các lựa chọn của bạn rất tệ: khởi tạo các hàng đợi Redis riêng biệt cho từng khách hàng và quản lý chúng một cách linh hoạt, hoặc chấp nhận sự bất công.
BullMQ bổ sung tính năng giới hạn tốc độ dựa trên nhóm. Bạn gắn thẻ mỗi công việc với một khóa nhóm, thường là ID của khách hàng hoặc người dùng, và định nghĩa các giới hạn cho từng nhóm. Cùng một hàng đợi xử lý công việc cho tất cả khách hàng, nhưng bộ lập lịch sẽ điều tiết từng nhóm một cách độc lập. Một đợt tăng đột biến từ Khách hàng A không làm cạn kiệt tài nguyên của Khách hàng B. Bạn tránh được tình trạng bùng nổ số lượng hàng đợi và giữ cho không gian khóa Redis luôn gọn gàng. Đối với các nền tảng lo ngại về vấn đề "hàng xóm ồn ào" (noisy-neighbor), chỉ riêng điều này thôi đã đủ để biện minh cho việc chuyển đổi.
Kiến trúc sạch hơn trong thực tế
Sự tách biệt giữa Queue và Worker rất tinh tế cho đến khi bạn phải gỡ lỗi một sự cố trên môi trường production. Với Bull, việc thấy mã tạo công việc nằm sâu trong các trình xử lý route (route handlers) vốn cũng đang import các dependency xử lý nặng là điều phổ biến. BullMQ buộc bạn phải quyết định nơi công việc được thực hiện. Các máy chủ web của bạn sẽ luôn gọn nhẹ. Các container worker sẽ đóng gói các thư viện nặng, bộ xử lý hình ảnh hoặc trình duyệt không giao diện (headless browsers). Nếu xảy ra lỗi rò rỉ bộ nhớ, bạn biết chính xác loại tiến trình nào cần được profile. Mô hình tư duy này gần giống với các hệ thống như Celery hoặc Sidekiq.
Đưa ra lựa chọn
Hãy bắt đầu với BullMQ nếu bạn đang xây dựng dự án mới từ đầu. Các định nghĩa TypeScript rất chính xác và đầy đủ. Luồng công việc (job flows) giúp loại bỏ hàng tá mã điều phối (orchestration code). Giới hạn tốc độ theo nhóm giải quyết các vấn đề về tính công bằng ngay từ đầu. API async/await mang lại cảm giác rất tự nhiên. Có rất ít lý do để chọn thư viện cũ hơn cho một dự án mới hoàn toàn (greenfield project).
Hãy tiếp tục dùng Bull nếu nó đang hoạt động ổn định. Việc chuyển đổi tốn thời gian và rủi ro về tính ổn định. Nếu các công việc của bạn đơn giản và độc lập, bạn sẽ không thiếu những tính năng mà mình thực sự cần. Một hàng đợi chỉ gửi email đặt lại mật khẩu và thay đổi kích thước ảnh đại diện thì không cần đến các đồ thị luồng (flow graphs). Viết lại mã đang chạy tốt chỉ để đạt được sự thuần khiết về mặt lý thuyết không phải là kỹ thuật. Đó chỉ là sở thích cá nhân.
Kiểm tra thực tế việc chuyển đổi
Nếu bạn thực sự chuyển đổi, hãy coi đó là một thay đổi về hạ tầng, chứ không phải là tái cấu trúc mã (code refactor). Bull và BullMQ sử dụng các schema khóa Redis khác nhau. Chúng không thể đọc dữ liệu hoặc trạng thái công việc của nhau. Bạn không thể chỉ bật một feature flag rồi hy vọng các công việc cũ sẽ tự hoàn thành. Bạn phải làm trống hoàn toàn mọi hàng đợi hiện có, triển khai các worker mới và bắt đầu đưa công việc vào hàng đợi bằng BullMQ. Hãy lên kế hoạch cho một khoảng thời gian bảo trì hoặc triển khai blue-green, nơi các worker cũ tiêu thụ hàng đợi cũ trong khi các worker mới xử lý hàng đợi mới.
