🍲

ライブ配信のキャンペーン機能を実装したら起きた大規模負荷障害とその対策

に公開

SKIYAKI Tech Blog Advent Calendar 2025 の2日目記事です!


はじめに

この記事では、ライブ配信プラットフォームにおいてキャンペーン機能を実装した際に発生した大規模な負荷障害と、その原因分析、対策について共有します。リアルタイム性が求められる機能において、どのような設計ミスが致命的な障害を引き起こすのか、実例を通じて学んだ教訓をお伝えします。
自分の犯した大変なやらかしについての記事です。

背景:キャンペーン機能とは

今回実装したキャンペーン機能は、期間限定で配信視聴者が投げる「チップ(投げ銭)」のハートマークの柄を変更する機能です。例えば、ハロウィンやクリスマス、バレンタインといった季節イベントに合わせて、通常のハートマークがイベント限定のデザインに変わります。

技術スタック

  • フロントエンド: React
  • リアルタイム通信: Agora RTM (Real-Time Messaging)

何が起きたのか

インシデントの概要

影響範囲: インシデントが起きる箇所は配信機能ですが、結果として負荷がサービス全体に影響を及ぼしていました。

  • 特定の配信にアクセスが集中
  • 別の配信でコメントが正常に表示されない
  • サイトや管理画面全体の動作が重くなる

負荷の規模

  • 0.5時間に50,000リクエスト超を記録
  • これが /campaigns(キャンペーン一覧取得API)と /current_time/now(現在時刻取得API)のそれぞれで発生
  • つまり、合計で0.5時間に100,000リクエスト超

通常時の負荷と比較して、1エンドポイントだけでこの数値は極めて異常な値でした。

原因分析

当初の実装(問題のあったコード)

問題の核心は、コメントが送信されるたびに全視聴者のクライアントからキャンペーン関連のAPIリクエストが発生するという実装でした。

問題点1: コメント受信時の無条件リクエスト

元の実装では、Agoraを通じてコメントを受信した際、チップの有無に関わらずキャンペーンコードの取得処理が走っていました。

// 問題のあった実装の概念
const onChannelMessage = async({channelName, args}) => {
  const [messageData, memberId] = args;
  const parseMessageData = JSON.parse(messageData.text);

  // コメントが送信されるたびに全視聴者がこのリクエストを実行
  const campaigns = await getCampaignCode(); // <- 問題の原因
  const currentCampaignCode = await getCurrentCampaignCodeByTime(campaigns);

  // ... コメント表示処理
}

問題点2: リクエスト頻度の制限がない

キャンペーン一覧取得や現在時刻取得にインターバル制御が一切なく、コメントが送信されるたびにリクエストが発生していました。

なぜこれほどの負荷になったのか

ライブ配信では以下のような特性があります:

  1. 視聴者数が多い: 人気配信では数百〜数千人が同時視聴
  2. コメント頻度が高い: 盛り上がる配信では1秒間に複数のコメントが投稿される
  3. 全員に配信される: Agoraを通じて全視聴者にコメントがブロードキャスト

負荷計算の例

  • 視聴者数: 500人
  • コメント頻度: 1秒に2件
  • 1コメントあたりのリクエスト: /campaigns + /current_time/now = 2リクエスト

1秒間のリクエスト数 = 500人 × 2件/秒 × 2リクエスト = 2,000 req/s

これが30分続けば:

  • 2,000 req/s × 1,800秒 = 3,600,000リクエスト

実際には、さらにチップ投稿時の追加リクエストも発生していたため、負荷はこれ以上になっていたと推測されます。

実施した対策

対策1: キャンペーン一覧の初回取得とキャッシュ

キャンペーン一覧は、配信・視聴ページを開いたタイミングで一度だけ取得し、その後はクライアント側でキャッシュするように変更しました。

// 修正後の実装
const campaignCodesListRef = useRef([]); // キャンペーン一覧をキャッシュ

// 初期化時に一度だけ取得
useEffect(() => {
  load();
  getCampaignCodesList(); // 初回のみ実行
  // ...
}, []);

const getCampaignCodesList = async() => {
  const response = await instance.get(campaignsPath);
  campaignCodesListRef.current = response.data.campaigns;
}

対策2: 現在時刻取得のインターバル制御

現在時刻の取得に1時間のインターバルを設け、頻繁なリクエストを防止しました。

const INIT_GET_CURRENT_TIME_INTERVAL_1H = 1000 * 60 * 60; // 1時間

const getCurrentTime = async (isForce) => {
  if(previousCurrentTimeRef.current === null || requestCurrentTimeTimerRef.current === null || isForce) {
    // 初回または強制取得時のみサーバーへリクエスト
    const currentTime = await requestCurrentTime();
    previousCurrentTimeRef.current = currentTime;

    if(!isForce) {
      requestCurrentTimeTimerRef.current = setTimeout(() => {
        clearTimeout(requestCurrentTimeTimerRef.current);
        previousCurrentTimeRef.current = null;
      }, getCurrentTimeIntervalRef.current);
    }

    return currentTime;
  } else {
    // インターバル内は前回取得した時刻を返す
    return previousCurrentTimeRef.current;
  }
}

対策3: 日付変更時の動的インターバル調整

キャンペーンは日付単位で切り替わるため、日付変更のタイミングで正確に切り替わるよう、インターバルを動的に調整する機能を追加しました。

const checkNextDateIsChanged = (now) => {
  const parsedCurrentTime = new Date(now);
  const nextDate = new Date(parsedCurrentTime);
  nextDate.setDate(parsedCurrentTime.getDate() + 1);
  nextDate.setHours(0, 0, 0, 0);
  const millisecondsUntilNextDate = nextDate - parsedCurrentTime;

  if(millisecondsUntilNextDate < INIT_GET_CURRENT_TIME_INTERVAL_1H) {
    // 1時間以内に日付が変わる場合、その時刻までをインターバルとする
    getCurrentTimeIntervalRef.current = millisecondsUntilNextDate;
  } else {
    getCurrentTimeIntervalRef.current = INIT_GET_CURRENT_TIME_INTERVAL_1H;
  }
}

対策4: チップ送信時のみキャンペーンコード取得

コメント受信時には、チップのheartCountが存在する場合のみキャンペーンコードを取得するよう条件を追加しました。

const onChannelMessage = async({channelName, args}) => {
  const [messageData, memberId] = args;
  const parseMessageData = JSON.parse(messageData.text);

  // チップが含まれる場合のみキャンペーンコードを取得
  if (parseMessageData.hasOwnProperty('content') && rtmClient) {
    const currentCampaignCodeByRequest = parseMessageData['tip']['heartCount'] ?
      await getCampaignCodeByCurrentTime() // <- チップがある場合のみ実行
      : null;

    const parseMessageDataAddedCampainCode = parseMessageData['tip']['heartCount'] ? {
      ...parseMessageData,
      tip: {
        ...parseMessageData.tip,
        campaignCode: currentCampaignCodeByRequest
      }
    } : parseMessageData;

    // ... コメント表示処理
  }
}

対策の効果

これらの対策により、リクエスト数は以下のように激減しました:

  • 修正前: コメント1件につき、視聴者全員が2リクエスト
  • 修正後:
    • 初回ページ読み込み時: 1リクエスト(キャンペーン一覧)
    • 1時間ごと: 1リクエスト(現在時刻)
    • チップ送信時のみ: キャンペーンコード判定(サーバーリクエストなし、ローカル計算)

リクエスト削減率: 99%以上

再発防止策

1. レビュープロセスの強化

動作確認項目の明文化

プルリクエストのテンプレートに以下の項目を追加:

  • ✅ 想定される負荷の見積もりを行ったか
  • ✅ 全視聴者に影響する処理の確認を行ったか
  • ✅ 非期待動作(意図しないリクエスト等)の確認を行ったか

2. リリース後の監視強化

デプロイ後のモニタリング

配信機能の変更が反映された後は、ブラウザの開発者ツールや監視ツールを使って以下を必ず確認:

  • エンドポイントごとのリクエスト数
  • レスポンスタイム
  • エラーレート
  • ネットワークパフォーマンス
  • サーバー負荷(CPU、メモリ使用率

4. フロントエンド実装における追加の改善案

今回の対策に加えて、こんな対策もどうかな…と考えてみました。

改善案1: Service Workerでのキャッシュ活用

Service Workerを使ってキャンペーンデータをキャッシュすることで、ページリロード時もAPIリクエストを削減できます。

// service-worker.js
self.addEventListener('fetch', (event) => {
  if (event.request.url.includes('/campaigns')) {
    event.respondWith(
      caches.match(event.request).then((response) => {
        return response || fetch(event.request).then((fetchResponse) => {
          return caches.open('campaign-cache').then((cache) => {
            cache.put(event.request, fetchResponse.clone());
            return fetchResponse;
          });
        });
      })
    );
  }
});

改善案2: LocalStorageでの永続化

キャンペーン情報をLocalStorageに保存し、ページリロードをまたいでキャッシュを維持します。

const getCampaignCodesList = async() => {
  // LocalStorageから取得を試みる
  const cached = localStorage.getItem('campaign_codes_list');
  if (cached) {
    const { data, timestamp } = JSON.parse(cached);
    // 5分以内のキャッシュなら使用
    if (Date.now() - timestamp < 5 * 60 * 1000) {
      campaignCodesListRef.current = data;
      return;
    }
  }

  // APIから取得してキャッシュ
  const response = await instance.get(campaignsPath);
  campaignCodesListRef.current = response.data.campaigns;
  localStorage.setItem('campaign_codes_list', JSON.stringify({
    data: response.data.campaigns,
    timestamp: Date.now()
  }));
}

改善案3: デバウンス/スロットルの活用

短時間に複数回呼ばれる可能性のある処理に対して、デバウンスやスロットルを適用します。

import { throttle } from 'lodash';

// 1秒に1回までしか実行されないように制限
const throttledGetCampaignCode = throttle(
  getCampaignCodeByCurrentTime,
  1000,
  { leading: true, trailing: false }
);

学んだ教訓

1. リアルタイム配信における「全員が実行」の恐怖

通常のWebアプリケーションでは、APIリクエストは「ユーザーのアクション」に紐づきます。しかし、リアルタイム配信では他人のアクション(コメント送信)が、全視聴者のAPIリクエストのトリガーになるという特殊性があります。

この特性を理解せずに実装すると、今回のような負荷障害を引き起こします。

2. 「動く」と「本番で耐える」は別物

開発環境や少人数でのテストでは正常に動作していても、本番環境の規模では致命的な問題になることがあります。

  • スケーラビリティ: 視聴者数が10倍、100倍になったときにどうなるか
  • 継続性: 30分、1時間と長時間稼働し続けたときの挙動
  • 同時性: 多数のユーザーが同時に同じ操作をしたときの影響

3. 監視の重要性

今回、負荷に気づくのが遅れた原因は、デプロイ後の通信状態を確認していなかったことです。

「変更をリリースしたら、必ず影響を確認する」という習慣が、早期発見・早期対応につながると強く感じています。

4. データの性質に応じた取得戦略

今回のキャンペーンデータは以下の特性がありました:

  • 変更頻度が低い: 数日〜数週間は同じキャンペーンが続く

このようなデータに対して、コメントのたびにリクエストするのは明らかにオーバーエンジニアリングでした。
データの性質を理解し、適切な取得戦略を選択する。← これが大事でした。

まとめ

今回のインシデントは、リアルタイム配信という特殊な環境における設計の難しさを改めて認識させられる出来事でした。

0.5時間で50,000リクエストという数字は、設計上の小さなミスが、どれほど大きな影響を及ぼすかを示しています。

しかし、このインシデントを通じて、以下のような貴重な学びを得ることができました:

  • リアルタイム配信における負荷の特性理解
  • Reactにおけるクライアント側キャッシュ戦略(useRefの活用)
  • setTimeoutを使ったインターバル制御の実装
  • フロントエンドパフォーマンスの監視の重要性

今後は、これらの教訓を活かし、より堅牢で高品質なサービスを提供できるよう、チーム全体で改善に取り組んでいきます。

同じような機能を実装される方の参考になれば幸いです。

SKIYAKI Tech Blog

Discussion