ページキャッシュの仕組みとI/O負荷の軽減
OSには基本的に、ディスクから読み込んだデータをメモリにキャッシュする機構がある。はてなでメインに使用しているLinuxにも、この「ページキャッシュ」の仕組みがある。
ページはページングの最小単位で、ディスクの内容をメモリに読み込むときに作成される。これは破棄されずに、カーネルバッファの中に残される。次にアクセスするときには、さっき読み取ったページがキャッシュにあるので、ディスクにはアクセスしない。
さらにLinuxではキャッシュの制限がなく、メモリが空いていれば全部キャッシュに回される。実際にsar -rコマンドで確認すると、次のようにメモリの使用率が99%を超えていることがある。
% sar -r 1 10000 Linux 2.6.11-co-0.6.4 (colinux) 05/28/07 19:50:32 kbmemfree kbmemused %memused kbbuffers kbcached kbswpfree kbswpused %swpused kbswpcad 19:50:33 5800 1005888 99.43 28244 694088 262132 4 0.00 0 19:50:34 5800 1005888 99.43 28244 694088 262132 4 0.00 0 19:50:35 5800 1005888 99.43 28244 694088 262132 4 0.00 0 19:50:36 5800 1005888 99.43 28244 694088 262132 4 0.00 0
これは決してメモリ不足なわけではなく、搭載している1GBのメモリのうち700Mくらいがキャッシュに使われている(kbcached)ということだ。ここで新しいサーバープロセスを立ち上げると、古いキャッシュを追い出して、実際のアプリケーションにメモリが割り当てられる。
結論として、単純にメモリを増やすことで、I/O負荷を軽減させることができるということになる。実例として、メモリが4GBのときのデータベースの負荷をsarコマンドの出力で見ると、次のように表示される。
14:10:01 CPU %user %nice %system %iowait %idle 14:20:01 all 8.58 0.00 5.84 16.58 69.00 14:30:01 all 7.41 0.00 5.14 17.81 69.63 14:40:01 all 7.74 0.00 4.97 18.56 68.73 14:50:01 all 7.02 0.00 5.01 16.24 71.72
iowaitが20%くらいあるのが見てとれる。20%というと、大したことがないように思えるが、CPU負荷に換算するとほぼ4倍なので、100%に近いI/O負荷が発生しており、実は大変な状況になっている。
メモリを8GBに増設して同じサーバーでしばらく動かしてみると、次のようにiowaitがほとんどなくなる。
14:10:01 CPU %user %nice %system %iowait %idle 14:10:01 all 18.16 0.00 11.56 0.80 69.49 14:20:01 all 12.48 0.00 9.47 0.88 77.17 14:30:01 all 14.20 0.00 10.17 0.91 74.72 14:40:01 all 13.25 0.00 9.74 0.75 76.25
このとき面白いのは、CPU負荷が上がっていること。これはCPUが本来するべき仕事を、きちんと行っているためだ。I/O負荷がかからずに、CPUだけが動き続けているのが理想的な状態といえる。
サーバーの負荷には「CPU負荷」と「I/O負荷」がある。CPU負荷をスケーリングするのは簡単だが、I/O負荷のスケーリングは難しい。サイトが遅いときに「サーバーを増やしたらいいんじゃないか?」と言われることが多いが、サーバーを増設して解決できるのはCPU負荷。大抵のWebサービスの問題は、I/O負荷をどう分散するかにかかっている。
最近はメモリが8GB~16GBというサーバーがスタンダード(大抵の1Uサーバーにはメモリスロットが8つあり、最近は2GBのモジュールが1番安いので、16GBがコモディティになる)なので、それより小さいデータ規模のアプリケーションなら、すべてメモリにキャッシュできる。
キャッシュを前提としたデータベースの分割
一方で、キャッシュしきれない規模になったらどうするか。16GBをコモディティとするなら、32GBのメモリが必要になってしまったとき、ここでようやく複数サーバーにスケールさせるという話になる。
I/O負荷分散では、データベースを分割して、キャッシュできないところをなくす。下図のように、データベースの赤い部分がキャッシュできてないとする。このとき青い部分にアクセスするパターンAと、赤い部分に来るパターンBがあれば、赤い部分と青い部分を別々のサーバーに分離してやると、両方のデータが全部キャッシュに乗るので、キャッシュできない箇所がなくなるという訳だ。
具体的には、テーブル単位で分割する方法がある。はてなブックマークの「エントリ」テーブルに大量のアクセスが来て、「ブックマーク」テーブルを引くと重いという場合には、それぞれを別のホストで管理するとサクサク動くようになるという。
また、検索のインデックスを辞書順に分割する方法もある。国内のWebサービスでは「mixi」が採用している。はてなでも、「はてなダイアリー」でこの方法を利用しているという。
リクエストのアクセスパターンを考慮した分割
ちょっと変わった例としては、リクエストのアクセスパターンを考慮して分割する方法がある。次の図は、はてなブックマークで実際に行っている分割の例だ。
通常のユーザーからのアクセスは、基本的に人気エントリーや注目エントリーがよく閲覧されるので、新しいデータにアクセスが集中する。一方で、Google BotやYahoo! Slurpなどは、古いデータまで“絨毯爆撃”するが、応答速度はそれほど速くなくても良い。もう1つは、はてなブックマークに特徴的な、記事のブックマーク数をリアルタイムで「xxx users」と赤い画像で表示させるAPI。いろいろなサイトで利用されているので、1日で数億ものアクセスがあるが、このAPIは数の画像を返すだけで良い。このように、ボットとユーザー、そして画像APIでは、リクエストのパターンが違うので、はてなではアクセスさせるデータベースをそれぞれ分けているという。
透過的に作用することを考慮した運用上のノウハウ
運用面で大規模データを扱う際に気をつけなければいけないのは、OSを起動した直後、すぐにサーバーを立ち上げないということだ。キャッシュが全く構築されていないので、ディスクにアクセスしに行ってしまいハングアップしてしまう。
次のデータは、OS起動直後に数GBのファイルをリードしたときのもの。最初はメモリがほとんど使われていない(4%)が、18時40分からの10分間でいきなり96%になっている。
18:20:01 kbmemfree kbmemused %memused kbbuffers kbcached kbswpfree kbswpused %swpused kbswpcad 18:30:01 3566992 157272 4.22 11224 50136 2048276 0 0.00 0 18:40:01 3546264 178000 4.78 12752 66548 2048276 0 0.00 0 18:50:01 112628 3611636 96.98 4312 3499144 2048232 44 0.00 44
このように、ページキャッシュは透過的に作用するので、大きなファイルを一気にリードするだけでキャッシュが溜まってくれる。ノウハウとしては、OSが起動したら、データベースのファイルを全部catして「/dev/null」に投げておくと、メモリにキャッシュが構築される。
性能評価を実地のマシンで行うときにも、キャッシュが最適化されるまで少し時間がかかる。最適化されてない状態でテストしても意味がない。負荷がだんだん落ち着いたところで測定するのがポイントだ。
