以前の記事で、フロントエンド-バックエンドのI/Fをどうするか話しましたが、ここにRead Cache層を差し込んでみます。
キャッシュは、最近の大規模サービスではほぼ必須の技術要素といえます。実装としては、memcachedやRedisといったインメモリーKVSがよく用いられます。
■ キャッシングを行うポイント
以前示した図に、キャッシュ層を差し込むポイントを示してみます。候補としては図中の(A)か(B)となります。
(A)の場合は、参照系業務ロジック(Query Operation)の結果についてキャッシュする、ということとなります。KVSによるキャッシュとは、要は巨大なハッシュテーブルに保存しておく、ということなので、ハッシュテーブルのキーをどうするか?バリューをどうするか?が、設計ポイントとなります。ここでは「自動メモ化」の考え方を適用することができます。
キャッシュ対象となる参照系業務ロジック(Query Operation)の引数セットを、JavaならひとつのPOJOに、Scalaならひとつのcase classとして定義します。そして、そのPOJOの`equals`と`hashCode`を定義します。Scalaのcase classなら`equals`と`hashCode`は暗黙に自動生成されます。この引数を表すPOJOなりcase classをハッシュテーブルのキーとします。バリューは業務ロジックの返却値ですね。以下に疑似コードを示します。
-----(ここから)-----
// caching wrapper of the logic
function CachedMyQueryOperation(arg1, arg2) {
val args = new MyQueryOperationArgs(arg1, arg2);
val res = CacheResource.instance.get(args);
if(res is null || res is expired) {
res = MyQueryOperation(arg1, arg2); // actually executes the business logic here
CacheResource.instance.put(args, res);
}
return res;
}
-----(ここまで)-----
このような処理を任意の業務ロジックに対して適用できる汎用フレームワークを設けます。より実践的には、キャッシュするかしないかで、呼び出す業務ロジックのシグネチャーを変えることはしたくないので、AOPで適用するのがよいでしょう。(※関数型なら、まさに“モナディック”にやる/やれるところでしょうか?いまいちわかってない・・・。(なら書くな。。。))
キャッシュ効果は、対象の業務ロジック(Query Operation)の特性(=同一引数で呼び出される頻度がどれほどあるか)次第となります。
・
(B)の場合も、キャッシングの方式は(A)と大差はありませんが、比較的低レベルな単純CRUDに近いOperationの結果に対してそれを適用することとなります。(B)のレイヤーでは、通常、大きく下記二種類の参照系Operationが提供されると想定できます。
(ア) 主キーを引数にとり、対象のエントリーを単品で返すもの
(イ) SQLのような汎用問い合わせメカニズムにより、選択結果を複数のエントリーで返すもの
(ア)については、特にマスター系データについてかなりのキャッシュ効果を期待できます。(※マスター系データでは主キー参照がよく発生するので。)(A)のレイヤーでキャシュをするとしても、それに加えて(ア)のキャッシングも実施したいところです。
(イ)については、下記条件を満たす場合に限りキャッシュ効果を期待できます。
(1) その「汎用問い合わせ」の問い合わせ条件が、AST(抽象構文木)のようなデータ構造で表現できる。(※そして、そのASTに対して`equals`と`hashCode`を定義できる。)
(2) 汎用問い合わせを行う側の業務ロジック(Query Operation)では、意味的に同一の問い合わせを行う場合、同一のASTとなるような問い合わせを発行するよう、呼び出し方を整える。
例えば、「カテゴリーがXで、登録日が6月10日以降」といった問い合わせをするとき、「カテゴリーがXで、登録日が6月10日以降」の場合と「登録日が6月10日以降で、カテゴリーがX」の場合で、異なるASTになってしまってはキャッシュ効果が落ちる、という話です。汎用問い合わせを解釈する側でオプティマイズできるならよいですが、そうでないなら、問い合わせをする側で、呼び出しの整形をする必要が生じます。これはどっちにしても少々めんどうですので、(B)のレイヤーで(イ)をキャッシュするのは諦めて、(A)のレイヤーだけでキャッシュするのも一つの割り切りだと思います。
■ キャッシュ無効化するタイミングの取り方
キャッシュを導入するときには、キャッシングそのものよりも、そのキャッシュを無効化するタイミングや方式を考案することこそが設計の中心かもしれません。大原則は下記となります。
キャッシュ対象データが更新されたら、当該キャッシュを無効化する
もちろん、単純にキャッシュの有効期限だけで制御するのもアリです。有効期限だけの制御で問題がないなら、KVSなどによるキャッシュ機構ではなく、(Webシステムに限りますが、)HTTPプロキシーを用いてキャッシュを実現するという方式も選択肢に入ってきます。有効期限だけでキャッシュ無効化を実現する場合の唯一の問題点は「実データの更新のタイミングと同期することができない」という点です。ここがクリアーになればまったく問題ありません。
・
ここでは、更新とキャッシュ無効化を同期する必要がある場合について、議論を進めます。
「キャッシュ対象データが更新されたら、当該キャッシュを無効化する」という場合に重要なのは、当たり前のことですが、キャッシュ対象データは更新系Operationによって更新されるということです。つまり、キャッシュ機構については、参照系Operationと更新系Operationとの協調方式をよく考えねばならない、ということです。経験的に、この協調メカニズムは事前によく考えておかないと、キャッシュ制御のためのad-hocなコードが各業務ロジックに散乱して、保守上のリスクになりがちです。
協調の方式を考えるに、鍵となるのは、参照系Operationはどのエンティティやそのインスタンス(エントリー)を参照し、更新系Operationはどのエンティティやそのインスタンス(エントリー)を更新するのか、ここの参照側・更新側の突き合わせをgenericなメカニズムで実現できるか?という点になります。
次のような戦略が解法のひとつとなるでしょう。
・対象のエンティティを、実際にはUMLやDDDのいう集約の単位で捉えること。これで、あまりにも細かい粒度での更新やキャッシュを避けつつ、必要十分に“effective”なキャッシングが可能となります。
・更新側では、更新の発生したエンティティやインスタンス(エントリー)の情報をイベントとして書き出す。参照側では、そのイベントを観測して自身の対象があれば、キャッシュ無効化の処置を行う。
うまく作れば、業務ロジックから完全に隠蔽化し、基盤として処理させることができますが、(永続データのある)Data Access層から(キャッシュのある)Business Logic層にまたがる制御線が必要になりますね。
■ ログインユーザーのユーザーID等をキャッシュのキーに含めることを忘れずに!
上記(A)のレイヤーでも(B)のレイヤーでも、呼び出すOperationの引数に明示的でないパラメーターが存在する場合があります。典型的にはログインユーザーのユーザーIDです。ユーザーIDは、キャッシュのハッシュテーブルのキーに含めないと、完全にまずいことが起きます(^^;
◆以上