2013年2月12日火曜日

18.Google App Engineパターン (appengine ja night #23)




Ryo Yamasaki(@vierjp)です。


2/13に開催された appengine ja night #23 の前半のセッションで
@proppy氏によるDatastoreを中心としたAppEngineのパターンについての説明がされました。
このセッションについて@proppy氏に直接質問させてもらって理解した事も含め、
自身の復習も兼ねて考察してみました。

既に結構時間が立っていますので若干今更感はあるのですが、
書き始めてみると思いのほか濃い内容になった気がします。

なお、後半のセッションでBrian氏によって語られた「Google Compute Engine」については
"公開されているドキュメントをベースに、ドキュメントに書かれている範囲で"(重要w)
可能であればajn #23のスライドの内容にも踏み込んで近いうちに記事を書こうかと考えています。

関連リンク:
appengine ja night #23 が開催されます - Google Japan Developer Relations Blog
appengine ja night #23 - YouTube (セッションの全てが動画で公開されています)
Optimizing Your App Engine App - Google IO 2012 (セッションのスライド)


 今回のブログはセッションのスライドに沿って書いていますので、
ブラウザの別ウインドウでスライドを開きつつ読んでいただけるとより一層お楽しみいただけます(´▽`)
また、このスライドには各ページに「Run」ボタンがついていて、
その画面の処理を実行した際のAppstatsの詳細画面に繋がります。

Appstatsには各処理の実行時間とコストが表示されるため、
・なぜそのコードがいけないのか
・なぜそう書くのが正しいのか
が可視化された形でわかるようになっています。
(詳しくは前記事の「appstats(Java)でRPCのコストと処理時間を調べてみよう」を参照)

それでは@proppy氏による「App Engine パターン」を順に見ていきます。

◯ 目次

#1 クエリよりもgetを優先的に使う
#2 不要なインデックスを作らない
#3 Projectionクエリを活用する
#4 ページングはoffsetではなくカーソルを使う
#5 キャッシュを活用する
#6 RPC呼び出しをシリアルに行わない
#7 RPC呼び出しをシリアルに行わない(2)
#8 トランザクション管理をしよう
#9 同一トランザクション内で更新後に参照しない
#10 整合性が必要な場合はグルーバルクエリを使わない


◯ #1 クエリよりもgetを優先的に使う

クエリで取得するのは速度的にも課金額的にも良くないので、
できるだけgetを使いましょう、というお話です。

P.3からはじまる#1は、
ユーザー名を条件に「User」Entityを取得するケースを例としています。
アンチパターンの例では保存時にKeyを明示的に指定せず
「name」というプロパティにユーザー名を保持しています。
その後データを取得するためにnameプロパティの値を条件にクエリしています。

P.4ではユーザー名をKeyのname値にする形でEntityを保存し、
その後Keyの値を指定して"get"しています。

AppEngineのクエリは「インデックステーブル」を利用して実行しています。
クエリする際には
1.インデックステーブルを参照する
2.インデックステーブルが示すEntityの実データを取得する
と2つのプロセスでEntityのデータを取得します。
そのためDatastore Readの回数は2回になります。

対してgetした場合にはDatastore Readの回数は1回です。
getに比べてクエリは課金額は増えるし処理的にも無駄があって遅い。
単純にコストに2倍の差があるのでシステム全体で見れば結構な影響があるでしょう。

ただしKey値は同一Kind内でユニークな値(RDBの主キー的な値)なので、
このようにユーザー名からKeyを生成してgetするデータ構造にするなら
システムの仕様として「ユーザー名がユニークである」という前提が必要です。

逆にこの前提があるなら「参照時にクエリではなくgetしたい」という理由だけではなく、
ユーザー名をユニークであることを保証するためにユーザー名をKeyに保持するのが自然です。

また、Keyでgetするかプロパティでクエリするかは、後述の#10の内容にも絡んできます。
データを確実に取得するためにも、仕様上ユーザー名がユニークであるなら、
ユーザー名をKey値に保持した上で「Keyでget」するのが良いでしょう。

DatastoreにおいてKeyはとても特別で、とても重要な値です。
1.データを高速に・かつ低コストに取得するための値 (#1の内容)
2.データを確実に取得するための値 (#10の内容)
3.データのユニーク性を保証するための値

RDBのデータ設計をする際は
「主キーにビジネス的な意味を持つ値を持たせないようにする」は無難な方針だと思いますが、
Datastoreにおいては各プロパティにRDBのようなユニーク制約を指定することはできないので、
値のユニーク性を保証するため、また必要なタイミングで(クエリではなく)getでデータを取得するため、
ビジネス的な意味を持つ値を積極的持たせることになると思います。

Slim3の「Datastore.putUniqueValue(uniqueIndexName, value)」がしている方法で
Key以外のプロパティ値のユニーク性も保証することができますが、
少し気をつけるべきことがあります。
このメソッドを呼んで「その値をユニークな値として確保する」のと、
その後の「その値を含むEntityのput処理」が別トランザクションになるので、
Entityのputで失敗してもこちらは自動でrollbackされません。

よって、このメソッド呼び出し後の処理でputに失敗してユーザーにシステムエラーを返した場合、
何らかの対処をしない限り次のリクエストで同じ値を使えなくなります。

例えばユーザー登録処理なら
「登録ボタン押す」→「システムエラー」→「もう一度登録ボタン押す」→「その名前は既に使われています」
はちょっと嫌かな、と。
逆に「ランダムなお客様番号の払い出し」のような処理なら積極的に使えます。
ちなみにこのメソッドも内部的な仕組みとしては
「value 」でKeyを生成しトランザクション内で「uniqueIndexName」をKind名とするEntityをputすることで
ユニーク性を保証しています。

少し話が膨らんでしまいましたが「Keyをどう定義するか」はDatastore設計においてとても重要です。


◯ #2 不要なインデックスを作らない

P.6から始まる#2では、
「不要なインデックスを作らないことで、コスト的にどれたけの違いがあるか」を示しています。

「不要なインデックスは作らないようにするべし」とは以前からよく言われていますが、
こうやって具体的な数値として見ると、改めてその効果に驚き、また、Appstatsの価値を実感します。
(Appstatsに関しては「appstats(Java)でRPCのコストと処理時間を調べてみよう」を参照)

P.6P.7のAppstatsに表示されるDatastore Writeの回数は以下の内訳になるかと思います。

○「secret」にインデックス有りのEntityの登録 (P.6)
 ・Entity実データ書き込み (Write x 1)
 ・インデックス(カインド・インデックス?)の書き込み (Write x 1)
 ・「secret」プロパティのシングルプロパティ・インデックスの昇順・降順の追加(Write x 2)
合計でDatastore Writeが4回

○「secret」にインデックス有りのEntityの更新 (P.6)
 ・Entity実データ更新(Write x 1)
 ・「secret」プロパティのシングルプロパティ・インデックス(昇順・降順の2つ)の
  変更前の値「iamunindexed」の削除 (Write x 2)
 ・「secret」プロパティのシングルプロパティ・インデックス(昇順・降順の2つ)の
  変更後の値「iamreallyunindexed」の追加 (Write x 2)
合計でDatastore Writeが5回


○「secret」にインデックス無しのEntityの登録 (P.7)
 ・Entity実データ書き込み (Write x 1)
 ・インデックス(カインド・インデックス?)の書き込み (Write x 1)
合計でDatastore Writeが2回

○「secret」にインデックス無しのEntityの更新 (P.7)
 ・Entity実データ更新 (Write x 1)
合計でDatastore Writeが1回

「シングルプロパティ・インデックスが有効なプロパティ」が1つあるだけで、
Entityの新規登録時のDatastoreWritesの回数は2倍、更新時は5倍となっています。

登録時は「シングルプロパティ・インデックスが有効なプロパティ」の数*2のWriteが追加で行われ、
更新時は「シングルプロパティ・インデックスが有効なプロパティ」の数*4のWriteが追加で行われます。

「シングルプロパティ・インデックスが有効なプロパティ」が2つなら、
この場合新規登録時のWrite回数は3倍、更新時は9倍となります。

実際にスライドの例に「secret2」を追加して試してみところ、この計算通りになりました。
from google.appengine.ext import ndb

class User(ndb.Model):
    _use_cache = False
    _use_memcache = False
    secret = ndb.StringProperty()
    secret2 = ndb.StringProperty()

k = User(secret='iamindexed',secret2='iamindexed2').put()
# in another request
user = k.get()
user.secret = 'iamreallyindexed'
user.secret2 = 'iamreallyindexed2'
user.put()

と言っても、プログラムが必要としないインデックスを完全に排除してしまうと、
DatastoreViewerからのクエリがしづらくなって運用上きつくなります。
現実的には
1.運用上必要な最低限のインデックスは残す
2.BigQueryに簡単に転送できる仕組みを用意する (TT終了マダー?(・∀・)っ/凵⌒☆チンチン)
のどちらかが必要でしょう。


◯ #3 Projectionクエリを活用する

P.9からの#3は「Projectionクエリ」についての説明です。
以前まで、Datastoreに対するクエリは「Entityを丸ごと取得する」ことしかできず、
「Entityに含まれる一部の値だけを取得する」ことはできませんでした。
 (クエリが「SELECT * FROM 〜〜」のみで、「SELECT hoge FROM 〜〜」ができなかった)
それを可能にするのが「Projectionクエリ」です。

#1に書いたとおり、通常クエリは以下の二段階のプロセスで行われます。

1.インデックステーブルを参照する
2.インデックステーブルが示すEntityの実データを取得する

スライドの例では、
「city」の値を条件にUserカインドをクエリして「email」の値を取得するのが目的です。
この場合に「city・email」の複合インデックスが存在しているなら、
このインデックスを参照することで「1」のプロセスの時点でemailの値を取得できます。
そのため「2」のプロセスを省くことができます。
これが「Projectionクエリ」の仕組み。(たぶんこれで合ってると思う・・・!)

Projectionクエリを使った場合、処理的には「Small Datastore Operation」になります。
「Small Datastore Operation」は「Datastore Read」に比べてそのコスト(課金額)は1/7です。
よって、Projectionクエリを使えるのであれば、
普通にクエリして結果を取得するのに比べて参照コストは大きく下がることになります。

しかしその代わりに
「プロジェクションクエリを実行するために必要な複合インデックス」
を用意する必要があります。
スライドにおいて「"maybe" cheaper」と書かれているのはそのためで、
もしこのインデックスを使用するProjectionクエリの実行頻度が少なく、
逆にこのインデックスに含まれる値の更新頻度が高いとしたら、
むしろインデックスを追加した分Datastore Writeのコストが上がり、
総合的にコスト高になる場合もあり得ます。
そのため、参照頻度と更新頻度を考慮した上で使用するのが良いでしょう。
(「必要なインデックスが別のクエリの都合で元々存在している」なら良いのですが)

また、ProjectionクエリはインデックステーブルのみにアクセスしてEntityそのものにはアクセスしないため、
インデックスの生成が遅延した場合(詳しくは#10を参照)、
「取得した値が実際に記録されている値よりも古い可能性がある」でしょうから、
この点も注意が必要かと思います。

他にProjectionクエリには以下の特徴があります。

・結果のdistinctができる
・インデックスを設定できない「Text型」「Blob型」はProjectionクエリでは使えない
・「=」や「IN」で条件に指定したプロパティを取得する事はできない
 ○ SELECT A FROM kind WHERE B = 1 (条件と取得するプロパティが異なる、はOK)
 ○ SELECT A FROM kind WHERE A > 1 (プロパティは同じだけどin equalityフィルターで条件指定はOK)
 ☓ SELECT A FROM kind WHERE A = 1 (equality フィルターで条件指定したプロパティを取得はNG)
 (これってクエリ投げる前から結果わかってるんじゃ・・と思ったけどList Propertyでややこしい話になるのかな?)

参考:Projection Queries - Google App Engine — Google Developers

残念ながら私の好きなSlim3は現段階でProjectionクエリに未対応です。
(そのため私は使った事がありません^-^;)
しかし自前で機能を追加してコードも公開している方がいらっしゃるので参考にすると良いかもしれません。
slim3でProjectionQueryを使えるようにしてみた - Orfeon Blog 


◯ #4 ページングはoffsetではなくカーソルを使う

P.12からの#4はページング処理において「offset」ではなく「カーソル」を使いましょうというお話。
「AppEngineでページングをする場合にはoffsetではなくカーソルを使うべし」
というのは以前からよく言われている話ですが、
ここではAppstatsによってそれが数値化されてわかりやすく示されています。

「1度のクエリで10件ずつ結果を取得する」とした場合、
プログラムで受け取る結果のデータはoffsetを使ってもカーソルを使っても同じで、
最初のクエリで「1~10件目」のデータを取得、
次のクエリで「11-20件目」のデータを取得となります。

しかし、Datastore Readの回数は、
offsetを使った2回目のクエリでは21回
カーソルを使った2回目のクエリでは11回(1ページ目取得時と同じ)
と大きく異なります。
offsetの場合には内部的に
「1ページ目も含めて2ページ目までのデータを全て読み込んで、その上で1ページ目の分のデータを切り捨てる」
としているため、このような結果になります。

スライドにはカーソルはoffsetに比べて「Faster, less bandwidth, cheaper」と書いてありますが、
ページ数が増えるほど「無駄に読み込むデータ」が増えていく、と考えれば理解は簡単です。
1ページ10件で100ページ目ならDatastore Readは「1001」になってしまい、
速度的にも課金額的にも大きな無駄があります。
(カーソルなら1000ページ目でもDatastore Readは「11」です)
また、offsetに大きな数字(5万とか)を指定すると
データの取得に時間がかかりすぎてタイムアウトしてしまうこともあり、(DeadlineExceededExceptionが発生)
システム的な限界を作ってしまいます。

Admin ConsoleのDatastore Viewerではリクエストパラメーターで「offset」の値を指定できるところを見ると、
内部的に「offset」を使っているのかもしれません。
1Entityのサイズにもよるのでしょうが、
実際にDatastore Viewerでoffsetに5万程度の数値を指定するとエラーになります。
もっともDatastore Viewerでoffsetを指定して結果を参照できないとそれはそれで不便なので、
これはトレードオフの結果なのかもしれません。

「指定したページに直接遷移する」という要件では「offset」を使いたくなる事もあるかもしれませんが、
コスト的にも高くつくし、システム的な限界が生まれていまうというデメリットがあるのでその点注意が必要です。


◯ #5 キャッシュを活用する

P.15から始まる#5は、可能であればキャッシュを有効に使おう、というお話です。
・Datastoreから取得するよりMemcacheから取得した方が速く、安い
・Memcacheから取得するより「サーバーインスタンスのメモリ」から取得した方が速い

ここで使っている「サーバーインスタンスのメモリにキャッシュする機能」は
AppEngineが標準の機能として提供しているわけではなく、
「In-Context Cache」と呼ばれる、Python用フレームワークNDBの機能です。
NDB Caching - Google App Engine — Google Developers
日本語の資料では「@najeiraさんのスライド」が詳しいと思います。
(キャッシュについてはP.10から)

NDBの「In-Context Cache」は、
「そのリクエスト内」または「そのトランザクション内」を有効なスコープとしてキャッシュします。
また、NDBは「Memcacheを使ったEntityの自動キャッシュ制御」も自動でやってくれます。
(なぜ「そのトランザクション内」というスコープがあるのか、おそらく#9で説明された件と合わせているのでしょう)

NDBや他の同様の機能を提供するフレームワークを使わずに「In-Context Cache」を行うなら、
「自分で都度変数に保存しておいて再利用する」か、
それを自動で行う「システム基盤」を作る必要があります。

・「Memcacheを使った自動Entityキャッシュ制御」についてSlim3をラップする形で実装している例
14.CA Beatのシステム基盤 第二回「自動Entityキャッシュ」|CA Beat エンジニアのブログ
(過去に私が書いたエントリーですが)


◯ #6 RPC呼び出しをシリアルに行わない

P.19からの#6は、
複数のEntityを操作したい場合には「get」「put」「delete」を何度も繰り返すのではなく、
「batch get」「batch put」「batch delete」を使いましょうというお話。
アプリケーションサーバーとDatastoreの間ではメソッド呼び出しのタイミングで通信が発生するので、
繰り返し実行すると通信回数が増え、無駄が大きくなります。

スライドの内容に合わせて言えば、
「1つのEntityをputするRPC呼び出しを順番に100回実行する」(P.19)のではなく、
「100個のEntityのputをまとめて一回のRPC呼び出しで実行する」(P.20)べき、
という事です。

P.21のNDBの例は「1つのEntityのputを100回実行する」のですが、
一つずつ順番に行うのではなく、非同期で同時に100回実行しています。
相手からの返事を待たずに次の呼び出しをしているので、
こちらも順番に実行するよりも速くなります。


◯ #7 RPC呼び出しをシリアルに行わない(2)

P.23P.25は複数のURLFectchを実行して結果を取得する例ですが、
話の趣旨としては前項のP.21と似ていて、
「一つずつ順番にアクセスすると遅いから、並列に実行しましょう」
というものです。
・順番に実行した場合全てのURLFetchの実行時間の合計の時間がかかりますが、
 並列に実行すれば全ての結果を取得するのに必要な時間は「一番遅いRPCの時間」だけになる
・非同期に実行する場合、この待ち時間の間に別の処理を実行することもできる
というメリットがあります。

ちなみに、DatastoreアクセスやURLFetchのような標準で提供されている非同期処理以外にも
自分で作った処理をマルチスレッドで実行することができます。
https://developers.google.com/appengine/docs/java/runtime?hl=en#The_Sandbox
制限として
1.「同時に実行するThread数は50まで」
2.「生成したスレッドの生存期間はリクエストを返すまで」
がありますが、
「1」は「java.util.concurrentパッケージ」を使って
Executor executor = Executors.newFixedThreadPool(50);
とすればそれで済みます。
「2」はこの処理のように「別スレッドで実行した全ての処理が終わるまで待つ」前提ならば
実用上何の問題もありません。
リクエストを受けたThreadの終了と関係なく非同期で処理を移譲して結果を待たないようにしたいなら、
これまで通りTaskQueueを使えばOKです。


◯ #8 トランザクション管理をしよう

P.26からはデータの整合性を保つためトランザクション管理をしましょう、というお話。

P.26がトランザクション管理なし
P.27がトランザクション管理あり
の例で、
どちらもEntity「User(kazo_sensei)」と「それを親EntityとするUser(proppy)」の2つをPutしています。

AppEngineでは「親EntityのKey」を指定して「EntityのKey」を生成することによって、
EntityGroupを構成することができます。
同一EntityGroupに所属するEntity同士は、トランザクション管理することができます。

トランザクション管理をすることによって、以下の事ができます。

・Datastoreの更新は「全か無かのどちらか」になる。

RDBでトランザクション管理した場合と同じで、
「成功して全部適用されるか、失敗して全部なかったことになるか」
のどちらかになることが保証されます。

・一貫性と独立性の保証

#9の中にも関連しますが、
Transaction開始後に最初の操作を行ったEntityのEntityGroupのスナップショットから参照するので、
トランザクション中に複数Entityを参照した場合にも
どちらか一方だけが他のトランザクションから更新された状態で読み出してしまうようなことはなく、
取得したデータの一貫性が保証されます。
(ただし同一トランザクション内で参照できるのは同一EntityGroupに属するEntityのみ)

・TaskQueueにも使える

TaskQueueへのTaskの登録もTransactionに参加できます。
これはAppEngineでデータの結果整合性を保つために重要になります。

Transcation中でTaskQueueにTaskを追加する場合、
上記の「全か無のどちらか」に「QueueへのTaskの追加」も含まれます。

つまり、「Entitiyの更新とTaskQueueへのTask登録」を同一トランザクションで行った場合、
「Entityの更新とTaskの登録の両方が成功する」か、
「Entityの更新とTaskの登録の両方が失敗する」のどちらかである、
という形で一貫性が保証されます。

TaskQueueへの登録がトランザクションに参加できる事」は
AppEngine上で動くアプリの開発を行うにおいてとても重要な事です。

AppEngineにおいて複数のEntityGroupをまたがって複数データの整合性を保つ方法は基本的に2つで、
一つがXGTx(クロスグループ・トランザクション)、
もう一つが「TaskQueueによる遅延更新で結果整合性を保つ」という方法。
(補償トランザクションは厳密ではないので除外しています)

TaskQueueにTaskを登録すると、基本的にはTaskが成功するまで自動でリトライされます。
よって、
「トランザクション内でのEntityの更新」と
「TaskQueueへのTaskの登録」を
一貫性を持って行うことができるなら、
トランザクション内で
Entityの更新と「別EntityGroupに属するEntityの更新を行うTaskの登録」をすることで、
「リアルタイムでは無いものの、複数のEntityGroupに対する更新を(結果)整合性を保証した上で行える」
と言えます。

この方法は
「Taskによって行う更新処理が遅延する」というデメリットはあるものの、
(通常Taskはすぐに処理されるが厳密にリアルタイムではないし、遅延する場合もある)
「他のトランザクションが対象のEntityを更新中はトランザクション内でのgetがエラーになるXGTx」と比べて、
スケーラビリティは高いように思います。

この時Taskで行う処理の内容は「別EntityGroupに属するEntityの更新」に限らず、
「Entityに対応するSearch APIのデータ(Document)を更新する処理」や、
「別のシステムに対してデータの更新をAPIで通知する処理」でも良いです。
トランザクション中でTaskQueueを使うことで、複数EntityGroup間だけではなく、
「AppEngineのDatastoreのデータ」と「別のデータや外部のシステム」との間でも
データの結果整合性を保つことができます。

逆に「EntityGroupをまたがる全てのEntityが同時に更新される」事が必要であれば、
TaskQueueを使った方法ではなく、XGTxが必要になります。

同時に更新される事が要求される例としては、
登録する「別のEntityGroupに所属する複数のEntity」が
別の処理において「そのEntityが存在するかどうかのチェック処理」で使われる場合があります。
この場合、これらのEntityはリアルタイムに、一貫性を持って、同時に登録されている必要があります。
この場合はTaskQueueを使った遅延更新では問題が起こるので、
「XGTxにする」か、「XGTxが必要無いようにデータ設計を見直す」必要があります。
(データ設計を見直しても無理なものは無理でしょうけど)


◯ #9 同一トランザクション内で更新後に参照しない

P.29からの#9は、
同一トランザクション内では
「トランザクション開始時のスナップショットからデータをgetする」ため、
トランザクション内で更新→削除とした場合には更新前のEntityを取得してしまうというお話です。

更新処理を行うトランザクションを終了した後、再度取得しましょう。


◯ #10 整合性が必要な場合はグルーバルクエリを使わない

P.32からは「クエリで取得するデータ」についての話です。
「グローバルクエリ」はいわゆる普通のクエリのことで、
・Ancestorクエリではない
・KeysOnlyクエリではない
・Projectionクエリでもない
検索条件を指定してEntityを取得するクエリです。

個人的には#10は今回のセッションの中で一番重要だと感じました。
なぜなら、最近まで多くの人が認識していなかったと思われる、重要かつ新しい情報が含まれています。

#1に書いたとおり、クエリを実行する際に内部的に行われている手順は
1.インデックステーブルを参照する
2.インデックステーブルが示すEntityの実データを取得する
です。

そして、#10の内容についてはこの「1」と「2」それぞれの処理において、
期待と異なる結果を取得してしまう場合があることを示しています。


・「1」の処理において期待と異なる結果を取得してしまうケース


これについては従来から広く認識されています。
インデックステーブルの更新が遅延することで
「クエリの条件に合わない結果が取得される事がある」
という問題です。
これは「Eventual Consistency」という言葉とセットで以前から知られています。
P.32の例では、
「kaz」と「proppy」をputした後の一回目のクエリの結果では「kaz」しか取得できていません。
「proppy」がまだインデックステーブルに反映されていないため取得できなかった、というケースを示しています。
(これは必ずこうなるというわけではなく、「こうなる場合がある」という話です)


・「2」の処理において期待と異なる結果を取得してしまうケース


こちらはつい最近までほとんどの人が認識していなかった話ではないでしょうか。
少なくとも私の周りで知っている人はいませんでした。
P.32の3回目のクエリでは、取得結果が「kaz:python,proppy:golang」と書かれています。
「kaz」のloveは「golang」に変更されているはずなのに「love」の値が「python」になっています。
これは「クエリの結果として取得したデータの値が最新ではない可能性がある」という事を示しています。

これまでは、
インデックスの更新が遅延することにより
「1」の処理においてクエリの条件に合わないデータが返ってくる可能性があるが、
「2」の処理において取得したEntityの値そのものは最新のデータである
というのが多くの方の認識だったのではないでしょうか。

取得した値が最新であるなら、
クエリの条件と合わない結果が返ってきても
値をチェックすることでクエリの条件に一致するデータなのかどうかをチェックすることができます。
しかし「取得した値が最新ではない場合がある」なら
取得したデータを確認してもこれをチェックすることはできません。

この「2」の処理が「batch getと同等の処理」であれば、取得したEntityのデータは最新であるはずです。
ですが、実際にはこの「2」の処理は「batch getと同等の処理」ではなく、
「古いデータを取得してしまう可能性のある別の何か」のようです。
セッションの中で「レプリケーション」という言葉が出てきていたので
「非同期にコピーされた最新ではないかもしれないデータ」を参照するのでしょうか。

対策として紹介されているのは
・Ancestorクエリ
・keys_only query + get_multi
の2つです。


・Ancestorクエリ (P.33)


P.32と違い、P.33のクエリ結果は全て「kaz:golang,proppy:golang」と正しい値になっています。
よって、Ancestorクエリでは最新のデータを正しく取得できる事が保証されているようです。
Ancestor Queryはトランザクションにも参加できるので、グローバルクエリとは仕組みも大きく違うのでしょう。

ただし、Ancestor Queryは親EntityのKeyを指定して実行するため、
「クエリ対象のEntityが同一EntityGroupに所属している」必要があります。
同一EntityGroupに所属するEntityはトランザクション中で一貫して更新することができる代わりに、
同時更新することができません。
(同じEntityGroupに所属するEntityAとEntityBを複数のトランザクションから同時に更新することもできません)

EntityGroupを使う場合「データの整合性」を確保できる代わりに、
「更新性能が下がる」というデメリットもついてきます。

EntityGrupの使用を検討する場合、同時更新性能の低下には相当の注意が必要です。
EntityGroupはそのEntityGroupに対する更新頻度をしっかり意識した上で使わないと、
「(更新処理において)満足にスケールアウトしないAppEngineアプリ」が出来上がる可能性があります。
(この辺のトレードオフをしてうまくバランスを取る事はAppEngineのデータ設計におけるポイントかと思います)

「更新するため」や「値をチェックするため」ではなく「表示するため」にクエリを実行しているなら、
次項の「keys_only query + get_multi」の方が使用する場面は多そうに思います。
その上で厳密な整合性が必要な場合には「Ancestor Query」を検討するのが良いでしょう。


・keys_only query + get_multi (P.34)


クエリの結果としてEntityを取得するのではなく、
最初のKeysOnlyクエリでEntityのKeyだけ取得し、Entityの実体は「batch get」で取得しています。
やっていることは上述の「1」と「2」の処理をプログラム側から別々に実行するような内容です。
しかし、「2」の処理として「batch get」をするため、確実に最新のデータを取得する事ができます。
逆に「1」の処理においては
従来の「インデックスの更新が遅延したことにより異なる結果を取得する問題」は残ります。
(P.34の一回目のクエリでは、直前にputした「proppy」を取得できないケースがある事が示されています)


・どの選択肢が正しいのか?


#10に関しては、他のほとんどのパターンのように「こうすれば万事OK」的な答えはありません。
基本的には「可能であれば極力getでデータを取得できるように作るべき」と考えていますが、
データをクエリで取得する場合にはメリットとデメリットを考慮した上で
複数の選択肢から状況に応じて選択する必要があります。

よって、クエリを実行するパターンには
(A) Ancestorクエリ ←参照においては最も確実な方法だが、同時更新の点でデメリットがある
(B)「keys_only query + get_multi」←「1」の処理でインデックスが遅延した場合の問題は残る
(C) グルーバルクエリ←「1」と「2」両方の処理で問題が発生し得る。
の3つの方法があり、#10では「グルーバルクエリ」を「アンチパターン」としているため、
「AかBから選択するのが良い」ということになります。

ちなみにBの「keys_only query + get_multi」は「#5 キャッシュを活用する」の話と組み合わせると、
「batch get」のタイミングでMemcacheから取得することができるので、
「Entityの自動キャッシュ制御」が無い場合に比べてコスト的・速度的なデメリットは少なくなります。
もし元々の処理でクエリの結果を明示的にキャッシュしていなかったのなら、
「Entityの自動キャッシュ制御」と組み合わせて「keys_only query + get_multi」することで、
条件によってはコストが下る可能性すらありそうです。

ところで、現状Slim3のModelQueryクラスには「keys_only query」でカーソルを使うためのメソッドが無いようです。
ModelQueryは拡張することが難しそうだったので、
「ModelQueryのソースコードに直接メソッドを追加してクラスパスの優先順位を利用してModelQueryだけ差し替える」
という対応をしているのですが、
バージョンアップの度に修正するのが面倒なので、
メソッド追加してもらうかコミットさせて欲しいな。。(´・ω・`)
(実は最初からあったりして・・・無いですよね?)


○更新対象のデータを取得する方法について

「更新のためのデータの取得」が正しくできない事はデータの整合性を崩す原因にするので、
多くの場合「表示するためのデータの取得」よりも厳密性が要求されます。

「1」の処理で起こり得る「インデックスの更新が遅延する」問題を考慮した上で
確実に目的のデータを取得して更新するためには、
データを「Ancestor Query」か「get」で取得できる必要があります。
グローバルクエリも「keys_only query + get_multi」も、
クエリ用のインデックスの更新が遅延して更新対象であるEntityを取得できない場合があるので
更新漏れのリスクがあります。
「keys_only query + get_multi」でクエリ条件と異なるEntityを取得してしまった場合なら
get後に値をチェックして更新対象から除外する事もできますが、
あるはずのデータを取得できなかった場合には対処のしようがありません。

「Ancestor Query」を使用するためには「EntityGroup」を構成する必要があり、
更新性能の低下というデメリットがセットで付いてくるので
使いどころはなかなか難しいです。
もちろんシステムの特性やそのEntityの更新頻度によっては
スケーラビリティをいくらか妥協してデータの整合性に寄せるのもアリなんでしょうが。

同一ユーザーによってのみ更新されるEntityのような、
「同時更新性能の低下が問題にならないEntity」であれば「Ancestor Query」を検討しつつ、
「必要なタイミングでKeyでgetできるようなデータ構造にしておく」のが良いでしょう。

「クエリがEventual Consistencyである」ということがデータ設計に与える影響は大きく、
私がデータ設計を行う際にも特に慎重に検討している部分です。
この点もAppEngineのデータ設計のポイントと言えるでしょう。



◯ まとめ

これまでに書いたエントリーと比べても随分長くなってしまいましたが、
以上が「@proppy氏による「App Engine パターン」についての考察でした。
若干違う方向に話が膨らみすぎた感もありますが、
AppEngineのDatastoreの「パターン・アンチパターン」という話になると、
どうしてもデータストア設計に関連した話に結びつけたくなってしまいました。

AppEngineの難しいところはやはりDatastoreの制約から来る「設計の難しさ」でしょう。
「データの整合性を確保した上でスケーラビリティを高く保つ」を必須条件とするなら、
・Datastoreの制約(クエリやトランザクション)に対する知識が必要。
・このセッションのような「パターン・アンチパターン」に関する知識が必要。
・設計者は「データ設計」だけではなく「プログラム設計」もできる必要がある。
 「Eventual Consistency」に関連して「必要なタイミングでクエリでなくgetでデータを取得できる必要がある」ため、
 プログラムはどのタイミングでどのような値を取得できるか、
 それに合わせてどのような値をKeyにすべきか、
 を考える必要があり、その結果プログラム設計とデータストア設計を並行して行う必要がある。
・Datstoreの制約の中で要件を実現するための知識やノウハウが必要
 (シャーディングカウンターとか、List Propertyを使った全文検索とか)
・「データの整合性とスケーラビリティを守りつつ実現する事が難しい仕様」については
 「ユーザーにとって不便にならない代替案」を考え提案できる必要がある。
 (安易にこれに傾くのもダメですけど。
 「AppEngineだからできません」という言葉はあまり聞きたくないし、言いたくない)

ただしこれらは全て「コーディングの難しさ」というより「設計の難しさ」で、
チーム開発を前提としても、
システム基盤とセットで「こういう時はこう書くべき」という指針を示すことで、
コーディングについては比較的なんとかなると思っています。
「そのクエリは実行可能か」
「これらの複数のEntityを同一トランザクションで更新することが可能か」
「このタイミングで確実にチェック処理を行うことが可能か」
といった事は設計レベルで確定していて、
エンジニア全員に「設計に必要な全ての知識」を要求するわけではないので。

逆にAppEngineのアプリは設計がとても重要で、この段階で成功か失敗か、大方決まるように思います。

しかし、設計の難しさを考慮しても
Datastoreの制約に沿って、パターン・アンチパターンを考慮して作ったシステムは
負荷に負ける気も、整合性に問題が生じる気もしません。
設計は少し大変でも、私は運用時におけるAppEngineの絶大な安心感が大好きです。
エンジニアが運用中のシステムのトラブルに追われることなく新規の開発に集中できる事は、
ビジネス的にもエンジニアの精神的にも重要だと思います。
そしてうまく設計できた時はとっても気持ちが良いですヽ(´▽`)ノ


今回の記事の中で私の認識が間違っている点を見つけた方は、ご連絡いただければ幸いです。
できれば優しく・・・(人ω・`)

0 件のコメント:

コメントを投稿