Node.jsとRedis Pub/Subによるリアルタイム通知システムの構築:深夜2時のデータベース救出劇

Development tutorial - IT technology blog
Development tutorial - IT technology blog

実例:’setInterval’が静かにデータベースを破壊する時

深夜2時ちょうど、スマートフォンのバイブレーションが鳴り響きました。Prometheusからのアラートが止まりません。RDSデータベースのCPU使用率が98〜100%に張り付いています。コミュニティグループでは、通知が遅い、あるいは届かないといったユーザーの不満が噴出し始めました。ログを調査した結果、見覚えのある犯人が浮上しました。HTTPポーリングです。

「いいね」やコメントが付くたびに通知が飛ぶ機能を実装するため、以前の開発チームは「お手軽な」解決策を選んでいました。フロントエンドから5秒おきにAPI GET /notifications を呼び出していたのです。500ユーザー程度ならシステムは余裕を見せていましたが、同時接続ユーザーが1万人に達した途端、データベースは新着データの有無をチェックするためだけに毎分12万回以上のリクエストを捌かなければならなくなりました。これは、大規模システムを扱う際に少なくとも一度は直面するパフォーマンス上の大惨事です。

なぜポーリングはスケーラビリティの解決策にならないのか?

基本的にHTTPは伝統的なリクエスト・レスポンス型のプロトコルです。クライアントが尋ねて初めて、サーバーが答えます。リアルタイム通知において、この絶え間ない「問い合わせ」は3つの深刻な問題を引き起こします。

  • 圧倒的な無駄: リクエストの99%は空の配列 [] を受け取るためだけに行われます。しかし、サーバーは認証、DBクエリ、JSONパッケージングといった処理コストを支払い続けます。
  • 不快な遅延: ポーリング間隔を10秒に設定すると、ユーザーは遅延を確認するまでに最大10秒待たされる可能性があります。
  • 帯域の浪費: HTTPリクエストのヘッダーは小さいですが、数百万回の呼び出しが重なれば、クラウドの利用料金を跳ね上げる要因になります。

システムには能動的な「プッシュ」メカニズムが必要だと確信しました。何かが起きた瞬間に、サーバー側から声をかけなければならないのです。

検討したいくつかのアプローチ

その際、最も現実的な選択肢をいくつか検討しました。

1. 純粋なWebSockets (Socket.io)

クライアントとサーバー間に全二重(full-duplex)の通信経路を開きます。新しいメッセージがあれば、サーバーが socket.emit() を呼ぶだけで完了です。非常に高速ですが、一つ大きな壁にぶつかります。それは 水平スケーリング(Horizontal Scaling)の難しさ です。

ロードバランサーの背後で2つのNode.jsインスタンス(サーバーAとサーバーB)を実行していると仮定しましょう。ユーザー1がサーバーAに接続しており、通知処理のロジックがサーバーBで動いている場合、サーバーBはユーザー1を見つける手段がなく、ソケットを送信できません。結果として、通知は完全に「迷子」になってしまいます。

2. Server-Sent Events (SSE)

サーバーからクライアントへの単方向通信であるため、WebSocketよりも軽量なソリューションです。しかし、クラスター内のインスタンス間で状態を共有できないという問題は依然として解決されません。

救世主の登場:Node.js + Socket.io + Redis Pub/Sub

水平スケールの問題を解決するには、すべてのNode.jsインスタンスが送受信できる中間的な「郵便配達員」が必要です。RedisのPub/Sub(Publish/Subscribe)こそが、欠けていた完璧なピースでした。運用フローは以下の通りです。

  1. ユーザーはロードバランサーに従い、任意のインスタンス(A、B、Cなど)にSocket.ioで接続します。
  2. 新しいイベントが発生すると、バックエンドはRedisの共通「チャンネル」にメッセージを パブリッシュ(Publish) します。
  3. そのチャンネルを サブスクライブ(Subscribe) しているすべてのNode.jsインスタンスが同時にメッセージを受け取ります。
  4. ターゲットユーザーと直接接続を維持しているインスタンスが、ブラウザに対してデータの emit を実行します。

この仕組みにより、Node.jsサーバーを2台で運用していても200台で運用していても、システムはスムーズに動作します。

実践的な実装:アイデアからコードへ

まず、Redisサーバーが必要です。Dockerを使えば30秒で環境を構築できます。

docker run -d --name redis-notify -p 6379:6379 redis

次に、Node.jsプロジェクトを初期化し、標準的なライブラリをインストールします。ここでは安定性が高くクラスター対応も万全な ioredis を使用します。

npm install express socket.io ioredis

こちらが、あの日「火消し」のために使用した最小限の server.js ファイルです。


const express = require('express');
const http = require('http');
const { Server } = require('socket.io');
const Redis = require('ioredis');

const app = express();
const server = http.createServer(app);
const io = new Server(server);

// Redis接続:パブリッシュ用とサブスクライブ用の2つを用意
const redisPub = new Redis(); 
const redisSub = new Redis(); 

// 'notifications' チャンネルの購読を開始
redisSub.subscribe('notifications', (err) => {
    if (err) console.error('Redis接続に失敗しました:', err);
});

// Redisからメッセージを受け取り、適切なクライアントにプッシュ
redisSub.on('message', (channel, message) => {
    if (channel === 'notifications') {
        const data = JSON.parse(message);
        // ユーザー専用のルームに通知を送信
        io.to(`user:${data.userId}`).emit('new_notification', data.content);
    }
});

io.on('connection', (socket) => {
    const userId = socket.handshake.query.userId;
    if (userId) {
        socket.join(`user:${userId}`);
        console.log(`ユーザー ${userId} がオンラインになりました。`);
    }
});

// 他のバックエンドサービスからのイベントをシミュレートするエンドポイント
app.get('/test-notify', (req, res) => {
    const { userId, message } = req.query;
    const payload = { userId, content: message, time: Date.now() };
    
    redisPub.publish('notifications', JSON.stringify(payload));
    res.send('Redisに通知をパブリッシュしました!');
});

server.listen(3000, () => console.log('システムはポート3000で稼働中です'));

サービス間で複雑なデータを扱う際、私はよく toolcraft.app/ja/tools/developer/json-formatter を使ってJSON構造を素早くチェックしています。重い拡張機能をインストールしたり、面倒なターミナルコマンドを打つことなく、フィールドの欠落やデータ型の誤りを即座に発見するのに役立ちます。

結果:なぜこの方法がこれほど効果的なのか?

デプロイ後、データベースのCPUグラフは100%から5〜7%前後まで一気に低下しました。私たちは3つの大きな目標を達成しました。

  • ポーリングの廃止: データベースはもはや、毎分何百万回もの「無意味な」問い合わせに答える必要がありません。
  • リソースの最適化: 実際に新しいデータがRedisにパブリッシュされた時のみコードが実行されます。
  • 柔軟なアーキテクチャ(疎結合): 通知システムは完全に独立しました。Python、Go、PHPなどからRedisにパブリッシュすれば、Node.jsが通常通り受け取ってソケットを介してプッシュします。

システムの遅延に頭を抱えているなら、高価なサーバーのスペックアップを急ぐ必要はありません。RedisとSocket.ioを用いたデータ通信の考え方を変えるだけで、深夜に電話が鳴ることを心配せずにぐっすり眠れるようになるはずです。

Share: