LESSON 30分

ストーリー

あなた
LINE、Slack、Discord…チャットアプリは日常だが、リアルタイムで数億人にメッセージを届ける仕組みは奥が深い
高橋アーキテクト
HTTPのリクエスト-レスポンスモデルでは対応できない。サーバーからクライアントにプッシュする仕組みが必要だ

要件の整理

interface ChatSystemRequirements {
  functional: {
    oneToOne: "1対1のチャット";
    groupChat: "最大500人のグループチャット";
    onlineStatus: "オンライン/オフライン状態の表示";
    readReceipts: "既読確認";
    mediaSharing: "画像・ファイルの送受信";
    messageHistory: "メッセージ履歴の検索";
  };
  nonFunctional: {
    deliveryLatency: "p99 < 500ms(1対1)";
    availability: "99.99%";
    messageOrdering: "送信順序を保証";
    deliveryGuarantee: "メッセージのロスなし";
    dau: "5000万DAU";
    concurrentConnections: "1000万同時接続";
  };
}

通信プロトコルの選択

// プロトコル比較
const PROTOCOL_COMPARISON = {
  polling: {
    description: "定期的にサーバーに問い合わせ",
    pros: ["実装が簡単"],
    cons: ["リソース浪費が大きい", "リアルタイム性が低い"],
    verdict: "チャットには不適切",
  },
  longPolling: {
    description: "サーバーが新しいデータまで応答を保留",
    pros: ["ポーリングより効率的"],
    cons: ["接続が頻繁に切れる", "サーバーリソース消費"],
    verdict: "妥協案。WebSocket非対応環境向け",
  },
  webSocket: {
    description: "双方向の持続的TCP接続",
    pros: ["低レイテンシ", "双方向通信", "効率的"],
    cons: ["ステートフル", "ロードバランシングが難しい"],
    verdict: "推奨。リアルタイムチャットに最適",
  },
  sse: {
    description: "サーバーからの一方向ストリーミング",
    pros: ["シンプル", "自動再接続"],
    cons: ["一方向のみ"],
    verdict: "通知には使えるが双方向チャットには不十分",
  },
};

ハイレベル設計

┌──────────────────────────────────────────────────┐
│                                                  │
│  [クライアントA]◄──WebSocket──►[チャットサーバー群]  │
│  [クライアントB]◄──WebSocket──►[チャットサーバー群]  │
│                                   │              │
│                          ┌────────┼────────┐     │
│                          ▼        ▼        ▼     │
│                     [メッセージ] [プレゼンス] [グループ]│
│                      キュー     サービス   サービス │
│                       │          │         │     │
│                       ▼          ▼         ▼     │
│                   [メッセージDB] [Redis] [グループDB]│
│                                                  │
│           [プッシュ通知サービス] ← オフラインユーザー向け │
│                                                  │
└──────────────────────────────────────────────────┘

詳細設計

メッセージ配信フロー

// 1対1メッセージの配信
class ChatService {
  constructor(
    private connectionManager: ConnectionManager,
    private messageQueue: MessageQueue,
    private messageStore: MessageStore,
    private pushNotification: PushNotificationService,
  ) {}

  async sendMessage(message: ChatMessage): Promise<void> {
    // 1. メッセージIDの付与(サーバー側で一意性保証)
    message.id = this.generateMessageId();
    message.timestamp = Date.now();

    // 2. メッセージを永続化
    await this.messageStore.save(message);

    // 3. 受信者がオンラインかチェック
    const recipientConnection =
      this.connectionManager.getConnection(message.recipientId);

    if (recipientConnection) {
      // 4a. オンライン: WebSocketで直接配信
      recipientConnection.send(JSON.stringify(message));
    } else {
      // 4b. オフライン: キューに入れ、プッシュ通知を送信
      await this.messageQueue.enqueue(message.recipientId, message);
      await this.pushNotification.send(message.recipientId, {
        title: message.senderName,
        body: message.content.substring(0, 100),
      });
    }

    // 5. 送信者に配信確認を返す
    const senderConnection =
      this.connectionManager.getConnection(message.senderId);
    senderConnection?.send(JSON.stringify({
      type: 'ack', messageId: message.id, status: 'delivered',
    }));
  }
}

// メッセージの順序保証
interface ChatMessage {
  id: string;            // サーバー生成のグローバルID
  clientId: string;      // クライアント生成のデバイスローカルID(重複排除)
  senderId: string;
  recipientId: string;
  channelId: string;
  content: string;
  timestamp: number;     // サーバータイムスタンプ
  sequence: number;      // チャンネル内のシーケンス番号
}

WebSocket接続管理

// 分散環境でのWebSocket接続管理
class ConnectionManager {
  // サーバーごとにローカルの接続マップを持つ
  private localConnections: Map<string, WebSocket> = new Map();

  // どのユーザーがどのサーバーに接続しているかをRedisで管理
  private registry: RedisClient;

  async registerConnection(userId: string, ws: WebSocket): Promise<void> {
    this.localConnections.set(userId, ws);
    await this.registry.set(`conn:${userId}`, this.serverId, 'EX', 3600);
  }

  async routeMessage(userId: string, message: string): Promise<void> {
    // ローカル接続を確認
    const local = this.localConnections.get(userId);
    if (local) {
      local.send(message);
      return;
    }

    // 他のサーバーに接続している場合、Pub/Sub経由で転送
    const targetServer = await this.registry.get(`conn:${userId}`);
    if (targetServer) {
      await this.pubsub.publish(`server:${targetServer}`, message);
    }
  }
}

オンラインステータス

// プレゼンスサービス
class PresenceService {
  // ハートビート方式でオンライン状態を管理
  async heartbeat(userId: string): Promise<void> {
    // 30秒ごとにハートビートを受信
    await this.redis.set(`presence:${userId}`, 'online', 'EX', 60);
  }

  async isOnline(userId: string): Promise<boolean> {
    const status = await this.redis.get(`presence:${userId}`);
    return status === 'online';
  }

  // 大規模グループでは全員のステータス取得は高コスト
  // → フレンドリスト内のみに制限する
}

まとめ

ポイント内容
プロトコルWebSocketが双方向リアルタイム通信に最適
メッセージ配信オンライン→直接配信、オフライン→キュー+プッシュ通知
接続管理Redis + Pub/Subで分散サーバー間のルーティング
順序保証サーバータイムスタンプ + チャンネル内シーケンス番号

チェックリスト

  • WebSocket、Long Polling、SSEの違いを説明できた
  • メッセージ配信フローを設計できた
  • 分散環境での接続管理方法を理解した
  • オンラインステータスの実装方法を把握した

次のステップへ

次は「ニュースフィードの設計」を学びます。大量のコンテンツを効率的にユーザーに配信するFan-outパターンを深掘りします。


推定読了時間: 30分