web-dev-qa-db-ja.com

データストアから多数のndbエンティティをクエリするベストプラクティス

App Engineデータストアで興味深い制限に遭遇しました。実稼働サーバーの1つで使用データを分析するためのハンドラーを作成しています。分析を実行するには、データストアから取得した10,000以上のエンティティをクエリして要約する必要があります。計算は難しくありません。使用サンプルの特定のフィルターを通過するアイテムのヒストグラムにすぎません。ヒットする問題は、クエリの期限に達する前に処理を行うのに十分な速さでデータストアからデータを取得できないことです。

クエリを並列RPC呼び出しにチャンクしてパフォーマンスを向上させるために考えられるすべてを試しましたが、appstatsによれば、クエリを実際に並列に実行させることはできないようです。どの方法を試しても(以下を参照)、常にRPCは次のクエリのウォーターフォールにフォールバックするようです。

注:クエリおよび分析コードは機能しますが、データストアからデータを十分にすばやく取得できないため、実行が遅くなります。

バックグラウンド

共有できるライブバージョンはありませんが、ここで説明しているシステムの一部の基本モデルは次のとおりです。

class Session(ndb.Model):
   """ A tracked user session. (customer account (company), version, OS, etc) """
   data = ndb.JsonProperty(required = False, indexed = False)

class Sample(ndb.Model):
   name      = ndb.StringProperty  (required = True,  indexed = True)
   session   = ndb.KeyProperty     (required = True,  kind = Session)
   timestamp = ndb.DateTimeProperty(required = True,  indexed = True)
   tags      = ndb.StringProperty  (repeated = True,  indexed = True)

サンプルは、ユーザーが特定の名前の機能を利用するときと考えることができます。 (例: 'systemA.feature_x')。タグは、顧客の詳細、システム情報、および機能に基づいています。例:['winxp'、 '2.5.1'、 'systemA'、 'feature_x'、 'premium_account'])。したがって、タグは、対象のサンプルを見つけるために使用できる非正規化されたトークンのセットを形成します。

私がやろうとしている分析は、日付範囲を取得し、顧客アカウント(ユーザーではなく会社)ごとに1日(または1時間ごと)に使用される一連の機能(おそらくすべての機能)の機能である回数を尋ねることです。

そのため、ハンドラーへの入力は次のようになります。

  • 開始日
  • 終了日
  • タグ

出力は次のようになります。

[{
   'company_account': <string>,
   'counts': [
      {'timeperiod': <iso8601 date>, 'count': <int>}, ...
   ]
 }, ...
]

クエリの共通コード

すべてのクエリに共通するコードを次に示します。ハンドラーの一般的な構造は、クエリパラメーターの設定、クエリの実行、結果の処理、返すデータの作成を行うwebapp2を使用した単純なgetハンドラーです。

# -- Build Query Object --- #
query_opts = {}
query_opts['batch_size'] = 500   # Bring in large groups of entities

q = Sample.query()
q = q.order(Sample.timestamp)

# Tags
tag_args = [(Sample.tags == t) for t in tags]
q = q.filter(ndb.query.AND(*tag_args))

def handle_sample(sample):
   session_obj = sample.session.get()    # Usually found in local or memcache thanks to ndb
   count_key   = session_obj.data['customer']
   addCountForPeriod(count_key, sample.timestamp)

試した方法

可能な限り迅速かつ並行してデータストアからデータをプルするために、さまざまな方法を試しました。私がこれまでに試した方法は次のとおりです。

A.単一の反復

これは、他のメソッドと比較するためのより単純な基本ケースです。クエリを作成し、すべてのアイテムを反復処理するだけで、ndbが次々にプルするための処理を実行できます。

q = q.filter(Sample.timestamp >= start_time)
q = q.filter(Sample.timestamp <= end_time)
q_iter = q.iter(**query_opts)

for sample in q_iter:
   handle_sample(sample)

B.大規模なフェッチ

ここでのアイデアは、1つの非常に大きなフェッチを実行できるかどうかを確認することでした。

q = q.filter(Sample.timestamp >= start_time)
q = q.filter(Sample.timestamp <= end_time)
samples = q.fetch(20000, **query_opts)

for sample in samples:
   handle_sample(sample)

C.時間範囲にわたる非同期フェッチ

ここでの考え方は、サンプルの時間間隔がかなり広いことを認識して、全体の時間領域をチャンクに分割する一連の独立したクエリを作成し、非同期を使用してこれらのそれぞれを並列に実行しようとすることです:

# split up timestamp space into 20 equal parts and async query each of them
ts_delta       = (end_time - start_time) / 20
cur_start_time = start_time
q_futures = []

for x in range(ts_intervals):
   cur_end_time = (cur_start_time + ts_delta)
   if x == (ts_intervals-1):    # Last one has to cover full range
      cur_end_time = end_time

   f = q.filter(Sample.timestamp >= cur_start_time,
                Sample.timestamp < cur_end_time).fetch_async(limit=None, **query_opts)
   q_futures.append(f)
   cur_start_time = cur_end_time

# Now loop through and collect results
for f in q_futures:
   samples = f.get_result()
   for sample in samples:
      handle_sample(sample)

D.非同期マッピング

Query.map_asyncメソッドを使用すると、ドキュメントでndbが一部の並列処理を自動的に活用するように聞こえるので、この方法を試しました。

q = q.filter(Sample.timestamp >= start_time)
q = q.filter(Sample.timestamp <= end_time)

@ndb.tasklet
def process_sample(sample):
   period_ts   = getPeriodTimestamp(sample.timestamp)
   session_obj = yield sample.session.get_async()    # Lookup the session object from cache
   count_key   = session_obj.data['customer']
   addCountForPeriod(count_key, sample.timestamp)
   raise ndb.Return(None)

q_future = q.map_async(process_sample, **query_opts)
res = q_future.get_result()

結果

全体的な応答時間とappstatsトレースを収集するために、1つのクエリ例をテストしました。結果は次のとおりです。

A.単一の反復

実数:15.645s

これは、バッチを次々にフェッチし、その後、memcacheからすべてのセッションを取得します。

Method A appstats

B.大規模なフェッチ

実数:12.12秒

オプションAと実質的に同じですが、何らかの理由で少し高速です。

Method B appstats

C.時間範囲にわたる非同期フェッチ

実数:15.251秒

開始時により多くの並列処理を提供するように見えますが、結果の反復中にnextを呼び出すシーケンスによって速度が低下するようです。また、セッションmemcacheルックアップを保留中のクエリとオーバーラップできないようです。

Method C appstats

D.非同期マッピング

実数:13.752s

これは私が理解するのが最も難しいです。かなり重複しているように見えますが、すべてが平行ではなく滝に広がっているようです。

Method D appstats

推奨事項

このすべてに基づいて、私は何が欠けていますか? App Engineの制限に達しているだけですか、それとも並行して多数のエンティティをプルダウンするより良い方法がありますか?

次に何をしようか迷っています。クライアントを書き換えて、App Engineに複数のリクエストを並行して作成することを考えましたが、これはかなり強引に思えます。アプリエンジンがこのユースケースを処理できることを本当に期待しているので、不足しているものがあると推測しています。

更新

最終的に、オプションCが私の場合に最適であることがわかりました。 6.1秒で完了するように最適化できました。まだ完璧ではありませんが、はるかに優れています。

数人からアドバイスを受けた後、次の項目を理解し、留意することが重要であることがわかりました。

  • 複数のクエリを並行して実行できます
  • 一度に飛行できるRPCは10個のみです
  • 二次クエリがない点まで非正規化してみてください
  • このタイプのタスクは、リアルタイムクエリではなく、reduceおよびタスクキューをマップする方が適切です。

それで、私がそれをより速くするためにしたこと:

  • 時間に基づいて、クエリスペースを最初から分割しました。 (注:返されるエンティティに関してパーティションが等しいほど、より良い)
  • セカンダリセッションクエリの必要性を取り除くために、データをさらに非正規化しました
  • Ndb非同期操作とwait_any()を使用して、クエリを処理とオーバーラップさせました

私はまだ期待したり好きなパフォーマンスを得ていませんが、今のところ実行可能です。ハンドラーで多数の連続したエンティティをすばやくメモリにプルするより良い方法であることを願っています。

62
Allen

このような大規模な処理は、60秒の時間制限があるユーザーリクエストでは実行しないでください。代わりに、長時間実行される要求をサポートするコンテキストで実行する必要があります。 タスクキュー は、最大10分間のリクエストをサポートし、(通常はF1インスタンスに 128MBのメモリ )の通常のメモリ制限があります。さらに高い制限(リクエストタイムアウトなし、1GB以上のメモリ)の場合は、 backends を使用します。

試してみることは次のとおりです。アクセスすると、タスクキュータスクを起動するURLを設定します。 〜5秒ごとにポーリングするWebページを、タスクキュータスクがまだ完了している場合にtrue/falseで応答する別のURLに返します。タスクキューはデータを処理します。これには数十秒かかることがあり、計算されたデータまたはレンダリングされたWebページとしてデータストアに結果を保存します。最初のページが完了したことを検出すると、ユーザーはページにリダイレクトされ、現在計算された結果がデータストアから取得されます。

8
mjibson

新しい実験的な データ処理 機能(MapReduceのAppEngine API)は、この問題の解決に非常に適しているようです。自動シャーディングを実行して、複数の並列ワーカープロセスを実行します。

2
Martin Berends

私は同様の問題を抱えており、数週間Googleのサポートに協力した後、少なくとも2017年12月の時点で魔法の解決策がないことを確認できます。

tl; dr:220エンティティ/秒からスループットを期待できますB8インスタンスで実行されているパッチ適用済みSDKの場合、最大900エンティティ/秒のB1インスタンスで実行されている標準SDK.

制限はCPU関連であり、インスタンス化されたタイプを変更すると、パフォーマンスに直接影響します。これは、B4およびB4_1Gインスタンスで得られた同様の結果によって確認されます

約30のフィールドを持つExpandoエンティティで得た最高のスループットは次のとおりです。

標準GAE SDK

  • B1インスタンス:〜220エンティティ/秒
  • B2インスタンス:〜250エンティティ/秒
  • B4インスタンス:〜560エンティティ/秒
  • B4_1Gインスタンス:〜560エンティティ/秒
  • B8インスタンス:〜650エンティティ/秒

パッチが適用されたGAE SDK

  • B1インスタンス:〜420エンティティ/秒
  • B8インスタンス:〜900エンティティ/秒

標準のGAE SDKについては、マルチスレッドを含むさまざまなアプローチを試しましたが、 fetch_asyncwait_any 。現在のNDBライブラリは既に、非同期とフューチャーを内部で使用する素晴らしい仕事をしているので、スレッドを使用してプッシュしようとしても、それは悪化するだけです。

これを最適化する2つの興味深いアプローチを見つけました。

マット・ファウスはこの問題を非常によく説明しています。

GAE SDKは、クラスから派生したオブジェクトをデータストアに読み書きするためのAPIを提供します。これにより、データストアから返された生データを検証し、使いやすいオブジェクトに再パッケージするという退屈な作業を省くことができます。特に、GAEはプロトコルバッファを使用して、ストアから生データを必要なフロントエンドマシンに送信します。 SDKは、この形式をデコードし、クリーンなオブジェクトをコードに返す役割を果たします。このユーティリティは素晴らしいですが、時にはあなたが望むよりも少し多くの仕事をします。 [...]プロファイリングツールを使用して、これらのエンティティの取得に費やした時間の完全に50%がprotobufからPythonオブジェクトへのデコードフェーズにあることを発見しました。これは、フロントエンドサーバーのCPUがこれらのデータストア読み取りのボトルネックだったことを意味します!

GAE-data-access-web-request

どちらのアプローチも、デコードされるフィールドの数を減らすことで、protobufの実行に費やす時間をPythonデコードに短縮しようとしています。

両方のアプローチを試しましたが、Mattでしか成功しませんでした。 Evanがソリューションを公開してから、SDK内部が変更されました。 Matt here によって公開されたコードを少し変更する必要がありましたが、非常に簡単でした-興味があれば、最終的なコードを公開できます。

約30のフィールドを持つ通常のExpandoエンティティの場合、Mattのソリューションを使用して、カップルフィールドのみをデコードし、大幅な改善を得ました。

結論として、それに応じて計画する必要があり、「リアルタイム」GAEリクエストで数百を超えるエンティティを処理できるとは考えないでください。

1
blueCat

何らかの並べ替え操作を使用して、App Engineでの大規模なデータ操作を最適に実装します。

プロセスを説明するビデオがありますが、BigQueryが含まれています https://developers.google.com/events/io/sessions/gooio2012/307/

BigQueryが必要なように思えませんが、おそらくパイプラインのMapとReduceの両方の部分を使用したいでしょう。

あなたがしていることとmapreduceの状況の主な違いは、1つのインスタンスを起動し、クエリを反復処理していることです。mapreduceでは、クエリごとに個別のインスタンスが並行して実行されます。すべてのデータを「合計」して、結果をどこかに書き込むには、reduce操作が必要になります。

もう1つの問題は、カーソルを使用して反復する必要があることです。 https://developers.google.com/appengine/docs/Java/datastore/queries#Query_Cursors

反復子がクエリオフセットを使用している場合、オフセットは同じクエリを発行し、いくつかの結果をスキップして次のセットを提供しますが、カーソルは次のセットに直接ジャンプするため、非効率になります。

0
dragonx