Expressのルート内で画像のリサイズを実行するのは、災難を招く行為です。ユーザーが10MBの写真をアップロードし、サーバーがピクセル計算を開始すると、30秒後にはリクエストがタイムアウトしてしまいます。バックグラウンドジョブキューは、まさにこのような問題を回避するために存在します。Node.jsのエコシステムにおいて、BullとBullMQはRedisを介した非同期処理を扱う二大巨頭となっています。両者は共通のDNAを持ちながらも、設計思想や日常的な使い勝手においては大きく異なります。後から切り替えるのは単なるパッケージのアップデートでは済まないため、適切な方を選ぶことが重要です。

共通の基盤

両ライブラリとも、バックボーンとしてRedisを使用しています。Redisは、アトミック操作、遅延ジョブ用のソート済みセット、イベント用のpub/subを処理します。すでにキャッシュやセッション用にRedisを運用しているなら、ジョブキューを追加するために新しいインフラを構築する必要はありません。BullとBullMQはどちらも、優先度、バックオフ付きのリトライ、並行数制御、および繰り返しジョブをサポートしています。この機能の重複が、選択を容易にするどころか、むしろ難しくしています。機能のチェックリストだけで判断することはできません。代わりに、各ライブラリがどのようにコードを構造化することを求めているかに注目する必要があります。

Bull: 鍛え上げられたベテラン

Bullは何年も前から存在し、数千ものプロダクション環境で稼働しています。確実に動作します。APIはすべてを単一の Queue インスタンスに集約します。インスタンス化、処理関数の定義、イベントのリスニングをすべて同じオブジェクト上で行います。このモノリシックな設計は、古いNode.jsのパターンに慣れている人には馴染み深いものです。async/await が広く普及する前のコードベースは、コールバックや初期のRedisクライアントと共に成長してきたBullと自然に適合します。

デメリットは、密結合(tight coupling)です。APIサーバーがジョブを作成する際、ワーカーのロジックが含まれているのと同じ Queue オブジェクトをインポートすることになります。実際には、Webプロセスが実行することのない依存関係まで引き込んでしまうことを意味します。致命的な欠陥ではありませんが、クリーンなアーキテクチャを追求する上では気になる点です。単純なワークロードであれば気づかないかもしれませんが、数十のモジュールを持つ大規模なチームでは、その摩擦が蓄積していきます。

BullMQ: ゼロからの再構築

BullMQは公式の継承者です。最初からTypeScriptで書き直されているため、型定義はJavaScriptソースに後付けされたものではありません。APIは責任を明確に異なるクラスへと分割しています。Queue はジョブの追加を、Worker はその処理を、QueueEvents はオブザーバビリティ(観測性)を担当します。この分離は、現代の分散システムが実際に動作する仕組みを反映しています。APIポッドには Queue クラスとRedis接続さえあればよく、ワーカーポッドは Worker クラスをインポートします。境界は概念的なものではなく、物理的なものになります。

この転換は、大規模なチームで大きなメリットをもたらします。新機能をリリースする開発者は、どのファイルにプロセッサが含まれているかを知らなくても、ジョブをエンキューできます。コンパイラが、ジョブデータとハンドラー間の型の不一致を、実行時ではなく早い段階で検知してくれます。async/await APIも現代のNode.jsにおいてネイティブな感覚で扱えます。レガシーな慣習と戦う必要もありません。

ジョブフロー: ハックから第一級市民へ

マルチステップのワークフローにおいて、両ライブラリの差は最も顕著に現れます。

例えば、Eコマースの請求処理パイプラインを構築しているとしましょう。顧客がチェックアウトすると、在庫を確保し、カード決済を行い、PDFを生成し、メールを送信する必要があります。Bullでこれらのステップを連鎖させるには、手動での管理が必要です。あるプロセッサが次のジョブを起動し、Redisや巨大なデータペイロードを介して状態を渡すといった実装になります。親子関係の調整は自分で行わなければなりません。動いてはいますが、いつか限界が来ます。リトライロジックが複雑になります。もしPDF生成ステップが失敗した場合、決済を取り消すにはカスタムの補償コードが必要になりますが、これは間違いが発生しやすいものです。

BullMQは FlowProducer を導入しています。親ジョブが子の完了を自動的に待機する、ジョブのツリーを定義できます。請求の例では、finalize-order というルートジョブを作成し、その下に reserve-inventorycharge-paymentgenerate-pdf という3つの子ジョブを設定します。メール通知をPDFジョブの子にすることもできます。Redisがグラフ構造を保持し、親ジョブはすべての依存関係が成功したときにのみアクティブになります。もし一つの子が失敗すれば、そのブランチ全体が停止します。ポーリングループや再帰的なジョブ生成器を書く必要はありません。これは単なる構文糖衣ではなく、ビジネスロジックのモデリング手法そのものを変えるものです。

レート制限: 鈍器か、メスか

両ライブラリともスループットを制限(throttle)できますが、その粒度は劇的に異なります。

Bullはキューごとにレート制限を適用します。キューの処理速度を毎秒100ジョブに設定した場合、その上限はキュー内のすべてのジョブに等しく適用されます。これは均質なワークロードであれば問題ありません。しかし、マルチテナントSaaSプラットフォームではこれが問題になります。例えば、ある「ノイジーな」顧客が、共有キューに100万件ものWebhook配信を流し込んだとしましょう。Bullのキューレベルの制限では、そのテナントの速度を落とそうとすると、他のすべてのテナントの速度も落とさざるを得ません。選択肢は芳しくありません。顧客ごとに個別のRedisキューを立ち上げて動的に管理するか、あるいは不公平を受け入れるかです。

BullMQは、グループベースのレート制限機能を追加しています。各ジョブにグループキー(通常はテナントIDやユーザーID)をタグ付けし、グループごとに制限を定義します。同じキューで全テナントのジョブを処理しますが、スケジューラが各グループを個別にスロットリングします。顧客Aからのバースト的なリクエストによって、顧客Bのリソースが枯渇することはありません。これにより、キューの乱立を防ぎ、Redisのキースペースを整理された状態に保つことができます。ノイジー・ネイバー(隣人)問題を懸念するプラットフォームにとって、これだけでも移行の十分な理由になり得ます。

実践におけるよりクリーンなアーキテクチャ

キューとワーカーの分離は、本番環境でインシデントが発生してデバッグするまで、その重要性はなかなか実感できません。Bullを使用していると、重い処理用の依存関係をインポートしているルートハンドラーの深い場所に、ジョブ作成のコードが混在していることがよくあります。BullMQは、「どこで処理を行うか」を明確に決定することを強制します。これにより、Webサーバーは軽量なまま保たれます。一方で、ワーカーコンテナには重いライブラリや画像プロセッサ、ヘッドレスブラウザなどをまとめます。もしメモリリークが発生しても、どのプロセスタイプをプロファイリングすべきかが明確に分かります。そのメンタルモデルは、CeleryやSidekiqといったシステムにより近いものです。

選択の基準

新規プロジェクトであれば、BullMQから始めるべきです。TypeScriptの型定義は正確かつ完全です。ジョブフロー機能により、膨大なオーケストレーションコードを排除できます。グループベースのレート制限は、公平性の問題が顕在化する前に解決してくれます。async/await APIはネイティブな感覚で扱えます。新規開発において、あえて古いライブラリを選択する理由はほとんどありません。

すでに稼働している場合は、そのままBullを使い続けてください。移行には時間とコストがかかり、安定性を損なうリスクもあります。もしジョブが単純で独立しているなら、本当に必要な機能が欠けているわけではありません。パスワードリセットメールの送信やアバターのリサイズを行うだけのキューに、フローグラフは必要ありません。理論的な純粋さのために動いているコードを書き直すのは、エンジニアリングではなく、単なる趣味の領域です。

移行における現実的な検討事項

もし移行する場合は、コードのリファクタリングではなく、インフラの変更として扱う必要があります。BullとBullMQでは、Redisのキースキーマが異なります。互いのジョブデータや状態を読み取ることはできません。フィーチャーフラグを切り替えるだけで、古いジョブが完了するのを待つ、といったことは不可能です。既存のすべてのキューを空にし、新しいワーカーをデプロイしてから、BullMQでのキューイングを開始しなければなりません。メンテナンス時間を設けるか、古いワーカーがレガシーなキューを処理し、新しいワーカーが新しいキューを処理するブルーグリーンデプロイメントを計画してください。

結論