とりあえず雑記帳(跡地)
キャッシュの計画
最終更新:
fujiyan
-
view
WebコミックLibraryhttp://web-comi.appspot.com/ GAE/JとSlim3で作成してみた、各出版社から配信されているWebコミックをまとめて閲覧できるサイトです。只今、実験運用中… |
概要
Datastoreの主要なQuotaである「Datastore Read Operations」と「Datastore Small Operations」を節約するために、どのようにMemcacheを活用すればよいか、についての考察です。
月並みな話から
まずは、キャッシュの計画については「これが正解だ」というのはありません
- それがあれば、既にフレームワークとして提供されています…
ということで、対象となるWebアプリケーションの特性に応じた、「最適解」としてのキャッシュの計画を考える必要があります。
- アクセスされるデータの偏り方
- データの更新頻度
- 更新結果を反映させるまでの猶予がどれだけあるか(リアルタイム性)
- などなど
例えば、現在ひっそりと実験運用中のWebコミックLibraryhttp://web-comi.appspot.com/に関しては
- データの更新頻度については、原則、一日数回のクローリングでのみ更新なので、クローリング直後の内容をキャッシュしておけば、毎回Datastoreにアクセスする必要が無い
- クエリ結果も、「サイトの作品全部」や「作品の作者全員」等、決まりきった検索条件ばかりなので、これもどんどんキャッシュしておけば良い
- クエリ結果のキャッシュは、(クローリングによる更新も含めて)何らかのModelの更新があった時点で、全てクリアでもいいや
- そこまでリアルタイム性が要求されるデータでは無いので、万が一、キャッシュの内容が古かったとしても、次のクローリングのタイミングで最新化されれば良い。
といった感じです。
前提
計画にあたり、Google App Engine側での、いくつかの前提を確認しておきましょう。
Eventual Consistency(結果整合性)
参考
najeira: Eventual consistencyなクエリ結果のキャッシュ
結果整合性(Eventual Consistency)についての分かりやすいプレゼン資料 - Publickey
najeira: Eventual consistencyなクエリ結果のキャッシュ
結果整合性(Eventual Consistency)についての分かりやすいプレゼン資料 - Publickey
デフォルトのHigh Replication Datastoreでは「Eventual Consistency」が採用されています。
これは、「更新された内容は、そのうち全ノードに反映される」→「更新直後のクエリでは、直前の更新内容が取得されない可能性がある」ということです。
まぁ、全ノード反映の時間はかなり早いので、「単一のリクエスト処理内で、更新直後にクエリ発行」ぐらいでしか問題にならないかと思います。
とは言え、そのような「更新前の情報によるクエリ結果」が返ってきた場合に、それをキャッシュしてしまうと、キャッシュにヒットする間は古いクエリ結果のまま、ということが発生してしまいます。
これは、「更新された内容は、そのうち全ノードに反映される」→「更新直後のクエリでは、直前の更新内容が取得されない可能性がある」ということです。
まぁ、全ノード反映の時間はかなり早いので、「単一のリクエスト処理内で、更新直後にクエリ発行」ぐらいでしか問題にならないかと思います。
とは言え、そのような「更新前の情報によるクエリ結果」が返ってきた場合に、それをキャッシュしてしまうと、キャッシュにヒットする間は古いクエリ結果のまま、ということが発生してしまいます。
上記により「Eventual Consistencyのためにキャッシュが当てにならない」という声もありますが
- 仮に古い結果でキャッシュされたとしても、いつまでにキャッシュがご破算になって再取得が発生すれば、実運用上問題ないか?
を検討して、キャッシュのクリアがそこまでに発生するのが判明すれば、Eventual Consistencyとキャッシュは共存可能です。
「WebコミックLibrary」の例で言えば、「更新が発生したクローリング直後のクエリは古い結果になるかも知れないけど、急ぎのデータでもないし、次のクローリングの際にキャッシュがご破算になるから、その後のクエリで最新化できればいいや」という考えです。
むしろ「そのうちキャッシュがご破算になって、正しい内容で最新化される」までを含めて、Eventual Consistencyであると解釈しましょう。
「WebコミックLibrary」の例で言えば、「更新が発生したクローリング直後のクエリは古い結果になるかも知れないけど、急ぎのデータでもないし、次のクローリングの際にキャッシュがご破算になるから、その後のクエリで最新化できればいいや」という考えです。
むしろ「そのうちキャッシュがご破算になって、正しい内容で最新化される」までを含めて、Eventual Consistencyであると解釈しましょう。
ということで、クエリ結果のキャッシュについては、この特性を前提にして
- いつ、クエリ結果をキャッシュすべきか
- いつ、キャッシュをクリアすべきか
を考える必要があります。
Datastore Read OperationsとDatastore Small Operationsのコスト比較
「Datastore Read Operations」「Datastore Small Operations」の計算方法は下記の通りです。
- Datastore Read Operations
- 「Datastore Entity Fetch Ops」+「Datastore Query Ops」
- Datastore Small Operations
- 「Datastore Key Fetch Ops」+「Datastore Id Allocation Ops」
注目は「Datastore Entity Fetch Ops」と「Datastore Key Fetch Ops」ですが、同じクエリでも
- Query#setKeysOnly()を指定しない場合は、結果件数が「Datastore Entity Fetch Ops」にカウント。
- Slim3の場合は、ModelQuery#asList()/asIterator()を用いた場合
- Query#setKeysOnly()を指定した場合は、結果件数が「Datastore Key Fetch Ops」にカウント。
- Slim3の場合は、ModelQuery#asKeyList()/asKeyIterator()を用いた場合
という計算になります。
これは、filterでEntity(Slim3ならModel)のプロパティを検索キーにしても同様です。
これは、filterでEntity(Slim3ならModel)のプロパティを検索キーにしても同様です。
さて、参考ページに記載されているコストをみると
Operation | Cost |
Read | 10万回につき、$0.07 |
Small | 10万回につき、$0.01 |
と、Readに比べてSmallは7分の1のコストになっています。
これらの内容から考察すると、「Keyに紐づくEntityが全てキャッシュされており、KeyさえあればEntityの取得でDatastoreにアクセスする必要が無い」という前提条件があれば、
クエリはQuery#setKeysOnly()を指定してKeyのみを取得したほうが、同じ件数でも低コスト
ということになります。
クエリはQuery#setKeysOnly()を指定してKeyのみを取得したほうが、同じ件数でも低コスト
ということになります。
もちろん、Entityが必ずしもキャッシュされているとは限らないので、もしキャッシュに存在しない場合はEntityをDatastoreから取得するためにDatastore Entity Fetch Opsが追加で発生します。
よって、その場合はSmallとRead両方にカウントされてしまいます。
まぁ、キャッシュなので、ミスしたときのペナルティはそれなりに発生してしまうもんです…。
よって、その場合はSmallとRead両方にカウントされてしまいます。
まぁ、キャッシュなので、ミスしたときのペナルティはそれなりに発生してしまうもんです…。
ということで、
- キャッシュされる率が高いEntityについては、クエリ結果はQuery#setKeysOnly()を指定してKeyのみ取得し、実際のEntityはキャッシュから取得することで、コストを削減する
という方針は有効です。
なお「無料枠内なんでコスト関係ないっす」という方についても、Query#setKeysOnly()をまったく指定しなければ全てのクエリ結果がReadのカウントとなってしまいますが、上記の方針ならばReadとSmallに分散されて過度なReadの増加が抑えられるので、やっぱり有効です。
ここから先の議論は、最初にお話した通り、全てのWebアプリケーションに適用できるわけではありません。
が、概ねのモノには当てはまるかと思います。後は、各アプリケーションに応じてカスタムするのが良いかと。
が、概ねのモノには当てはまるかと思います。後は、各アプリケーションに応じてカスタムするのが良いかと。
どこで実装するか
実際のデータ参照先がDatastoreなのかキャッシュなのか、については、データを利用する側からは意識したくないので、通常はDAO層で実装します。
キャッシュ対象の分類
キャッシュ対象は、主に「Entity」「クエリ結果」の2つになります。
- Entity(Slim3ならModel)
- 「Datastore Entity Fetch Ops」の節約
- クエリ結果
- クエリ結果のKeyのListをキャッシュします。
- 「Datastore Query Ops」と、(Query#setKeysOnly()を指定する場合は)「Datastore Key Fetch Ops」の節約
以下、それぞれについて、どのような方針でキャッシュするかを検討します。
なお、実装はSlim3を例としますが、考え方はそれ以外でも変わらないかと。
なお、実装はSlim3を例としますが、考え方はそれ以外でも変わらないかと。
Entityのキャッシュ
Memcacheのキーとしては、DatastoreのKeyそのものを用います。
キャッシュの更新方針は、あまり深く考えずに、
- Entityの登録/更新/削除と同時に、キャッシュにも登録/更新/削除
- 取得時に、最初にキャッシュを参照し、なければDatastoreから取得して、キャッシュに登録
で良いかと。
Daoクラスの拡張
Slim3の場合、通常はDaoBaseをexntedsして、各Modelの具象Daoを定義しますが、
その間にキャッシュをサポートするDao階層を挟みます。
その間にキャッシュをサポートするDao階層を挟みます。
public abstract class CachingDao<M> extends DaoBase<M> {
@Override
public M get(final Key key) {
M model = Memcache.get(key);
if (model == null) {
if (!Memcache.contains(key)) {
// キャッシュミス時
model = super.get(key);
Memcache.put(key, model);
}
}
return model;
}
@Override
public Key put(M model) {
Key key = super.put(model);
Memcache.put(key, model);
return key;
}
@Override
public void delete(Key key) {
super.delete(key);
Memcache.delete(key);
}
}
各Modelの具象クラスは、上記CachingDaoをextendsするようにします。
ModelRefへのアクセスのキャッシング
Slim3では、Modelのプロパティの1つとして、他のModelへの参照を表すModelRefクラスがあります。
ModelRefを使った参照では、間接的にDatastoreへのアクセスが発生するので、これもキャッシングの対象とします。
※以前は、setxxxModelでキャッシュに登録していましたが、これをするとDatastoreと同期がとれなくなるので、止めました
ModelRefを使った参照では、間接的にDatastoreへのアクセスが発生するので、これもキャッシングの対象とします。
※以前は、setxxxModelでキャッシュに登録していましたが、これをするとDatastoreと同期がとれなくなるので、止めました
@Model(schemaVersion = 1)
public class FooModel implements Serializable {
....
private ModelRef<BarModel> barModelRef = new ModelRef<BarModel>(BarModel.class);
public BarModel getBarModel() {
Key key = barModelRef.getKey();
if (key == null) {
// keyがnullなら、キャッシュ/Datastoreにはアクセスしない
return barModelRef.getModel();
}
M model = Memcache.get(key);
if (model == null) {
if (!Memcache.contains(key)) {
// キャッシュミス時
model= barModelRef.getModel();
Memcache.put(key, model);
}
}
return model;
}
....
}
大抵の場合、上記の対応だけでも、かなりDatastoreへのアクセスが減ります。
※WebコミックLibraryの場合、コレだけでキャッシュヒット率が94%です。つまり、殆どキャッシュです。
※WebコミックLibraryの場合、コレだけでキャッシュヒット率が94%です。つまり、殆どキャッシュです。
クエリ結果のキャッシュ
Memcacheのキーとしては、クエリの種類や検索条件を表す文字列を用います。
- 例えば、「クエリを実行したDAOクラス名+メソッド名+検索キー」等
これは、キャッシュ更新方針が難しい(Entityが更新された場合、キャッシュされたクエリ結果をどうするか等)ので、アプリケーション毎に最適な方針を検討します。
(後日追記予定)
(後日追記予定)