こんにちは、AmebaLIFE事業本部の「ピグパーティ」でバックエンドエンジニアをしている松岡穂高です。

今回は、RedisのPub/Subを使用して、「ピグパーティ」のライブ配信機能(以下、観覧機能)において高い同時視聴数でも安定して配信できるように改善をしたため、その事例を紹介したいと思います。

ピグパーティとは

ピグパーティとは_1ピグパーティとは

ピグパーティ」とは、仮想空間内でなりきりたいアバター(ピグ)を作って、ピグのきせかえや自分のお部屋のもようがえをしながら楽しむ、アバターSNSサービスです。

背景

観覧機能は、もようがえをしたお部屋でパーティを開催し、ボイスチャットやコメント機能を使って配信者と視聴者(以下、観覧ユーザー)が交流するための機能です。

普段の運用では負荷に問題はありませんでしたが、最近ではVTuberとのコラボイベントで大規模な観覧パーティを行う機会が増えました。既存の仕様では負荷に耐えることが難しくなってきたため、アーキテクチャの根本的な見直しが必要となりました。

詳細な背景についてはこちらの記事もご覧ください。

従来のアーキテクチャと課題点

ピグパーティではGoogle CloudのGoogle Kubernetes Engineを使用しており、通常のAPIを返す「APIサーバー」と、チャットやエリア移動などのリアルタイム通信を行う「チャットサーバー」の2種類で構成されています。今回は後者のチャットサーバーに焦点を当てて話を行います。

従来のアーキテクチャ

従来のアーキテクチャ

 

こちらは既存のアーキテクチャです。次のような流れでコメントが配信されます。

1️⃣ まず、観覧ユーザーがコメントを投稿します。
2️⃣ コメントが観覧エリアのPodからパーティエリアのPodへ送信されます。
3️⃣ パーティエリアに送信されたコメントは、配信者へブロードキャストされ、同時に各観覧エリアのPodへ配信されます。
4️⃣ 観覧エリアへ配信されたコメントは、観覧ユーザーへブロードキャストされます。

パーティが開催されているエリアと観覧エリアは別のPodに存在しており、従来のアーキテクチャでは、パーティエリアから全ての観覧エリアへデータを配信する形となってます。

しかしこの設計には「観覧エリアのPodがスケールすればするほど、観覧エリアへの配信する回数が増加する」というボトルネックがありました。特にイベント時には、観覧コメントのリクエスト数がかなり増えて負荷が高騰します。

サーバーが落ちてしまうとフェイルオーバーはされるものの、多少のダウンタイムが生じてしまいます。またAPIサーバーなどのステートレスなサーバーとは異なり、チャットサーバーはステートフルなサーバーなので、オンメモリとして保持しているデータが欠損するというリスクもありました。

改善後のアーキテクチャ

改善後のアーキテクチャ

こちらは改善後のアーキテクチャです。次のような流れでコメントが配信されます。

1️⃣ 観覧エリアのインスタンス作成時に、RedisをSubscribeします。
2️⃣ 観覧ユーザーがコメントを投稿します。
3️⃣ 投稿コメントは観覧エリアのPodからパーティエリアのPodへ送信されます。
4️⃣ パーティエリアに送信されたコメントは、配信者へブロードキャストされ、同時にRedisへPublishされます。
5️⃣ Subscribeしている各観覧エリアはコメントを受信し、その後、観覧ユーザーへブロードキャストされます。

改善後のアーキテクチャの大きな変更点は、Redis Pub/Subを使用して配信先のコネクション数を1つに削減したことです。

Redis Pub/Subは、メッセージの送信者(パブリッシャー)と受信者(サブスクライバー)を分離するメッセージングパターンの一つです。以下にRedis Pub/Subの基本的な流れを説明します。

  1. チャンネルのサブスクライブ:

    • サブスクライバーは、特定のチャンネルに対してサブスクライブ(購読)します。これにより、そのチャンネルに送信されるメッセージをリアルタイムで受け取れるようになります。
    127.0.0.1:6379> SUBSCRIBE channel1
    1) "subscribe"
    2) "channel1"
    3) (integer) 1
    
  2. メッセージのパブリッシュ:

    • パブリッシャーは、特定のチャンネルに対してメッセージをパブリッシュ(公開)します。チャンネルにメッセージを公開すると、そのチャンネルをサブスクライブしている全てのクライアントにメッセージが配信されます。
    127.0.0.1:6379> PUBLISH channel1 "Hello World"
    (integer) 1
    
  3. メッセージの受信:

    • サブスクライブしたクライアントは、そのチャンネルにパブリッシュされたメッセージをリアルタイムで受信します。
    127.0.0.1:6379> SUBSCRIBE channel1
    1) "subscribe"
    2) "channel1"
    3) (integer) 1
    1) "message"
    2) "channel1"
    3) "Hello World"
    

このPub/Subシステムを導入することで、観覧エリアのPodがスケールしたとしても、パーティエリアからの配信回数はかなり軽減されました。

しかし、Pub/Subを導入しても観覧コメントが殺到すると配信が捌ききれなくなるという問題は依然として残っています。この課題に対応するために、パーティエリアがコメントを受信した直後にRedisへPublishするのではなく、一定数のコメントをキューにまとめてからPublishする方式を採用しました。これにより、パーティエリアがRedisへPublishする回数が減少し、負荷を効果的に抑えることができます。

以下は、実際の実装を元に簡略したサンプルコードで、言語はNode.jsになります。

パーティエリアのクラス

import Redis from 'ioredis';

// Redis クライアントのセットアップ
const redisPublisher = new Redis();

export class PartyArea {
  private areaId: string;
  
  constructor(areaId: string) {
    this.areaId = areaId;
  }
  
  /** コメント送信のためのタイマー */
  private commentTimer: NodeJS.Timer | undefined;
  
  /** コメント送信用のキュー */
  private commentQueue: { content: object }[] = [];
  
  /** コメント送信の間隔(ミリ秒単位) */
  private commentInterval: number = 1000;
  
  /** コメント送信処理を定期的に実行する */
  public startCommentPublisher(): void {
    this.commentTimer = setInterval(() => this.publishComments(), this.commentInterval);
  }
   
  /** コメントをキューに追加 */
  public addComment(data: object): void {
    this.commentQueue.push({ content: data });
  }
  
  /** キューに溜まったコメントをRedisにまとめてpublish */
  private publishComments(): void {
    if (this.commentQueue.length > 0) {
      const comments = this.commentQueue.splice(0); // キューからコメントを取り出し、キューを空にする
      redisPublisher.publish(this.areaId, JSON.stringify(comments)); // コメントをまとめてpublishする
    }
  }
}

概要

  • このファイルには、パーティエリアでリアルタイムにコメントを管理し、Redisにパブリッシュするためのクラス PartyArea が定義されています。partyArea インスタンスは観覧コメントをキューに追加し、定期的にまとめてRedisにパブリッシュします。

主な機能

  • startCommentPublisher: 観覧が開始されるとコメント送信のためのタイマーを発火します。
  • addComment: コメントをキューに追加します。
  • publishComments: キューに溜まったコメントをRedisのチャンネルにまとめてパブリッシュします。

観覧エリアのクラス

import Redis from 'ioredis';

export class WatchingArea {
  private partyAreaId: string;
  private redisSubscriber: Redis;

  constructor(partyAreaId: string, redisSubscriber: Redis) {
    this.partyAreaId = partyAreaId;
    this.redisSubscriber = redisSubscriber;
  }
   
  /** パーティエリアをサブスクライブする */
  public subscribeToPartyArea(): void {
    this.redisSubscriber.subscribe(this.partyAreaId);

    // データを受信したらユーザーへブロードキャストする
    this.redisSubscriber.on('message', (_channel, message) => {
      const data = JSON.parse(message);
      
      // データが配列の場合(観覧コメントの場合)
      if (Array.isArray(data)) {
        data.forEach((item) => {
          this.broadcast(item.cmd, item.content);
        });
      } else {
        // データが配列でない場合(観覧コメント以外の場合)
        this.broadcast(data.cmd, data.content);
      }
    });
  }

  // ブロードキャストを行う(仮想のメソッドとして定義)
  private broadcast(cmd: string, content: object): void {
    // 実際のブロードキャスト処理がここに入ります
    console.log(`Broadcasting ${cmd}:`, content);
  }
}

概要

  • このファイルには、パーティエリアからのメッセージを受信し、観覧エリアのユーザーにブロードキャストするためのクラス WatchingArea が定義されています。watchingArea インスタンスはRedisからサブスクライブしてメッセージを受信し、それを各ユーザーに配信します。

主な機能

  • subscribeToPartyArea: Redisの指定されたチャンネルをサブスクライブし、メッセージを待ち受けます。
  • broadcast: 受信したメッセージをユーザーにブロードキャストします。
    import { PartyArea } from './area';
    import { WatchingArea } from './watching_area';
    import Redis from 'ioredis';
    
    // パーティエリアのインスタンス生成
    const partyArea = new PartyArea('example-area-1');
    
    // 観覧エリアのインスタンス生成
    const redisSubscriber = new Redis();
    const watchingArea = new WatchingArea('example-area-1', redisSubscriber);
    
    // パーティエリアをサブスクライブする
    watchingArea.subscribeToPartyArea();
    
    // 観覧機能の開始
    partyArea.startCommentPublisher();
    
    // 観覧コメントの受信処理が入ります(省略)
    // 観覧コメントをキューに格納
    partyArea.addComment({ userId: 'user1', message: 'こんにちは!!' });
    

    概要

    • PartyAreaWatchingArea のインスタンスを生成し、それらが相互に通信する設定を行います。このスクリプトは、パーティエリアが観覧コメントをパブリッシュし、観覧エリアがそれを受信してユーザーに配信する一連の流れを実現します。

    主な処理

    • PartyArea のインスタンス生成と設定。
    • WatchingArea のインスタンス生成とサブスクライブ設定。
    • パーティエリアのコメント送信タイマーの開始。
    • 観覧コメントのキューへの格納。

    このように、Redis Pub/Subを使用することで、アプリケーション間の効率的なリアルタイム通信が実現されます。

    Redis Pub/Subの特徴としては、揮発性がありデータを保存することはできません。つまり、データの配信には優れていますが、持続的な保存には不向きです。もしRedis内にデータを保持しながらデータ配信も行いたい場合は、Redis Streamsを用いる方法があります。しかし、現行の設計では観覧コメントをオンメモリで保持しており、実装が複雑になる可能性があるため、今回はStreamsを選択しませんでした。

    また、他の選択肢としてMQTTブローカーの使用も検討しましたが、Redisはキャッシュサーバーとしても利用しており、パフォーマンス面で大きな差がないため、学習コストが低いRedis Pub/Subを採用しました。

    終わりに

    Redis Pub/Subを用いて配信形式を見直すことで、スケーラビリティの向上と負荷分散を実現することができました。

    従来の観覧人数の限界は2000人でしたが、負荷試験では同時視聴数が2万人規模でも耐えられるようになり、10倍以上の視聴者が参加可能となりました。

    今のユーザー規模では十分耐えられるシステムとなり、実際のライブ配信でも安定して高品質な視聴体験を提供できるようになりました。

    これからもユーザーの期待に応えるために、システムのさらなる改良を続けていきたいです。

    最後までご覧いただきありがとうございました!

2022年新卒入社のバックエンドエンジニアです。 現在はピグ事業部の主力サービスである「ピグパーティ」でSREリーダーをしています。