この記事書いているあいだにこんなニュースを見つけた。これはすごいや。
100万PV/日のmixiモバイルアプリをGoogle App Engineで実装した@gclue_akira氏に直撃インタビュー
さて、本題
不可能と思い込んでいたことができることもある
何度かイタい目に遭うことで、またどうせ出来ないだろうと思い込んでしまうことを学習性無力感という。長期間にわたって鎖に繋がれたままの犬は、鎖を外してもしばらくは逃げないそうである。GAEはもともと情報が少ないのと、嵌りどころ満載なこともあって、学習性無力感に陥ることがしばしばある。だから、一人悶々とやるより、Blogに晒したり、GAE Nightなどの集まりに出向くなどして、情報共有に努めるのがよろしいかと思う。そこで重要なのは、ありのままの事実を晒すということ。現時点で正しい答えを識っているものは皆無に等しいのだから、誰かに正しい答えを訊くよりかは、自分で実際にやってみて、その結果だけを信じるというスタンスが重要な気がしている。また、思い込みによる間違った情報でも、公開することがきっかけとなり、いろいろと議論が深まって、正しい結論へと導き出せるかもしれない。というわけで、今度のGAE Nightへは、そういうスタンスで乗り込むつもりなので、そこんとこよろしく。(間違った情報を晒すつもりはないが言い訳はさせてね・・)
ところで、当Blogで公開したもののうち、私自身が「学習性無力感」によって不可能と思っていたことがいくつかある。例えば、ココで紹介した前方一致検索は、Low Level APIの2つのFilterを使って、次のように書ける。このように、1つの項目に2つのFilterを指定することが可能で、Bigtable的には、これはRange Scanと解釈されるようである。
String value = "コーヒー";
Query query = new Query("Product");
query.addFilter("product_name",FilterOperator.GREATER_THAN_OR_EQUAL,value);
query.addFilter("product_name",FilterOperator.LESS_THAN,value + "\ufffd"); // "\ufffd"はUNICODEの最大値
前記事では、GREATER_THAN_OR_EQUALの一つしか使っていなかったが、LESS_THANを同時に指定することで、”コーヒー”で始まっていないものを省くことができる。これまでaddFilterは1つだけが指定できると思い込んでいたのだが、内部の動きを知って、なるほどと納得した次第である。
1000件までしか検索できないことはない件
ココでも述べているが、以前はたしかに1000件しか検索できなかった。正確にいうと、JDOでは1000件以上検索できたが、Low Level APIだと1000件が限界だった。ところが、SDK1.2.5では、FetchOptionsのlimitの値を増やして実行することで、2000件以上検索できることがわかった。しかし、30秒ルールの壁があるため、実際には2000件程度が表示の限界だろうと思われる。私たちのアプリでは、2468件の時点で30秒以上となりエラーとなった。(まあ、鎖から開放された犬が全速力で逃げようとして、30M付近の透明の壁にぶち当たったといった感じかな)
また、Keyによる検索や更新については制限があり、Quotas_and_Limitsによれば、Batch Getは最大1000件まで可能で、Batch Put/Deleteでは最大500件まで可能である。また、countEntities()で取得できる件数も最大1000件であるが、datastore statistics APIを使うと1000件以上カウントできるとのこと(MLより)
The query restrictions are an artifact of the way App Engine's datastore is constructed, which makes certain operations (e.g. queries and reads) very fast and scalable but does limit the types of queries you can make, though you can typically get around these restrictions by re-thinking your model a bit.
We are working on adding built-in cursor support for easier paging through entities and have just added a datastore statistics API for, among other things, getting the total entity count, even if it exceeds 1,000. More details here:
http://code.google.com/appengine/docs/java/datastore/stats.html
And we also have a data export utility included with the SDK to make it easier for you to back up or even move off of App Engine should you choose to, and we're continuing to look at ways of making App Engine, particularly the datastore component, easier to use.
http://code.google.com/appengine/docs/python/tools/uploadingdata.html#Downloading_Data_from_App_Engine
使用例
Query query = new Query("__Stat_Kind__");
query.addFilter("kind_name", Query.FilterOperator.EQUAL, "ここにkind名を指定");
Entity stat = datastore.prepare(query).asSingleEntity();
Long count = (Long)stat.getProperty("count");
また、以下のようにすれば、既存のAPIでもcountを取得できる。
これは、Keyだけを対象にした検索であり実際の値までは取りにいかない。また、Keyはメモリー上のSSTableに存在するため非常に高速と思われる。(これはこれで非常におもしろいアイデアである)
でもGoogleの人によると、「実際に計ったわけではないがStaticsの方が速いだろう」とのこと。その理由は、全件読むのではなくKindごと管理している件数を返すだけだから。MLのひがさんのポストより
Low level API:
DatastoreService ds = DatastoreServiceFactory.getDatastoreService();
int count = ds.prepare(new Query("your kind").setKeysOnly()).asList(FetchOptions.Builder.withOffset(0)).size();
パフォーマンス
- 92300件のデータをカウントするのにかかる時間を計測
- Datastore Statistics : 0.553秒
- 自前カウンタ(base)をkeyでget : 0.030秒
- prepare.asList().size() : 17.338秒
- prepare.countEntities()は結果が1000件
- Datastore Statistics : 0.553秒
カウンタの実装とボトルネックについて
アプリが扱うデータは無限個が前提だが、30秒ルールや件数取得の問題があって、実際に無限個のデータを全部扱うのは難しい。なので、私たちは、各々のレコードにシーケンス番号をつけ、Pagingなどの部分的な処理を基本とすることで、これらの制約を回避するように工夫してきた。
シーケンス番号は、レコードのプロパティに保持しており、また、レコードを生成する都度、カウンタを管理するEntityの値をインクリメントする仕組みである。こうすることで、Pagingの際に、どこからどこまで処理したかを特定できるし、全体の個数もわかるようになる。ただし、カウンタ更新とレコード挿入は1つのトランザクションとして実行しなければならず、これがとても重いのが難点であった。例えば、トランザクション処理させるためには、カウンタのEntityとデータのEntityをEntityGroupとして括らなければならないが、そうすると、挿入の都度、Kind全体がロックされることになるので、大きなボトルネックとなってしまう。元来、Datastore書込は読込の10倍以上遅い。さらにDatastoreのトランザクション処理も遅い。そのうえ、テーブルロックに近い設計をしてしまうのは、ありえないぐらい遅くなる。とはいえ、トランザクション処理を諦めるわけにはいかないので、書込に関しては遅くなるのはしょうがない。(せいぜい、Memcacheを駆使した遅延書込で改善できたらいいなあぐらいは考えてはいたが・・。まあ、無力感に満ちた犬ですな)
でも、いろいろ実装してみると、実は、レコードにつけるシーケンス番号はいらないかもしれないということがわかってきた。たしかに、ユニークなKeyは必要だが、Paging処理であれば、keyだけあれば十分。また、前方一致検索の例のように、複数件ヒットした場合でも、Keyと「商品名」を連結した新たな項目を作ることで対応できる。
問題は全体の件数をどう取得するかであるが、これは先ほどの例のようなQueryか、Statistics APIを使えば可能である。Keyについては、KeyRange allocateIds()の自動採番を利用する方法もある。どうしても連番が欲しい場合には、カウンタEntityを作成しても構わないが、レコードのKeyとしては使わず、EntityGroupも構成しない方がいいだろう。そうすれば、カウンタそのものをスケールさせることもできる。(例えば、カウンタEntityを複数もちランダムに選択したものをインクリメントする。そして、すべての合計値をシーケンス番号とする、といった具合に。そんなサンプルがGoogle I/Oで紹介されていた。)
ちなみに、トランザクション処理をしないとどうなるかというと、レコードの更新失敗が起きたときに、カウンタEnitityだけがインクリメントされてしまい、いわゆる歯抜けの状態が起きてしまう。(ユニークな番号としては使える)これは実際に起こる可能性があるものと考えた方がよいが、補償トランザクションなどでデクリメントするような処理は絶対にやってはいけない。(その時点でカウンタEnitityが更新されている可能性があるため)
Keyに何を入れるべきか
当初はシーケンス番号をKeyとしていたが、普通のプロパティに入れてもFetchできるので、これをわざわざKeyにする必要はない。また前述したように、そもそもシーケンス番号でなくてもPagingはできる。Keyにはユニークなものを入れさえすればいいのだが、allocateIds()などで作成した機械だけがわかる値を入れても、何かもったいない気がする。なので、KeyFactory createKey()を使って、アプリのKeyを入れるのがよいのではないかと思う。このように、Keyさえ作成できれば、allocateIds()で取得したときのように、Batch PUTも可能である。
実際に私たちはこのような方法で、以下のような商品マスタ管理用のKindを作成した。ここでは、Shop_code+Product_code+Revisionといったように、アプリでユニークになるKeyを作成している。Revisionは、レコードが更新されるたびにインクリメントされる番号で、楽観的ロックに使っている。また、更新前のレコードは消さないでそのまま保存している。そうすることで履歴管理にもなる。Datastoreの容量は膨大にあるので、こういった大福帳のような感じの設計もありかなと考えている。
ただし、履歴をレコードとして持つ場合、Datastore statisticsのcountだと履歴分も全て件数に含まれてしまうので注意が必要。また論理削除する場合も同様に削除データが件数に含まれる。履歴を持つ、または論理削除する設計の場合で、カウンタ機能が必要な場合は自前のカウンタが必要な気がしている。