pixiv insideは移転しました! ≫ https://inside.pixiv.blog/

pixivのデータストア/キャッシュ戦略 その2

先月末にwww.pixiv.netのバージョン管理をSubversionからGitに移行できてホッとしているインフラ兼ソフトウェアエンジニアのbokkoです。

pixivのSubversionリポジトリには\( ^ o ^ )/ディレクトリなるものが存在していて、開発が終了したプロジェクトやもう使われなくなったソースコードはremoveされるのではなく、 この\( ^ o ^ )/ディレクトリにmoveされます。

www.pixiv.netもGitに移行した後、{trunk,branches,tags}のすべてを\( ^ o ^ )/へmoveしましたが、あまりにも巨大過ぎて「svn move -> commit」が完了するのに1時間半かかりました。おそらく僕の人生の中で最も時間のかかったコミットとして全僕の中で語り継がれるのではないかと思います。

最近は弊社でも「最初に触れたバージョン管理システムはGit」という人が増えてきたこともあって、思い切って移行に踏み切った次第ですが、 先々週の社内勉強会で「CVS使ったことある人、挙手!」と呼びかけて20~30人中2人しか手を挙げなかった時は自分も歳を取ったものだと思いました(でもまだ二十代です!)。

ああ、長話が過ぎました。それでは前回の記事で予告したとおり、今回はpixivにおける実際のデータストアの運用やキャッシングの方式について解説していきます。

pixivのデータストア/キャッシングレイヤー

pixivのデータストア/キャッシングレイヤーはざっくりと図で表すと以下のようになっています。

最下層から順に説明していきます。

MySQL

MySQLはpixivのデータストア/キャッシングレイヤーの最下層に位置するデータストアで、すべてのデータの第一ソースです。

pixivではデータが大きすぎてメモリに収まりきらない、問題の切り分けを容易にするといった理由からMySQLのデータストアを用途毎に複数のグループに分割(Sharding)しており、全部合わせると大体70〜80台くらいあります。

すべてのリクエストを前段のKyotoTycoonやAPCでキャッシュせずにさばくことができればアプリケーションはより簡素なものとなり、開発スピードを向上させることができますが、 それなりに規模の大きい現在のpixivでは以下の理由からそれは非常に困難です。

  • さまざまな歴史的経緯により、テーブルスキーマがすべての機能にとって最適化されているわけではない
  • 一度決めたテーブルスキーマ(データ構造)をカジュアルに変更するのは難しい、あるいは非常に時間がかかる
  • データ容量の関係ですべてのデータをオンメモリで処理することができないのでI/Oが刺さる可能性がある

そのため、pixivのアプリケーションコードにはできる限り前段のAPCあるいはKyotoTycoonでデータを返す最適化が施されています。

KyotoTycoon

KyotoTycoonには各ユーザ固有のデータキャッシュや毎回MySQLにアクセスしてると構築に時間のかかるデータ(例:元データが複数のテーブルやグループに分散している場合)、あるいはバッチによって生成されるデータが格納されます。

KyotoTycoonには利用できるデータベースの種類が豊富にありますが、pixivでは主にHashDBやTreeDB、CacheDBを利用していて、台数はこれまた用途毎に10台前後あります。 そして用途毎にactiveとstandbyの2台セットになっていて、各セットが互いにレプリケーションし合うマルチマスタ構成となっています。

APC

APC(Alternative PHP Cache)はPHPの中間コードキャッシュおよび最適化を行うための拡張モジュールですが、APCの共有メモリ領域は一種のKVSのように扱うことができます。

pixivでは主に全ユーザに共通のデータ(例:ランキング、登録されているイベントの件数等)をPHPアプリケーションサーバ(以下AP)上のAPCの共有メモリ領域にキャッシュすることで 後段のKyotoTycoonやMySQLへのアクセスを極力減らすようにしています。

また、APのローカル内でのみ通信を行うのでネットワークトラフィックを減らす効果もあります。

何をどこへどうキャッシュすべき/あるいはキャッシュすべきでないのか?

さて、これまでに話した内容を実際にシステムに適用しようとするとデータの特性によってキャッシュの仕方やキャッシュする場所を適切に選択する必要があります。 しかし、スキルのバラつきや専門分野の差異、システムの規模を考えるとアプリケーションを開発するエンジニア全員がこのことを考慮しながら 新機能の開発やアプリケーションのメンテナンスを素早く適切に行うのは難しいものがあります。

例えばアプリケーションを開発するエンジニアは KyotoTycoonやMySQLにアクセスするコードを書く際に常に以下のことを考えなければなりません。

  • APCを使うべきか否か?
  • どのKyotoTycoonサーバに接続すべきか?
  • キャッシュの期限(expireの値)はどの程度が適切か?(APCとKyotoTycoonでそれぞれ個別に!)

こういった問題に対処するため、pixivではアプリケーションを開発するエンジニア達がどこにどうキャッシュすべきか、あるいはそうでないのか極力考えなくてもいいような ライブラリインタフェースをKVSClientという形で提供しています。また、多段キャッシュを扱うようなコードは複雑になりがちで後々のメンテナンスが大変になりやすいのでそのあたりの負担を和らげる必要もあります。

KVSClientによるキャッシュへの透過的なアクセス

KVSClientは筆者が開発した複数のKVSレイヤーに対して透過的にアクセスするためのクライアントライブラリです(PHPで書かれています)。 KVSClientの説明をする前にまず、KVSClientを使わずにpixivのMySQL、KyotoTycoon、APCの3つから構成されるデータストア/キャッシュレイヤーから データを取り出すコードをPHPで書いてみます。(複雑さを和らげるためエラーや例外処理は省いています)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
$key = 'common_user_data_key';
$local_expire  = 600;
$remote_expire = 3600;
// ローカルのキャッシュにアクセス
$val = apc_fetch($key);
if ($val === false) {
    // ローカルにキャッシュがないのでリモートのキャッシュ取得を試みる
    $memc = new Memcache;
    $memc->connect(KT_HOST, KT_PORT TIMEOUT);
    // リモートのキャッシュを取得
    $val = $memc->get($key);
    // リモートのキャッシュがあればローカルにキャッシュする
    if ($val !== false) {
        apc_store($key, $val, $local_expire);
    }
}
// リモートキャッシュもなかったら
if ($val === false) {
    // MySQLに問い合わせる
    $val = select_val_from_mysql();
    // リモートのKVSに結果を格納
    $memc->set($key, $val, 0, $remote_expire);
}

これを見ればわかるように素で多段キャッシュを参照/作成するようなコードは非常に複雑でメンテナンス性が悪く、バグを生む可能性が高くなります。 そして多段キャッシュを扱わなければならないような箇所はpixivではいたるところに存在するので、その度にこういったコードを書くのは常にリスクが伴います。

KVSClientを使って書く

次にKVSClientを使ってさっきと同じことをするコードを書いてみます。

1
2
3
4
5
6
7
8
9
10
11
$key = 'common_user_data_key';
$kvs = new KVSClient($key);
$kvs->connect();
// キャッシュの取得を試みる
$val  = $kvs->get($key);
// キャッシュがなかったら
if ($val === false) {
    // MySQLに問い合わせる
    $val = select_val_from_mysql();
    // キャッシュを格納
    $kvs->set($key, $val);
}

コードが非常に短くなってやることも単純になりました。さっきと違ってキャッシュが1段しかないように見えるのがポイントです。 しかし、実際には上記のコードではAPC -> KyotoTycoon -> MySQLの順にアクセスするようになっています。

KVSClientはすべてを知っている

KVSClientはコンストラクタでKVSから参照する際に使用するキー名を受け取ります。 この時KVSClientはあらかじめ登録されたKyotoTycoonのホスト名やポート番号、キャッシュ期限、ローカルキャッシュ(APC)の使用の有無等が記述されたリストから そのキー名に対応する設定を探索します。

1
2
$key = 'common_user_data_key';
$kvs = new KVSClient($key);    // $keyに対応する設定を探索

また、設定例はこんな感じです。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
array(
    // リモートのKVS(KyotoTycoon)設定
    'remote' => array(
        'proto' => KVS_PROTO_MEMCACHED, // リモートのKVSとの通信にはmemcachedプロトコルを使う
        'host'  => HOST_HOGE,
        'port'  => PORT_HOGE,
    ),
    // ローカルのKVS(APC)設定
    'local' => array(
        'proto' => KVS_PROTO_APC, // ローカルのKVSとの通信にはAPCを使う
        'host'  => LOCALHOST,
        'port'  => 11211,
    ),
    // 対応するキー(の正規表現)のリスト
    'key_regexes' => array(
        'common_user_data_key' => array('local' => true,  'expire' => 3600, 'local_expire' => 600), // APCを使う
        '^hoge_\d$'            => array('local' => false, 'expire' => 1200),                        // APCを使わない
        '^fuga_\d$'            => array('local' => false, 'expire' => 2400),                        // APCを使わない
        // other key_regexes
    ),
)

上記のようにリモートとローカルのそれぞれのKVSとの通信方法や通信先、キャッシュ期限等を記述しています。 このおかげでアプリケーションを開発するエンジニアはキー名以外の情報を使わずにリモートあるいはローカルのKVS(KyotoTycoonとAPC)へアクセスできます。 キーの探索計算量がO(n)なのと、探索の際に正規表現を使用するため、CPU使用率の増加が若干気になりますが、nの値が小さいのでほぼ無視できるレベルです。

1
2
$kvs->connect();        // $keyに対応したKVSに接続
$val = $kvs->get($key); // $keyに対応するデータを取得

APCが有効な設定になっている場合は上記のgetメソッドの中でAPC -> KyotoTycoonの順にアクセスします。 そしてAPCにデータがない場合はKyotoTycoonから取得したデータをAPCに保存します。これで次回以降はAPCのキャッシュが使われるようになります。 最後に、APCにキャッシュがない場合はMySQLからデータを取得してリモートのKyotoTycoonに対して対象データを格納します。

1
2
3
4
if ($val === false) {
    $val = select_val_from_mysql(); // キャッシュがないのでMySQLに問い合わせる
    $kvs->set($key, $val);          // キャッシュをKyotoTycoonに保存
}

まとめ

pixivのデータストア/キャッシングレイヤーおよびその多段レイヤーを透過的に扱うためのKVSClientについて紹介しました。

各AP上でmemcachedを稼働させていた頃と比べるとかなり融通のきく構成になりましたが、実際にはAPの数だけあったmemcachedへのリクエストを 少数のKyotoTycoonにまとめたことで一部のKyotoTycoonサーバへのTCPコネクション数が爆発してKyotoTycoonサーバのCPUやメモリリソースには余裕があるのにネットワークで詰まるという問題がありました。

最終回(の予定)となる次回はこの問題を解決するために開発したA Yet Another Memcached Proxy Protocol Serverこと、neoagentの話で締めくくろうと思います。