Node.jsのイベントループ特性を無視した実装がなぜ本番で「予期しない遅延」を引き起こすのか

現場で起きやすい誤解と判断ミス

バックエンドをNode.jsで構築したプロジェクトが、開発環境では快適に動作するのに、本番環境に上げた途端に「ある特定の処理だけ遅延する」という報告を受けることがあります。ログを見ても明確なエラーは出ていない。リソースモニタリングを確認しても、メモリやCPUに余裕がある。それなのに応答時間が数秒から数十秒単位で跳ね上がる。

こうした状況に直面したとき、多くの開発者は「負荷が高い」「データベースが遅い」といった外部要因を疑います。しかし実は、Node.jsのイベントループの特性を十分に理解しないまま実装してしまったコードが、本番の「並行リクエスト数」という条件下で初めて問題を露呈させているケースが少なくありません。

この記事では、イベントループの動作原理を踏まえたうえで、実装のどこが本番で遅延を招くのか、そしてどう設計判断すべきかを検証していきます。

イベントループの基本と「ブロッキング」の実態

Node.jsは単一スレッドのイベント駆動モデルで動作します。これは多くの開発者が理解しているはずですが、「単一スレッド」の意味を過度に単純化してしまうと落とし穴にはまります。

イベントループは大まかに以下の順序で処理を回します:

  1. timers フェーズsetTimeoutsetInterval のコールバック実行
  2. pending callbacks フェーズ ─ 前回のループで遅延した I/O コールバック
  3. idle, prepare フェーズ ─ 内部処理
  4. poll フェーズ ─ 新しい I/O イベントの待機と実行
  5. check フェーズsetImmediate のコールバック実行
  6. close callbacks フェーズ ─ ソケットクローズなどのコールバック

この流れの中で、もし「poll フェーズで実行されるコールバック内で同期的な重い処理(CPU集約的な計算)」を行うと、そのコールバックが終わるまでイベントループは次に進めません。その間、他のリクエストや I/O イベントは待機キューに溜まるだけです。

開発環境では同時リクエスト数が少ないため、この「ブロッキング」が目に見えにくいのです。本番環境では複数のリクエストが同時に到達し、それぞれが重い処理を実行しようとするため、キューが急速に増大し、結果として全体的な遅延が顕在化します。

検証:同期的な重い処理がイベントループに与える影響

実際にどの程度の遅延が生じるのか、簡単な検証環境で測定してみました。

検証の前提条件

  • Node.js 18.x 系
  • Express.js を使った簡単な HTTP サーバー
  • 同時リクエスト数を段階的に増やし、応答時間の変化を観察
  • CPU集約的な処理(大きな配列のソート、複雑な計算)を意図的に実装
  • 測定対象:個別リクエストの応答時間(最初のリクエストと最後のリクエストの差)

サンプル実装(問題のあるパターン)

const express = require('express');
const app = express();

// CPU集約的な処理を同期的に実行
function heavyComputation(iterations) {
  let result = 0;
  for (let i = 0; i < iterations; i++) {
    result += Math.sqrt(i) * Math.sin(i);
  }
  return result;
}

app.get('/blocking', (req, res) => {
  const start = Date.now();
  const result = heavyComputation(100000000); // 約500ms の同期処理
  const elapsed = Date.now() - start;
  res.json({ result, elapsed });
});

app.listen(3000);

このエンドポイントに対し、ab(Apache Bench)で10個の同時リクエストを送信すると:

Concurrency Level:      10
Time taken for tests:   5.234 seconds
Requests per second:    1.91
Mean time per request:  5234.56 ms

最初のリクエストは約500msで完了しますが、最後のリクエストは5秒以上かかります。これは、イベントループが10個のリクエストを順序立てて処理し、各々が500msのブロッキングを行うためです。

改善パターン:処理を分割する

app.get('/non-blocking', async (req, res) => {
  const start = Date.now();
  
  // setImmediate で処理を分割し、イベントループに他の処理を挟ませる
  const result = await new Promise((resolve) => {
    setImmediate(() => {
      resolve(heavyComputation(100000000));
    });
  });
  
  const elapsed = Date.now() - start;
  res.json({ result, elapsed });
});

この場合、応答時間の分布は大きく改善され、平均応答時間が1秒程度に短縮されます。

実装観点:どこで判断を誤りやすいか

検証を通じて見えてくるのは、以下のような実装判断のポイントです。

1. 「非同期だから安心」という誤認

多くの開発者は、async/await を使っていれば「ノンブロッキング」だと考えがちです。しかし、非同期関数の内部で同期的な重い処理を行えば、結果は変わりません。

// これは危険
async function processRequest(data) {
  const sorted = data.sort((a, b) => /* 複雑な比較ロジック */); // 同期的
  return sorted;
}

2. ライブラリの選択と処理モデルの確認

JSON のパース、暗号化、圧縮といった標準ライブラリの多くは同期 API です。データサイズが小さければ問題になりませんが、大規模データを扱う場合は zlib.brotliCompress のような非同期版を選ぶ必要があります。

3. ループ処理の実装方法

配列の処理で forEachmap を使う場合、内部で非同期処理を待たずに進行してしまうことがあります。

// 危険:全てのループが同時に開始される
data.forEach(async (item) => {
  await processItem(item); // 待機されない
});

// 正しい:順序立てて実行
for (const item of data) {
  await processItem(item);
}

本番環境で追加で確認すべきポイント

実装を本番投入する前に、以下を確認しておくと、後々のトラブルシューティングが格段に楽になります。

パフォーマンスプロファイリング

Node.js の --prof フラグで V8 プロファイラを実行し、CPU 使用時間の分布を確認します。イベントループが長時間ブロックされている場合、プロファイル結果に顕著に表れます。

node --prof app.js
# 後で以下で分析
node --prof-process isolate-*.log > profile.txt

負荷試験での段階的な検証

開発環境での「快適さ」は参考になりません。本番想定の同時接続数で負荷試験を実施し、応答時間の 95 パーセンタイル値(p95)と 99 パーセンタイル値(p99)を測定します。

メモリリークと遅延の関連性

イベントループがブロックされると、その間に到着したリクエストはメモリ上のキューに蓄積されます。これが長時間続くとメモリ使用量が増大し、ガベージコレクションが頻繁に発動する悪循環に陥ります。

誰に向いているか、どんな案件で採用しやすいか

この設計判断は、特に以下のような案件で重要になります:

  • 高頻度の API リクエストを処理するバックエンド ─ 旅行予約サイトの検索 API、商品在庫照会など
  • 複数の外部 API 呼び出しを組み合わせるミドルウェア ─ 応答待機中に他のリクエストを処理する必要がある
  • リアルタイム性を求められるシステム ─ チャット、ライブ配信、リアルタイム通知など
  • マイクロサービス構成 ─ サービス間の通信遅延を最小化する必要がある

逆に、CPU集約的な処理が主体の場合(画像処理、大規模データ分析など)は、Node.js そのものが適切でないケースもあります。その場合は Worker Threads の採用や、処理の切り出しを検討すべきです。

まとめ:イベントループ特性を設計に組み込む

Node.js の単一スレッド・イベント駆動モデルは、I/O 待機が多い Web アプリケーションに最適化されています。しかし、その特性を無視して同期的な重い処理を実装すると、本番環境の「並行性」という条件下で初めて問題が顕在化します。

開発環境での快適さは、本番環境での安定性の保証にはなりません。実装段階で「このコードはイベントループをブロックしないか」という問い立てを習慣化することが、予期しない遅延を防ぐ最も実用的な対策です。