14.CA Beatのシステム基盤 第二回「自動Entityキャッシュ」 | CA Beat エンジニアのブログ

CA Beat エンジニアのブログ

Google App Engineをメインに技術情報を発信しています。

$CA Beat エンジニアのブログ-13.gae_default


CA Beat エンジニアリーダーのヤマサキ(@vierjp)です。

CA Beatのシステム基盤 第二回は「自動Entityキャッシュ」です。
これは実装時に意識する事無くEntityのデータを自動でMemcacheに追加・削除する処理です。



○きっかけは「appengine ja night」
appengine ja night #19」で@najeira さんが紹介していた、
AppEngine Python用フレームワーク 「NDB」のセッションを聞いたことから。
当時弊社で運用をしていたアプリ「Playなう!」の課金額が結構高かったため、その対策も含めて作りました。

セッション資料:NDB - A new datastore API



○NDBのキャッシュの種類
NDBには「In-Context Cache」と「Memcache」の2つのキャッシュパターンがあるそうです。
「In-Context Cache」は単一リクエスト内で有効なキャッシュで、おそらくインメモリのキャッシュ。
「Memcache」はAppEngineで広く使われる、皆さんご存知のMemcacheです。

CABeatのシステム基盤として実装したのはMemcacheの方のみです。

(単一リクエスト内だけで有効なキャッシュの使い道が思いつかないので、、と思っていましたが、
ブログを書きながらもう一度考えてみるとコードの書きやすさが上がりそうな気もします)


○自動Entityキャッシュのメリット
・Memcacheからの参照はDatastoreからの参照より高速
・Memcacheの読み書きは課金されないため「Datastore Reads」のコストを節約できる
・実装時に意識しなくても自動的にEntityのMemcacheへの登録・キャッシュクリアが行われる

の3点です。



○処理内容
・get・put・delete時の自動キャッシュ制御
Entityの操作はKeyを指定してget・put・deleteするのでEntityのKeyをそのままMemcacheのKeyにします。
DatastoreからgetするタイミングでEntityのKeyでMemcacheからgetを試みて、
取得できなければDatastoreから取得し、取得したEntityをMemcacheにPutする。
put・deleteする際はEntityのKeyを使ってMemcacheからも削除する。
これらの処理がシステム基盤に隠蔽された形で自動で行われます。


・batch get・batch put・batch delete時の自動キャッシュ制御
Datastoreの「batch get」「batch put」「batch delete」と同様、
Memcacheも複数Keyを指定して一度の呼び出しで複数データを参照・追加(更新)・削除行うことができます。
それを使って一回のMemcacheに対するRPCで一気に全Entityのキャッシュ操作をしますが、
キャッシュはEntity単位(Keyの単位)で行われます。



○やはり制限はNDBと似た感じに

@najeiraさんのスライド P.16より(「Memcache」に関連するものだけ抜粋)

・クエリ
 ・クエリはキャッシュからデータを取得しない
・トランザクション
 ・Memcacheは使わない


公式ドキュメント:NDB Caching - Google App Engine — Google Developers


・クエリについて
NDB同様自動制御はしていません。正確には、無理のない仕組みを思いつかなかったのでやめました。

クエリを自動でキャッシュする事はできそうですが、
関連するEntityの削除時に自動でキャッシュをクリアする事は難しいと感じました。
というのも、クエリのキャッシュを自動で削除する場合は、
あるEntityを更新した際に「そのEntityが含まれているクエリのキャッシュ」を削除する必要がありますが、
この「クエリの結果をMemcacheに登録したKey」と「EntityのKey」の間に関連がないので、
「更新したEntityのKeyを基に自動でクエリの結果データのキャッシュを削除する」のが困難だと思います。

そのため、クエリ結果をキャッシュしたい場合は自動キャッシュではなく、
通常通り自前でMemcacheに保存して、データを更新したタイミングで明示的に削除しています。


・トランザクション内での扱い
トランザクション内ではデータをput・deleteした際にキャッシュのクリアはしますが、
トランザクション内での参照時にはキャッシュは使いません。

EntityのputとMemcacheへのキャッシュの登録が「同時に」、「一貫して」行われる保証は無いので、
トランザクション内でのgetでMemcacheから取得した場合、
ほぼ同時にデータが更新された場合に、
実際のEntityのデータと一致しないデータを取得してしまう可能性がありそうなので
トランザクション内ではキャッシュを使用しないようにしました。

(NDBはトランザクション内でも「In-Context Cache」を使用するそうですが、
別リクエストからほぼ同時に更新された場合はどう解決しているんでしょう・・・?)


○効果があるケース
前述のようにクエリとトランザクション内の処理は自動でキャッシュ制御しないので、
トランザクション外での「get」「batch get」の場合にキャッシュを使用します。

・リクエストパラメーター等で呼び出し元からEntityのKey値を渡されるような処理
Keyを「keyToString」した値やKeyのname値をリクエストパラメーターで受け取るような処理では
このKeyでgetすることがよくあるでしょう。


・実際のデータとクエリ条件用のインデックスデータを別のEntityに分けて保持している場合
$CA Beat エンジニアのブログ-14.Mugen
* 画像は【18-C-4】Google App Engine - 無限の彼方へ (Google松尾さんによるデブサミ2011のスライド)
から引用させていただきました。
AppEngine用アプリの設計をする人にはこのスライドは必見。たくさんの重要なノウハウが書かれています。


「クエリの条件以外には使わないサイズの大きいインデックスデータ」がある場合に、
実データとインデックスデータを「同一EntityGroupの別Entity」に分ける事で、
参照時に「インデックスデータ」のデシリアライズ負荷を不要にする、という設計方針です。
(アプリケーションサーバーとDatastoreの間で転送されるデータ量も減るでしょう)

この場合は
「インデックスデータをKeysOnlyでクエリ→実データをbatch get」の流れなので、
batch getの際に「自動Entityキャッシュ」が作用します。


・クエリの関連データにリアルタイム性を持たせるために正規化している場合
AppEngineのDatastore設計においてはよく「できるだけ冗長化しろ」と言われますが、
「関連する別Entityのデータに対する変更」をリアルタイムに反映させたい場合、
冗長化せずに正規化した上でbatch getをする作りにする事があるでしょう。

例えばtwitterのTLの「タイムライン」と「ユーザー情報」のような関連を考えてみてください。
ユーザーが名前やアイコンを変更した場合に
・過去のツイートの名前やアイコンが古いままで良いなら、冗長化するのが良いです。
・過去のツイートの名前やアイコンを新しい情報に変更する必要があるなら、
 1.正規化した上で「参照時にツイートのデータをクエリした後、ユーザーデータをbatch getしてマージする」
 2.冗長化した上で「ユーザーデータの更新時に、そのユーザーの過去のツイートすべてをTask Queueで非同期に(遅延)更新する」
 のどちらかかと思います。
 (「タイムライン」の場合は想定されるデータ量を考えるとおそらく「2」にはしませんが、考え方的にはこう)

この「1」のケースにおいて、batch getの際に自動でキャッシュが使われます。

ここでは関連がわかりやすいようにtwitterを例にしましたが、
関連データを冗長化して保持するか、正規化するかの判断基準は
・関連データの変更は過去に遡って反映する必要があるか
・変更を反映する場合どれだけリアルタイム性を求めるか
・TaskQueueで遅延更新するのが現実的なデータ量かどうか
等の条件で決まるかと思います。
当然冗長化して一回のクエリで済ませた方が速いので、
スピードと「関連データの変更を(リアルタイムに)反映するかどうか」はトレードオフになります。
「冗長化した上で遅延更新」とした場合、TaskQueueが使うCPUコストと「Datastore Writes」のコストがかかるので、
この場合もトレードオフがあります。


基本的には、
実際に表示する「クエリ&batch getしてマージ済みのデータ」をMemcahceに保存すると思います。
(突き詰めていくとレスポンスのjsonを丸ごとMemcacheに入れるとかFrontendCacheという話になっていきますが、それはまた別の機会に)
これはこれで行いますし、関連データの更新頻度が低い場合はこれだけで十分かと思います。


しかし、上の例で「過去のツイートのユーザー名やアイコンを新しいデータに変更する」とした場合、
このキャッシュは「タイムラインのEntity」だけでなく「ユーザー情報のEntity」が変更・削除された場合にも、
最新の情報を反映するためにキャッシュを丸ごとクリアする必要があります。

twitterのユーザー情報は頻繁に変わらないでしょうが、
関連データの更新頻度が多い場合は頻繁にキャッシュがクリアされるため、キャッシュのヒット率が下がります。
よって、Datastoreに対する「クエリ&batch get」が頻繁に行われキャッシュの効果は薄くなります。

しかし、「Entity単位のキャッシュ」の場合には、
「batch get」の際には「更新されたEntityだけが再度Datastoreから取得される」となるため、
Datastoreから再取得するデータは最小限です。
データの更新頻度が高い場合、キャッシュの粒度を小さくする(併用する)のは有効かと思います。



○まとめ
以上が「自動Entityキャッシュ」の概要と使い所でした。
(途中からデータストア設計の話がメインのようになってしまいましたが、、、)

今回の例はget・batch getを使わずクエリだけでデータを取得しているようなシステムには効果がありません。
しかし、今回のキャッシュの話を抜きにしてもAppEngineにおいてはget・batch getを有効に活用すべきだと思います。

1.getはクエリよりも高速
2.HRDの「クエリのConsistency(一貫性)はEventualである」ため「get」でないと困る事が多々ある

特に「2」は重要で、
現在のDatastore(HRD環境)においてはデータの更新後のインデックス作成が遅延することが結構あります。
そのため、
「実際には存在しているはずのデータをクエリでは取得できなかったり」、
「直前に更新されたデータが更新された事によりクエリの条件に合わなくなったのに取得されたり」
することがあります。
その点EntityのKeyを指定して「get」する場合、この問題は起こりません。

例えば、
「Datastoreにデータをputしてredirect、redirect先でクエリを使ってそのデータを取得する」という処理を作った場合、
redirect先でデータを取得できない可能性があります。

同じ理由で「Datastoreのデータを参照して行うチェック処理」はgetでなければ正確に行うことができません。
あるシステムのユーザー登録において
「そのユーザー名が既に使われているかどうか」というチェックするとした場合、
ユーザー名を条件としてクエリでユーザーEntityを取得して判定しようとすると、
「既に存在しているのに取得できない」事があるからです。
この種のチェックをクエリで行おうものなら簡単にチェックをすり抜けて想定外の動作をします。
これはさほど珍しいことではなく、
更新頻度の高いデータのインデックスにおいては意外と簡単に発生しトラブルに発展します。

よって、クエリは遅延するものと考え、
「遅延が許されない処理ではgetやancestorクエリでデータを取得できるような設計にする」
必要があるでしょう。

getを活用した上で「Entityの自動キャッシュ」をすれば、実際に課金額も下がります。
当時「Playなう」ではこの仕組みの導入前後でDatastore Readが2~3割安くなりました。
上述のとおり、効果の大小はアプリの仕様と設計次第です。


AppEngineのデータストア設計においては、
・重要な処理ではgetでデータを取得できる設計にすること
は重要なポイントの一つだと思います。
そのうちデータストア設計についてもブログを書きたいなぁと思いつつ、今回はこの辺で。


それではまた次回お会いしましょう(`・ω・´)ノシ