ニコニコ生放送に見る Redis 活用ノウハウ

第3回RedisによるWebアプリケーション開発(1)

前回までに、Redisの概要と、コマンドラインクライアントによる基本的なコマンド操作を見てきました。今回からは、Redisのデータ型を使ったアプリケーション開発について、簡単なサンプルコードと、ニコニコ生放送での事例を交えて紹介していきます。

クライアントライブラリを用いた開発

WebアプリケーションからRedisにアクセスするには、言語ごとのクライアントライブラリを導入することになります。公式サイトにライブラリがリストアップされていますので、各々の環境に合ったものを探してください。

ニコニコ生放送では、PHPによる開発の場合にはphpredisを、Javaで実装している検索サーバーなどではJedisJRedisを使っています。

今回から掲載するサンプルコードでもこれらのライブラリを使っていきますので、他のライブラリを導入される方はそれぞれ対応するAPIに置き換えてご覧ください。

LIST型の構造

RedisのLIST型は、文字列要素のリストを保持することができます。一般的なプログラミング言語が備えている配列やリストといったデータ構造と同様に、要素の追加・削除、範囲を指定した取得、ソートなどの操作がコマンドとして用意されています。

LIST型の内部構造は連結リストであるため、リストの先頭と末尾への操作は高速ですが、インデックスを指定したアクセスは、その位置に比例した処理時間がかかります。したがって、LIST型を使ったアプリケーションロジックで最大限の性能を得るには、アクセスが先頭と末尾に対して行われるよう考慮しておく必要があります。

Redisのコマンドリファレンスには各コマンドの時間計算量が記載されていますので、これを参考に最適なコマンドを選択してください。

LIST型が有効なケース

前述の特徴から、開発においてLIST型がマッチするのは次のようなケースだと考えられます。

  • 要素の追加が高頻度に発生する
  • 要素が時系列(追加順)に表示される
  • 最近追加された要素にアクセスが集中する

例えば、Twitterのタイムラインや、ソーシャルメディアでのメッセージ受信箱、ユーザーのアクティビティログのような、タイムセンシティブな情報を格納するのに向いているといえます。

LIST型に向かないケース

連結リストであるため、先頭と末尾以外の更新が頻発する場合に不利になります。このため、リストに格納するのは主キー(等の不変情報)だけにしておき、その他の付帯情報(タイトルやデスクリプション)は、別途、RedisのハッシュやMySQLに格納しておくことをおすすめします。それらをリストに含めてしまうと、更新が発生した際にリストから該当の情報を探し出さなくてはならず、更新コストが高くつくからです。

ニコニコ生放送での利用例

ユーザー生放送一覧ページ

ニコニコ生放送システムでのLIST型の応用として代表的なのが、ユーザー生放送一覧ページです。ユーザーによる放送はピークタイムで分間数百番組が作成されるため、なるべく高頻度で内容を更新する必要があります。Redis導入以前は、バッチ処理にてMySQLから数千番組のリストを構築し、ページ数分に分割してmemcachedに載せるという手法を取っていました。現在はリスト全体をRedisのLIST型に載せることでバッチ処理の高速化と更新頻度の向上を図っています。

図1 ニコニコ生放送のユーザー生放送一覧(赤枠囲み部)
図1 ニコニコ生放送のユーザー生放送一覧(赤枠囲み部)

リアルタイムログ機能

リアルタイムログ機能は、フロントウェブサーバー群で発生したイベント情報(エラーやシステムアラート、デバッグメッセージ)のメッセージをRedisのリストに集約し、管理用ウェブサーバーの画面で一元的かつリアルタイムに監視する機能です。この種のイベント情報は集中的かつ大量に発生する可能性があるため、高頻度の追加に耐えうるLIST型の特性を生かしたものとなっています。

図2 リアルタイムログ機能の構成
図2 リアルタイムログ機能の構成

リストの構築とページネーションの実装

あるコンテンツのページネーション機能つきの一覧ページを構築するコードを例として、RedisのLIST型の操作について説明します。

リストの構築

要素追加はLPUSH/RPUSHコマンドによって行います。LPUSHはリストの先頭に、RPUSHは末尾に要素を追加します。

図3 LPUSHコマンドとRPUSHコマンド
図3 LPUSHコマンドとRPUSHコマンド

次のコードは、引数としてコンテンツIDをひとつだけ受け取り、リストの先端に追加します(分かりやすさのため、エラーハンドリング等は省略しています⁠⁠。

<?php
 function contents_list_push($id) {
   $redis = new Redis();
   $redis->connect('localhost', 6379);
   $redis->lpush('contents_list', $id);
   $redis->close();
 }

複数の要素を一度に追加する場合には、パイプラインを使うことで、リクエストとレスポンスの往復にかかる時間を削減できます。

<?php
 
 function contents_list_push_array($idarray) {
   $redis = new Redis();
   $redis->connect('localhost', 6379);
 
   $pipe = $redis->multi(Redis::PIPELINE);
   foreach ($idarray as $id) {
     $pipe->lpush('contents_list', $id);
   }
   $pipe->exec();
   $redis->close();
 }

phpredisでは、multiメソッドの引数でRedis::PIPELINEを指定することでパイプラインを作成できます。無指定の場合、MULTI/EXECの動作になります。

ページネーションの実装

LRANGEコマンドは、リスト内の指定したインデックス範囲の要素を取得できます。

図4 LRANGEコマンドの取得範囲(数値はインデックス)
図4 LRANGEコマンドの取得範囲(数値はインデックス)

次のコードは、ページ番号とページ当たりの要素数を指定して、該当範囲のコンテンツIDを配列で取得しています。

<?php
 
 function contents_list_page($page, $size = 20) {
   if ($page <= 0 || $size <= 0) {
     return array();
   }
   $redis = new Redis();
   $redis->connect('localhost', 6379);
 
   $start = ($page - 1) * $size;
   $end = $start + $size - 1;
   $list = $redis->lRange('contents_list', $start, $end);
   $redis->close();
   return $list;
 }

LRANGEコマンドで取得できる部分リストは、終点として指定したインデックスの要素を⁠含む⁠ことに注意してください。また、始点と終点にはマイナス値を指定できます。-1は末尾を表すので、リスト全体を取得するには次のようにします。

   $list = $redis->lRange('contents_list', 0, -1);

リストのトリミング

Redisはオンメモリデータストアですので、リストに要素を追加し続けるだけではいつか容量の限界に達してしまいます。したがって、何らかの方法でリストのサイズを制限する必要があります。

次のコードは、LTRIMコマンドによってリストから古い要素を削除します。LTRIMコマンドは、リストのサイズを指定した範囲でトリミングします。

<?php
 
 function contents_list_trim($size = 1000) {
   $redis = new Redis();
   $redis->connect('localhost', 6379);
   $redis->lTrim('contents_list', 0, $size - 1);
   $redis->close();
 }

前述したニコニコ生放送の「リアルタイムログ機能」では、直近1,000件のメッセージのみ参照できればよいため、定期的に1,000件でトリミングすることによって、リストが無制限に長くなるのを防いでいます。

リスト全体のアトミックな差し替え

LPUSHやRPUSHを使った更新では、リストの先頭や末尾から1要素づつ追加することになりますが、場合によってはリスト全体をアトミックに更新したい場合もあります。

こんなときに便利なのはRENAMEコマンドです。空のテンポラリリストに更新後の内容をゼロから構築し、リストが完成したら、RENAMEコマンドによってターゲットのリストに「上書き」してしまいます。

<?php
 
 function contents_list_replace($new_idarray) {
   $redis = new Redis();
   $redis->connect('localhost', 6379);
 
   $tmp_key = /* ここでユニークなテンポラリキー名を生成 */;
   foreach ($new_idarray as $id) {
     $redis->lpush($tmp_key, $id);
   }
 
   $redis->rename($tmp_key, 'contents_list');
   $redis->close();
 }

RENAMEコマンドはキー名を変更する機能ですが、変更後のキー名が存在する場合は上書きするので、既存のキーの内容が一瞬で差し替わったように見えるわけです。

前述のユーザー生放送一覧ページでは、この手法を使って、Redis上のアクティブなリストの内容を、バッチ処理で生成した新しいリストの内容でアトミックに差し替える処理を実装しています。

LIST型のソート

SORTコマンドは、他のコマンドと比べると指定可能な修飾子が多く、やや複雑です。

前のコード例と同様に⁠contents_list⁠にコンテンツIDのリストが格納されているとした場合に、単にIDでソートした結果を得るには以下のように指定します。

SORT contents_list

コンテンツの属性情報でソートしたいというケースを考えてみます。各コンテンツIDごとに、そのアクセス回数が次のフォーマットのキー名でRedisに格納されているものとします。

access_count:コンテンツID

このとき、外部キーパターン⁠access_count:*⁠を指定することによって、コンテンツIDのリストをアクセス数でソートした結果を得ることができます。

SORT contents_list BY access_count:*

外部キーパターンを指定すると、リストの各要素の値そのものではなく、対応する外部キーの値(この例ではアクセス数)でソートすることができます。

なお、外部キーを使って正しくソートするには、それらのキーが同じRedisサーバー上に存在している必要があります。複数のRedisサーバーに分散して格納している場合は注意してください。

次の例は、SQLではおなじみのLIMITやDESCと組み合わせ、アクセス数の多いトップ3を取得しています。

SORT contents_list BY access_count:* LIMIT 0 3 DESC

他にも、ソートした結果を別のキーに格納するSTORE修飾子や、文字列要素を辞書式に並べる為のALPHA修飾子などがあります。詳細な仕様は公式のリファレンスを参照してください。

リストのサイズが非常に大きい場合など、ユーザーのリクエストのたびにソートするのではなく、あらかじめソートした状態で格納しておきたい場合があります。そのときは、LIST型に対してSORTコマンドを使う代わりに、SORTED SET型に格納することを検討してください(実際のところニコニコ生放送では、ソートが必要な箇所の大半はSORTED SETで実装しています⁠⁠。

LIST型を使ったキューの実装

LIST型は連結リストとして実装されているため、キューを構築するのに適しています。GitHubが開発したResqueというバックグラウンド処理ライブラリでは、内部のキューの実装としてRedisが使われているようです。

ニコニコ生放送では、ユーザーのタグ編集情報をタグ検索インデックスサーバーに通知する機構で、LIST型によるキューを実装しています。

ブロッキング形式のキュー処理

LIST型をキューとして利用する場合、通常、キューから要素を取得するためにRPOPコマンド(またはLPOP)を使います。

図5 LIST型によるキューの実装
図5 LIST型によるキューの実装

BRPOPコマンドは、RPOPコマンドのブロッキングバージョンです。リストが空の場合は指定したタイムアウトまで処理がブロックされ、要素が追加されると直ちにその値を返します。このブロッキング形式のポップを使えば、効率のよいキュー処理用のデーモンプログラム等の開発が可能になります。

次のコードは、JavaのRedisクライアントJedisによって、キューからブロッキング形式で要素をポップして処理するプログラムです。

public void run() {
  Jedis jedis = new Jedis("localhost");
  while (true) {
    // BRPOPコマンドでキー"myqueue"から要素を取得する。
    // タイムアウトは5秒に設定。
    List<String> popped = jedis.brpop(5, "myqueue");
    if (popped != null) {
      // 戻り値の最初の要素はキー名
      String key = popped.get(0);
      // 戻り値の2番目の要素は値
      String value = popped.get(1);
      
      doSomething(key, value);
      
    } else {
       System.out.println("timeout!");
    }
  }
}

より堅牢なキュー処理

上記のコードでは、キューからメッセージをポップした直後にプログラムがクラッシュした場合、その内容が失われてしまいます。より堅牢なキュー処理が求められる場合、RPOPLPUSHコマンドが利用できます。このコマンドは、リストの末尾からポップすると⁠同時⁠に別途指定したリストの先頭に同じ要素を追加します。

図6 RPOPLPUSHコマンドの動き
図6 RPOPLPUSHコマンドの動き

元のリストから取り出した要素をバックアップ用のリストに格納しておけば、万が一クラッシュしても、バックアップ用のリストから再処理できるわけです。もちろん、処理に成功した要素はバックアップ用のリストから削除しておきます(LREMコマンドが使えます⁠⁠。

バージョン2.2からはRPOPLPUSHのブロッキングバージョンとしてBRPOPLPUSHが追加され、BLPOPのようなブロッキング形式のコーディングが可能になります。

まとめ

今回は、Redisの代表的なデータ型であるLIST型に注目し、実際のWebアプリケーション開発で頻繁に登場するページネーションやソート、キュー処理などの応用例を見てきました。次回は、SET型やSORTED SET型などについて詳しく紹介する予定です。

おすすめ記事

記事・ニュース一覧