memcached活用は、格納オブジェクトの”粒度”がキモ

最近じゃmemcachedを活用してデータベース(RDB)の負荷を下げるって話、そこらじゅうから聞こえてくるけれど、memcachedの活用は、格納オブジェクトの”粒度”(granularity)がキモだと思ってます。

memcachedは、KeyとDataをペアで格納して、Keyが与えられると、関連付けられたDataを返すだけのシンプルなシステム。PerlやPHPの連想配列と同じ。このmemcachedをRDBのキャッシュとして活用してやる場合、memcachedに格納するキャッシュデータの単位、”粒度”をどう設計するかが重要になってくる。

RDBの場合、格納されるデータはRow(レコード)単位。じゃぁキャッシュもRow単位で作ってやればいいのかといえば、それではうまくいかないケースもたくさんある。RDBでは専用の問い合わせ言語であるSQLを使って、

SELECT * FROM hoge WHERE 〜〜

というように、WHERE節の条件にマッチするレコードを根こそぎ引っ張ってくることができる。プライマリキー以外にも様々なフィールド値で該当データを絞り込めるのがRDBMSの強みなわけで、WebアプリケーションでRDBを使う場合、こういったクエリを発行することが実に多い。しかし、memcachedが参照できるのはあくまでKeyに対応するDataだけだから、上記のようにWHEREでマッチする条件を指定するようなクエリは相性が悪い。もしRow単位でキャッシュをmemcachedに格納してしまった場合、こういったクエリをmemcachedだけで完結させようとすると、全キャッシュデータをアプリ側でフルスキャンする必要が出てしまうだろうから、効率がとても悪い。RDBで、テーブルにプライマリインデックスしか張れないようなイメージかな。

じゃぁどうすればいいのか。普通こういう場合では、上記クエリの結果そのものを一塊のデータとしてキャッシュに格納するケースが多いと思う。上記クエリの結果たとえば複数レコードがヒットしたとすると、DB関数から多次元配列が返されてくるだろうから、それをそのままシリアライズしてmemcachedに格納してやる。つまりキャッシュデータの粒度を大きくする。

この場合の問題点は、そうして作ったキャッシュデータをいつ破棄しなければいけないのか、特定するのが難しいことだ。”SELECT * FROM hoge WHERE 〜〜”のようなクエリは、クエリ結果にどんなレコードがどれだけ含まれているのか、実行してみるまで分からない。だからそのクエリ結果をキャッシュするということは、自分がどんなデータをキャッシュしているのかをプログラム自体が(そのままでは)把握できないことになる。
たいていのWebサービスではユーザが常にデータを更新・追加し続けるから、古くなったキャッシュを破棄しないと、いつまでたっても古い情報が表示されっぱなしになってしまう。ユーザがあるデータを更新したとして「更新前の古いデータを含むキャッシュはどこにあるのか」を特定できなければ、キャッシュを破棄することはできない。もしキャッシュデータがデータベースのRow単位で格納されていた場合は、そのRowのキャッシュを破棄するだけでいいので、こういった問題は生じないが、より高次のクエリ結果をキャッシュしていた場合、当該Rowを含むキャッシュデータを把握することはなかなか難しい。

この問題の手っ取り早い解決策は、キャッシュに”有効期限”を設定することだ。上記のようなクエリの有効期限を、キャッシュ格納後から例えば10分に限定してやる。すると10分間は常にそのキャッシュが表示され続けることになるけれども、10分経過後にそのキャッシュは自動で破棄されるので、それ以降のリクエストに対しては、また新たにデータベースを参照してキャッシュデータを作り直すことができる。「それぞれのキャッシュデータの内訳を把握することは難しいので、一律に有効期限を設けて、期限切れは捨ててしまう」というこのアイディア、デメリットとしては、有効期限間にデータ更新が行われてもそれを表示データに反映させることができない、めったに更新されないデータのキャッシュも一律に破棄されてしまうので、キャッシュ効率が低下する、等。この辺の考え方は人によって分かれるんじゃないかな。データが更新されたにもかかわらず、有効期限が切れるまで古いデータが表示され続けるというのは、個人的にはちょっと許容できないけれど。

一般に次のようなトレードオフがあると考えていいと思う。つまり、

  • 「キャッシュデータの粒度」を大きくすればするほど、キャッシュ参照時の実行効率は高まるが、データ更新時の依存関係の解決が難しくなる。


表示の遅延が許容できない場合、データ更新時にキャッシュに及ぶ影響を特定するアーキテクチャを頑張って構築しなきゃいけない。この辺のアーキテクチャは、サービスの種類に応じて様々な方法があると思うので一概に例示はできないと思う。ただSNSとかmicroblog等、メジャーなWebアプリケーションについては、世の中に前例が確立されつつある感がある(参考:Twitterがスケールに苦しむ理由)。

Webサービスのスケーラビリティについての情報が集まっているサイト「High Scalability」に、世界最大の写真共有サイトFotologについてのmemcached活用事例が紹介されていたが、その中でのmemcached活用アドバイスが、かなりためになる。以下一部引用。

A Bunch of Great Strategies for Using Memcached and MySQL Better Together

Miscellaneous

There were a few suggestions on using memcached that didn't fit in any other section, so they're gathered here for posterity:

  • Have a lot of nodes to handle loss. Losing a node with a few nodes will cause a spike on the database as everything reloads. Having more servers means less database load on failure.
  • Use a warm standby that takes over IP of a memcached server that fails. This means you clients will not have to update their cache lists.
  • Memcached can operate with UDP and TCP. Persistence connections are better because there's less overhead. Cache designed to use 1000s of connections.
  • Use separate memcached servers to reduce contention with applications.
  • Check that your slab sizes match the size of the data you are allocating or you could be wasting a lot of memory.

Here are some additional strategies from Memcached and MySQL tutorial:

  • Don't think row-level (database) caching, think complex objects.
  • Don't run memcached on your database server, give your database all the memory it can get.
  • Don't obsess about TCP latency - localhost TCP/IP is optimized down to an in-memory copy.
  • Think multi-get - run things in parallel whenever you can.
  • Not all memcached client libraries are made equal, do some research on yours.
  • Instead of invalidating your data, expire it whenever you can - memcached will do all the work
  • Generate smart keys - ex. on update, increment a version number, which will become part of the key
  • For bonus points, store the version number in memcached - call it generation
  • The latter will be added to Memcached soon - as soon as Brian gets around to it

(訳)

  • memcachedのインスタンスはたくさん走らせたほうがいい。少数のインスタンスに依存していた場合、そのインスタンスが失われたときに再構築しなければいけないキャッシュデータが大量に生じ、データベースが過負荷に陥る。
  • VIPを使ってmemcachedを冗長化したほうがいい。障害発生時に同一IPで引き続きキャッシュを提供できれば、障害発生時にクライアント(Webサーバ)のキャッシュサーバリストを更新する必要が無くなる。
  • メモリの浪費を避けるため、スラブサイズが、自分のキャッシュデータとマッチしているかを確認したほうがいい。
  • データベースのrow単位でキャッシュを生成するな。より複雑なオブジェクトをキャッシュするほうがいい。(粒度を大きくしろ)
  • データベースサーバ上でmemcachedを走らせるな。データベースにはメモリを可能な限り与えたほうがいい。
  • memcachedを使用するときはTCPのレイテンシを心配するな。localhost上でのTCP/IP通信は、メモリ間コピーを活用することで最適化されている。
  • memcachedへのアクセスを並列処理できる場合は活用しろ。
  • memcachedクライアントライブラリの挙動はモノによって異なる。あらかじめ調べておいたほうがいい。
  • キャッシュデータを無効化するのではなく、有効期限を設けることを考えたほうがいい。
  • memcachedのキーについてちゃんと考えておいたほうがいい。プログラムのバージョン番号をキーに含めるとか(不整合を回避するため)。
  • プログラムのバージョン番号とかもmemchachedのデータとして格納したほうがいい。

そして、記事最後の筆者考察。

Final Thoughts

Fotolog has obviously put a great deal of thought and effort into creating sophisticated scaling strategies using memcached and MySQL. What I'm struck with is the enormous amount of effort that goes into syncing rows and objects back and forth between the cache and the database. Shouldn't it be easier? What role is the database playing when the application makes such constant use of the object cache? Wouldn't more memory make the disk based storage unnecessary?

Fotologは試行錯誤の末に、memcachedとMySQLを用いた、洗練されたスケール戦略を生み出した。個人的に驚いたのは、Fotologではキャッシュとデータベース間の整合性を保つため、キャッシュオブジェクトとデータベースのレコードを同期させるためにただならぬ苦労を払っていることだ。もうすこしいい方法はないんだろうか? アプリケーションでこれほどまでにキャッシュが多様されるようになると、データベースの役割はどうなるんだろう。もっともっとメモリを増やしていけば、そのうちディスクタイプのストレージは必要なくなるんじゃないだろうか。

高度にスケールすることが求められる現代のマンモスサイトでは、RDBMSの重要度が低下しつつある。元mixiのバタラさんもインタビューで同じようなことを言っていた。