読者です 読者をやめる 読者になる 読者になる

mixi engineer blog

ミクシィ・グループで、実際に開発に携わっているエンジニア達が執筆している公式ブログです。様々なサービスの開発や運用を行っていく際に得た技術情報から採用情報まで、有益な情報を幅広く取り扱っています。

Tokyo Tyrantによる耐高負荷DBの構築

algorithm mixi
連休中はWiiのマリオカートをやりまくってやっとVR7000越えたmikioです。愛車はマッハ・バイクとインターセプターです。さて今回は、分散ハッシュデータベースサーバTokyo Tyrantでmixiの最終ログイン時刻を管理するようにした時の苦労話を書きます。

ログイン処理は負荷地獄

mixiでは、全てのユーザについて、各々の最終ログイン時刻を管理しています。「マイミクシィ一覧」や「お気に入り」などの画面で、友人が近い時間にログインしていてコミュニケーションがとりやすい状態にあるかどうか確認できるようにするためです。

mixiのほぼ全てのページはログインしないと見られないページなので、ほぼ全てのページにアクセスされるたびにログイン確認が行われます。したがって、最終ログイン時刻はほぼ全てのページにアクセスされる度に更新されることになります。mixiの中で最も重いデータベースのひとつとして「足あとデータベース」(各ユーザのページに誰が訪れたかの記録)が挙げられますが、更新頻度の点では「最終ログイン時刻データベース」が頂点に位置します。

tt-login.png

レコード数はユーザ数とほぼ同じ(2008年1月現在で1331万人)で各レコードのサイズは30バイト未満なので規模は大したことないのですが、月間ページビューが100億を越えるmixiのトラフィックに相当する更新クエリの全てを単一のデータベースサーバに集中させると恐ろしいことになります。秒間のクエリ頻度をQPS(Query Per Second)という単位で示しますが、mixiの最終ログイン時刻データベースはピーク時には10000QPSを大きく越える負荷(同時接続数も10000クライアント強)になることがわかっています。

そのような負荷地獄に耐えるため、従来は、最終ログイン時刻はmemcachedに分散させて保持するようにしていました。それならば負荷は複数のマシンに分散されるし、オンメモリなので高速に応答できるというわけです。しかし、memcachedはデータを永続化するわけでも冗長化するわけでもないので、分散ハッシュを構成するサーバのどれかが落ちれば、そこにあったユーザの最終ログイン時刻は失われることになってしまいます。最終ログイン時刻は次にログインすれば書き換わるような一時的なデータなので、どちらかと言えばバイタルではないのですが、それでもデータ消失のリスクを抱えていることは問題として認識されていました。

Tokyo Tyrant

そこでTokyo Tyrant(TT)の登場です。TTはmemcached並に高速でありながらファイル上にデータを記録して永続化することができ、さらにレプリケーションによってHA(High Availability)を実現しています。mixiの最終ログイン時刻を扱うのにもってこいの技術じゃないですか。TTはmemcachedプロトコルも実装しているので、既存のmemcachedクライアントの機構をそのまま用いることができます。

今回はTTを採用するのみならず、アクティブマスタ1台+スタンバイマスタ1台の2台構成に集約させてしまおうという野心的な方針にしました。ということは、10000以上のクライアントから投げられる、更新クエリのみで10000QPSを越える負荷を、1台のアクティブマスタが処理しないといけないということです。なぜそこまでして1台にしたいのかというと、クライアントがmemcachedのget_multiメソッド(複数レコードの一括取得)を処理する際には、接続対象のサーバが少なければ少ないほど性能が良くなるからです。もちろん、利用するマシンを減らして運用コストを下げられるのも嬉しいことです。

以前の記事にも書いたように、TTは、Tokyo Cabinet(TC)をベースとしてマイクロ秒単位の応答を可能にするとともに、epollやスレッドプールによって10000同時接続(c10k問題)にも対処できる並列処理性能を実現しています。負荷テストをしてみたところ20000QPS強は達成したので、本番の負荷でも多分大丈夫でしょう。

ライブテスト

実験環境ではちゃんと動いていることを確認したので、いざmixiの本番システムにあてはめてみました。とはいえ、本番システムをいきなり新しい機構に依存したものにしてしまっては、もしかして不具合があった場合に困ったことになってしまいます。しかも最終ログイン時刻の管理は全てのページから行われるので、もしもそこがハングアップしてしまったら、全ページが閲覧不能ということになってしまいます。 ということで、まずは既存のmemcachedと新しく設置したTTの両方に更新クエリを送ることにして、きちんとデータ管理が行えることを確認します。また、TTとの接続にはタイムアウトを設けて、たとえTTが応答しなくなってもそこで処理がハングアップしないようにします。これで安心して本番サービスに組み込むことができますし、この時点ではTTを落としても問題ないので、チューニングなどの作業も存分に行うことができます。疑似コードで示すと以下のようになります。
sub initialize {
  $mc->new({ servers => [ "mc-1", "mc-2", "mc-3" ] });
  $tt->new({ servers => [ "tt-1" ], timeout => 0.1 });
}

sub set_login_timestamp {
  $mc->set($user_id, $timestamp);
  $tt->set($user_id, $timestamp);
}
で、実際に組み込んで動かしてみたのですが、実のところ、最初は見事にTTがハングしました。実験環境での負荷テストではQPSは再現できていたのですが、mixiの数百台単位のWebサーバからの同時アクセスが再現できていたわけではありません。本番環境で動かしてみて初めて発現した不具合が2つほどありました。それを直したところ、本番環境でも正常に動きつづけるようになりました。現状では最終ログイン時間の更新も参照もTTで処理するようになっています(実際のところ本件で私が作業したのはTTの不具合の修正だけで、本番環境での設置やチューニングやmixiコードベースへの組み込み作業には弊社アプリ開発&システム運用のタレント達が活躍してくれた次第です)。

設定

TTやTCでは基本データ構造としてハッシュとB+木を選択することできますが、今回はユーザIDの完全一致条件で検索できればよいので、ハッシュを採用しました。ハッシュはバケット数(バケット配列の要素数)のチューニングが重要ですが、これはレコード数1300万強より大きめにとっておけば十分でしょう。

また、今回は「マスタ/マスタ」構成のレプリケーションを行いました。つまり、1台のデータベースサーバ(アクティブマスタ)に常に更新クエリを投げるようにしておき、もしそれが死んでいる場合にはもう1台のデータベースサーバ(スタンバイマスタ)に更新クエリを投げるようにすることで、どちらかが死んだ場合のダウンタイムをなくします。両方のサーバは互いに同期させているので、データが失われることもありません。

なお、通常はアクティブマスタが生きている限りにおいてはスタンバイマスタには更新クエリを投げないのですが、今回に限っては、アクティブマスタが短い時間(0.1秒とか)に応答しなかった場合にもスタンバイマスタに更新クエリを投げるようにしています。そうすると万が一アクティブマスタが過負荷になった場合にもユーザへの応答時間が伸びずに済みます。マスタ/マスタ構成においてアクティブとスタンバイの更新が混ざるとよろしくないというセオリーがありますが、mixiのログイン時間に関しては1秒程度の誤差があったところで問題にはならないので、今回の方法が使えるのです。

以上の設定をアクティブマスタの起動スクリプトに反映すると、以下のようになります。スタンバイマスタではマスタにアクティブマスタのサーバを指定します。
ttserver \\
  -port 1978 \\               # 自分のポート指定
  -dmn \\                     # デーモン化
  -le \\                      # ログはエラーログのみ
  -pid /hoge/ttserver.pid \\  # プロセスIDファイル指定
  -log /hoge/log \\           # ログファイル指定
  -ulog /hoge/ulog \\         # 更新ログファイル指定
  -ulim 1024m \\              # 更新ログサイズ指定
  -sid 10001 \\               # サーバID指定
  -mhost tthost-02 \\         # マスタホスト指定
  -mport 1978 \\              # マスタポート指定
  -rts /hoge/ttserver.rts \\  # レプリケーションタイムスタンプファイル指定
  '/hoge/casket.tch#bnum=2000000' # データベースファイル指定

ファイルシステムのチューニング

TCのハッシュデータベースは、各レコードが更新される度にその内容をファイルに書き込みます。また、レプリケーションのための更新ログも、各レコードが更新される度にファイルに書き込まれます。毎回writeすると効率が悪いじゃないかと怒られそうですが、メモリ上だけに保持している状態でサーバが落ちたらレコードが失われてしまうのでは困るので、耐障害性を考えてそうしています。

しかし、近頃の有名Linuxディストリビューションの標準ファイルシステムであるEXT3では、writeの頻度が高いとジャーナリングのために性能が極端に悪くなるという問題があります。試しにデフォルト設定のEXT3でTTを動かしてみたところ、8000QPSくらいで頭打ちになってしまいました。

EXT2を使えばこの問題は起きないのですが、EXT3でも対処することはできます。EXT3にはデフォルトのorderedモードの他に、堅牢性重視のjournalモードと、高速性重視のwritebackモードがあります。このうちで、writebackモードを使うのです。また、readが発生する度にファイルの最終アクセス時刻のメタデータが更新されるのを防ぐために、noatimeオプションもつけます。fstabの設定は以下のようになります。
LABEL=/hoge  /hoge   ext3   rw,noatime,data=writeback    1 2
これらをやるだけでTCおよびTTのパフォーマンスは3倍くらいになり、晴れて実運用の要件を満たすことができました。このスループットであれば、今後もっとサイトが盛り上がって今のサーバでは耐えられなくなった際にも、基本的にはスケールアップ(その時点での良いマシンに載せ替える)で対処できると思います。

まとめ

TTでmixiの最終ログイン時刻を管理する方法について述べました。毎秒10000クエリ以上を1台で処理するという野心的な目標を何とか達成することができました。新しいデータ管理機構をリスクヘッジしながら導入するためには、既存システムを生かしたまま更新処理を多重化し、安定稼働を確認してから参照処理を移行するという漸次的な方法が有用です。

TTもやっとプロダクトレベルの品質になったと思うので、ぼちぼち普及活動を本格化させていこうかなと思っています。MySQLやmemcachedでなくTTをどうしても使わなければならないシーンは普通はそんなにないのですが、マッシュアップとかWebAPIとか言って容赦ない数のクエリを投げる世の中になってくるとTTの需要も伸びてくるのかなと個人的には思っています。mixi内でも他にも適用できるシーンがいくつかありそうなので、また進捗があったらここに書くつもりです。