「Request Timeout」が招く悲劇
最近、アカウント登録機能を構築した時のことを思い出してみてください。ユーザーがボタンを押した後、システムはDBへの保存、ウェルカムメールの送信、プロフィールの作成、そしてSlackへの通知送信を行わなければなりません。これらを順次実行すると、ユーザーは8〜10秒間もローディング画面を見続けることになります。さらに悪いことに、メールサーバーのレスポンスが少しでも遅れれば、プロセス全体が崩壊し、ユーザーに非常に不快な体験を与えてしまいます。
私が以前参加したあるEコマースプロジェクトでは、セール日にトラフィックが5倍に跳ね上がり、システムが「息切れ」を始めました。CPU使用率は常に90%に達し、504 Gateway Timeoutエラーが頻発しました。その時の唯一の解決策は、重いタスクをメインスレッドから切り離すことでした。それらをキュー(Queue)に入れ、バックグラウンドで静かに処理させるようにしたのです。
そこで登場するのが BullMQ と Redis です。この問題を根本から解決するための、標準的なジョブキューシステムのセットアップ方法を詳しく見ていきましょう。
ジョブキューはどのように動作するのか?
ジョブキューは、スターバックスの注文カウンターのようなものだと考えてください。顧客(Producer)が注文し、店員が注文内容を伝票(Job)に書き込んで待機列に貼ります。バリスタ(Worker)はその伝票を一枚ずつ受け取ってドリンクを作ります。店内がどんなに混雑していても、顧客は支払いを済ませた後、カウンターの前で立ち尽くす代わりに、ゆったりと席を探して待つことができます。
なぜ Redis を選ぶのでしょうか? Redisはメモリ上で動作するデータベースであり、レスポンス速度が非常に高速だからです。BullMQはRedisを活用することで、数百万のジョブを管理しつつ、Node.jsサーバーが万が一クラッシュしてもデータが失われないように保証します。最適化されたデータ構造により、複数のWorker間での競合も非常にスムーズに処理されます。
5分でできるBullMQの実装
1. インフラの準備
実行中の Redis インスタンスが必要です。最も手っ取り早い方法は Docker を使うことです:
docker run --name redis-bullmq -p 6379:6379 -d redis
次に、プロジェクトを初期化してコアライブラリをインストールします:
mkdir node-job-queue && cd node-job-queue
npm init -y
npm install bullmq ioredis
2. Producer – ジョブの発行者
producer.js ファイルは、リクエストをキューに投入する役割を担います。注意:BullMQをioredis経由で接続する場合、maxRetriesPerRequest を null に設定する必要があります。
const { Queue } = require('bullmq');
const IORedis = require('ioredis');
const connection = new IORedis({ maxRetriesPerRequest: null });
const emailQueue = new Queue('email-queue', { connection });
async function addEmailJob(userEmail) {
await emailQueue.add('send-welcome-email', {
email: userEmail,
subject: 'ようこそ!',
body: 'チームへのご参加ありがとうございます。'
});
console.log(`[+] メールをキューに追加しました: ${userEmail}`);
}
addEmailJob('[email protected]');
3. Worker – ジョブの実行者
Workerは常にキューを「監視」します。ロジックを処理するために worker.js を作成します:
const { Worker } = require('bullmq');
const IORedis = require('ioredis');
const connection = new IORedis({ maxRetriesPerRequest: null });
const worker = new Worker('email-queue', async (job) => {
console.log(`[*] ジョブ ${job.id} を処理中...`);
// 2秒かかるメール送信処理をシミュレート
await new Promise(res => setTimeout(res, 2000));
if (Math.random() > 0.8) throw new Error('SMTP接続に失敗しました!');
console.log(`[OK] ${job.data.email} に送信完了`);
}, { connection });
worker.on('failed', (job, err) => console.log(`[!] ジョブ ${job.id} エラー: ${err.message}`));
BullMQの「導入価値が高い」3つの機能
配列やオブジェクトで自作のキューを書くと、システムのスケール時に後悔することになります。BullMQは、すべてのバックエンドエンジニアが求める機能を標準で提供しています。
インテリジェントなリトライ機能
外部APIの呼び出しで500エラーやタイムアウトが発生するのは日常茶飯事です。諦める代わりに、指数バックオフ(exponential backoff)を用いて、一定の間隔を空けて自動的に再試行するように設定しましょう。
await emailQueue.add('welcome-job', data, {
attempts: 5,
backoff: {
type: 'exponential',
delay: 2000 // 2秒、4秒、8秒...後にリトライ
}
});
優先順位付け (Priority)
パスワードリセットのメールは即座に送信する必要がありますが、ニュースレターのメールは後回しでも構いません。BullMQは、重み付けによってこれを非常に簡単に処理できます。
await emailQueue.add('critical-job', { type: 'reset-pwd' }, { priority: 1 });
await emailQueue.add('low-job', { type: 'newsletter' }, { priority: 10 });
遅延実行 (Delay)
カート放棄からちょうど24時間後にリマインドメールを送りたいですか? setTimeout を使ってはいけません。サーバーが再起動するとデータが消えてしまうからです。信頼性を確保するために、BullMQの delay オプションを使用しましょう。
現場でトラブルを避けるための実践的な経験則
本番環境でのキューの運用には、通常以上の慎重さが求められます。私の3つの教訓を紹介します:
- 常に「べき等性(Idempotency)」を意識する: 1つのジョブが万が一2回実行されても、エラーや不整合が起きないように設計してください。例えば、課金処理の前に、そのトランザクションIDが既に処理済みかどうかを確認します。
- Workerを分離する: WorkerをAPIサーバーと同じプロセスで動かしてはいけません。別のコンテナに分離してください。画像リサイズなどの重い処理でWorkerが過負荷になっても、APIはユーザーに対してスムーズに応答し続ける必要があります。
- BullBoardによる監視: コンソールログを盲目的に眺めるのはやめましょう。BullBoard を導入して視覚的なダッシュボードを構築すれば、失敗したジョブの数を確認し、クリック一つでリトライできるようになります。
おわりに
ジョブキューの使用は、単にライブラリをインストールすることではなく、同期処理から非同期処理へと「思考を切り替える」ことです。Node.js、BullMQ、Redisの三銃士を使えば、高負荷に耐えうるシステムを自信を持って構築できます。もしあなたのアプリケーションが重いと感じているなら、今すぐ重いタスクをキューに追い出してみてください。ユーザー体験は劇的に向上し、何よりあなた自身が夜ぐっすり眠れるようになるはずです。
