Sunday, July 28, 2013

JavaとキャッシュとMixer2

激辛担担麺ランチ食ったら目が覚めるどころかかえって猛烈な睡魔に襲われております。

さて、今日はキャッシュ技術の話。先に言っておきますが、すごく長いです。そして大した結論ではないです。

ソフトウェア、ハードウェアの性能向上にはキャッシュ技術は欠かせません。これは開発言語も分野も問いません。 CPUにだってキャッシュ的な機能要素はあります。WebアプリではDBの検索結果をキャッシュして使いまわすことで DBの負荷を下げ全体の性能を上げるというのは常套手段です。

キャッシュとkey-valueストアとがごっちゃに論じられる場合もあります。実質的には同じようなことなのでどうでもいいんですけどね。 key-valueストアの代表格の一つにmemcachedがありますが、読んで字のごとく"キャッシュ"ですし。

これだけ便利で普及しているキャッシュという技術ですが、いざ使おうとすると結構面倒です。 どんどんキャッシュにつっこんでいたらキャッシュストアがあふれてしまって容量オーバー。よくある話です。 古いものから自動的に消えるようになってるから容量オーバーの心配無しとか言ってたら、 それがキャッシュヒット率の低下を招いていしまってキャッシュ機構の存在意義がなくなったり。 本物のデータのほうを変更したにもかかわらずキャッシュのデータのほうを変更あるいは削除しそこねてバグったり。 また、キャッシュしたいモノとそれにひもづけるキャッシュキーは正確に一対一でないと 間違ったキャッシュデータをキャッシュに保存し、 あるいは間違ったデータをユーザーにお届けしちゃうことになります。 何をキャッシュキーにするか?は慎重に考えねばなりません。

Javaアプリの場合はさらに面倒なことがあります。

キャッシュしたくなるようなデータは、多くのケースでは単なるString(例:JSON形式)だったりintやlongだったりします。その場合ディープコピーかシャローコピーかを意識する必要がありません。しかしその調子でやると、Javaクラスのインスタンス=たとえばいわゆるPOJO的なもの=をキャッシュ対象とする場合の落とし穴に気づかないことがあります。

二人のユーザー(≒二つのスレッド)が同時に同じデータをキャッシュから取得しようとしたとき、 キャッシュは二人に「同じデータ」を返します。 しかしJavaの場合は変数のコピーは基本的にリファレンス(参照)に過ぎないので、 キャッシュから受け取ったデータを一人が加工するともう一人のほうに渡されたデータも変わってしまいます。不思議と言えば不思議、当然と言えば当然の現象です。 それをよしとする要求ならそれでよいのですが、Mixer2のhtmlテンプレートロード機能の性能UPのためにキャッシュを使うという要求なのであれば話は別です。

AさんとBさんがほんの数マイクロ秒差で同じページにアクセスしたとしましょう。Aさん用画面を処理するスレッドがキャッシュから取得したHtml型インスタンスに「こんにちはAさん」を埋め込んだ直後、別スレッドがキャッシュから受け取った同じインスタンス(のリファレンス)に「こんにちはBさん」を埋め込んでしまう可能性が発生するのです。それはまずい。

Javaで実装されたキャッシュ機構として有名なものにEHCacheがあります。そのドキュメントにはこんなことが書いてあります。日本語訳by私なのであまり信用しないように(笑)

Ehcache | Documentation | Using Ehcache

Copy Cache

A Copy Cache can have two behaviors: it can copy Element instances it returns, when copyOnRead is true and copy elements it stores, when copyOnWrite to true.
コピーキャッシュパターンには二つの方法があります:

  • copyOnReadがtrueのとき、エレメントのコピーを戻り値として返す。
  • copyOnWriteがtrueのとき、エレメントのコピーをキャッシュに保存する。
注:エレメント≒キャッシュに保存しているデータのこと

A copy on read cache can be useful when you can't let multiple threads access the same Element instance (and the value it holds) concurrently. For example, where the programming model doesn't allow it, or you want to isolate changes done concurrently from each other.
同じエレメントのインスタンス(とそれが内部に持つ値)に対し、複数のスレッドがアクセスすることを許可したくない場合、キャッシュ読み込み時のコピー設定を使うとよいでしょう。たとえばプログラミングモデルがそれを許さない場合や、エレメントを確実に隔離しつつ同時発生的な変更を可能にしたい場合です。

Copy on write also lets you determine exactly what goes in the cache and when. i.e. when the value that will be in the cache will be in state it was when it actually was put in cache. All mutations to the value, or the element, after the put operation will not be reflected in the cache.
キャッシュ保存時のコピー設定を使うと、どんなデータがいつキャッシュに保存されたのかを厳密に把握することができます。キャッシュに保存されているものは保存した時点の状態のままです。エレメントに対するどんな変更も、それがキャッシュへのput処理の後のことなのであればキャッシュの中のエレメントには反映されません。

A concrete example of a copy cache is a Cache configured for XA. It will always be configured copyOnRead and copyOnWrite to provide proper transaction isolation and clear transaction boundaries (the state the objects are in at commit time is the state making it into the cache). By default, the copy operation will be performed using standard Java object serialization. We do recognize though that for some applications this might not be good (or fast) enough. You can configure your own CopyStrategy which will be used to perform these copy operations. For example, you could easily implement use cloning rather than Serialization.
コピーキャッシュパターンの具体例はXA(分残トランザクション)向きに設定されたキャッシュでしょう。 copyOnReadとcopyOnWriteを常にオンに設定することで、適切なトランザクション隔離と明確なトランザクション境界を提供します (コミット時のオブジェクトの状態はキャッシュに入れるときの状態と同じであるということ)。 デフォルトでは、コピー処理ではJavaオブジェクトのシリアライゼーションが使用されます。もちろん、アプリケーションによってはそれは良い(or速い)方法ではないことを、我々は認識しています。独自のCopyStrategyを設定しておけば、お望みのコピー処理方式を使用することができます。 たとえば、シリアライゼーションではなくクローン処理を使うように簡単に設定できます。

Ehcache | Documentation | Configuration

copyOnRead and copyOnWrite cache configuration

A cache can be configured to copy the data, rather than return reference to it on get or put.
キャッシュからgetまたはputするときにデータのリファレンスではなくコピーを使うかどうかを設定できます。
(途中略)
The default configuration will be false for both options.
デフォルトではcopyOnRead,copyOnWriteの両方ともfalseになっています。

長々と引用してしまいましたが、重要なポイントがさらりと書いてあります。

  • コピーを使うように設定したとしても、ほっとくとserializationを使うので場合によっては性能が悪かったりちゃんとディープコピーできなかったりするかもですよ。ちゃんとCopyStrategyも実装/設定してね。
  • デフォルトでは、ディープコピーじゃなくてリファレンスを、キャッシュから返したりキャッシュに保存したりしますよ。

EHCacheがイケてないということではもちろんありません。 このへんが汎用キャッシュライブラリとしてできる限界なのでしょう。 CopyStragetyパターンに沿ってディープコピーの方式を好きに実装&設定できるのですからむしろ高機能とも言えます。

ところで、キャッシュサーバやそのクライアントライブラリは今やいろんな実装があります。 しかしコレといった共通の規格がありません。そこで、Java言語用の共通APIが規格されつつあります(2013/7現在まだドラフト)。 それがJSR-107 JCacheです。ちなみに、JSR-107の中の人はEHCacheの中の人のシャローコピーです。

さて、ここでようやくMixer2の話になります。

テンプレートエンジンの多くがキャッシュ機能を持っています。velocityしかり、thymeleafしかりです。 同じ画面を表示するために同じテンプレートをパースする処理を繰り返すくらいなら、 パース処理後の状態をキャッシュに保存しておけばそのぶんの処理を省略できる=性能がよくなる、という寸法です。 多くの場合はキャッシュエンジンの内部にMapかなにかでキャッシュを維持しているだけです。

もちろん独自実装も使えるようにするためにインターフェイスを提供していることもあります。

Mixer2もこれらにならって独自のキャッシュインターフェースを提供することも考えたのですが、あえてそうはしませんでした。 代わりに選んだのがJSR-107 JCacheです。このinterfaceクラスを持っているjarファイル名で言うとcache-api-0.8.jar(数字はバージョンなので変わる可能性あり)です。

先日リリースしたmixer2-cachableでは、 EHCacheのような汎用キャッシュライブラリを使うか、あるいは独自に実装するかによらず、とにかくjavax.cache.Cache(通称JCache)として提供されるinterfaceを実装したクラスをプラグインして使えるようになっています。また、同梱したCache実装をデフォルトで自動的に使用可能なので、それでよければそれ以上頭を使う必要もありません。

CachableMixer2Engineクラス経由で提供されるloadHtmlTemplate()やその種のメソッドは、設定したCache実装を通してhtmlテンプレートのロードが可能です。何より重要なのが、必ず、キャッシュのget/putの過程ではHtml型インスタンスのディープコピーを使うように実装済みであることです。しかも、高速かつ正確なcopy()メソッドを使っています。

これによって、たとえばEHCacheを使ったときでもcopyOnWrite/copyOnReadのon/offに関係なく確実に高速ディープコピーが使われるので、キャッシュ上のデータを安全かつ高速に使うことができます。CopyStrategyに沿って実装をどうのこうのと考える必要もありません。

また、CacheableMixer2Engineはロードしようとするテンプレートそれ自体のSHA-1ハッシュを計算してそれをキャッシュのキーとして使っています。キーの生成方法としてはとても安全なのですが、実はそれが性能上の欠点でもあります。せいぜい数十kbyte程度であろうHTMLテンプレート文字列のSHA-1を計算すること自体は大したコストではないのですが、テンプレートがFileに入っている場合、それを全部String等に読み込むコストが発生してしまうのです。 しかしこれは、Mixer2のテンプレートロード機能は、テンプレートの型としてjava.io.FileのほかInputStreamにもStringにも対応しているので、どれであっても同じようにキャッシュ(のキー)を扱えるようにするための措置でもあります。このあたりは今後、キャッシュキー生成の高速性と安全性と簡易性を両立させるうまい実装が思いつけばどうにかなるかもしれません。次のバージョンアップにご期待ください(いつだ?)。

ところで、キャッシュ機構といえばこんなのもあります。

つまり、DIコンテナが、というか、Javaアプリケーションフレームワーク自体がキャッシュ機構を提供しているケースです。 こういうものとMixer2を組み合わせる場合はmixer2-cacheableなんてほっといてフレームワークに合わせるのもよいでしょう。

そんなわけで、Mixer2のテンプレートロード機能にキャッシュを使いたい場合は、以下の3つのパターンにまとめることができます。

  • mixer2-cacheableをデフォルトのまま使う。数十kbyte程度の小さなテンプレートが数百種類、という程度の要求仕様であれば十分でしょう。
  • mixer2-cacheableを使いつつ、cacheオブジェクトはJCache(JSR-107)を実装した別のものを使う。CacheableMixer2Engine.setCache(別のキャッシュインスタンス) のようにすれば差し替えられるようになっています。(DIコンテナでの設定例はこちら
  • mixer2-cacheableは使わない。Mixer2Engineを継承した別クラスを独自実装し、そこにキャッシュ機構を組み込む。あるいはアプリケーションフレームワーク提供のキャッシュ機構を経由するようにする。

そして究極の選択は、テンプレートのキャッシュなんて気にしないことです。Mixer2のテンプレートロード機能は結構速いです。 かなり大きなテンプレートであっても一桁ミリ秒で済んでしまいます。ならば、100msec単位のコストがかかりがちなほかの機能(外部通信、DB検索等)をチューニングするほうがよほど性能向上を見込めるでしょう。

No comments:

Post a Comment