web-dev-qa-db-fra.com

Meilleure pratique pour interroger un grand nombre d'entités ndb à partir du magasin de données

J'ai rencontré une limite intéressante avec la banque de données App Engine. Je crée un gestionnaire pour nous aider à analyser certaines données d'utilisation sur l'un de nos serveurs de production. Pour effectuer l'analyse, je dois interroger et résumer plus de 10 000 entités extraites de la banque de données. Le calcul n'est pas difficile, c'est juste un histogramme des éléments qui passent un filtre spécifique des échantillons d'utilisation. Le problème que j'ai rencontré est que je ne peux pas récupérer les données du magasin de données assez rapidement pour effectuer un traitement avant d'atteindre la date limite de requête.

J'ai tout essayé pour découper la requête en appels RPC parallèles pour améliorer les performances, mais selon appstats, je n'arrive pas à obtenir que les requêtes s'exécutent réellement en parallèle. Peu importe la méthode que j'essaie (voir ci-dessous), il semble toujours que le RPC retombe dans une cascade de requêtes séquentielles suivantes.

Remarque: le code de requête et d'analyse fonctionne, il s'exécute lentement car je ne peux pas obtenir les données assez rapidement de la banque de données.

Contexte

Je n'ai pas de version live que je puisse partager, mais voici le modèle de base pour la partie du système dont je parle:

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)

Vous pouvez considérer les exemples comme des moments où un utilisateur utilise une capacité d'un nom donné. (ex: 'systemA.feature_x'). Les balises sont basées sur les détails du client, les informations système et la fonctionnalité. ex: ['winxp', '2.5.1', ​​'systemA', 'feature_x', 'premium_account']). Les balises forment donc un ensemble de jetons dénormalisés qui pourraient être utilisés pour trouver des échantillons intéressants.

L'analyse que j'essaie de faire consiste à prendre une plage de dates et à demander combien de fois une fonctionnalité d'un ensemble de fonctionnalités (peut-être toutes les fonctionnalités) utilisée par jour (ou par heure) par compte client (entreprise, pas par utilisateur).

Ainsi, l'entrée du gestionnaire sera quelque chose comme:

  • Date de début
  • Date de fin
  • Mots clés)

La sortie serait:

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

Code commun pour les requêtes

Voici un code commun à toutes les requêtes. La structure générale du gestionnaire est un simple gestionnaire get utilisant webapp2 qui définit les paramètres de la requête, exécute la requête, traite les résultats, crée les données à renvoyer.

# -- 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)

Méthodes essayées

J'ai essayé diverses méthodes pour extraire les données du magasin de données le plus rapidement possible et en parallèle. Les méthodes que j'ai essayées jusqu'à présent comprennent:

A. Itération unique

Il s'agit plus d'un cas de base simple à comparer aux autres méthodes. Je construis simplement la requête et j'itère tous les éléments en laissant ndb faire ce qu'il fait pour les tirer l'un après l'autre.

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)

Grande taille

L'idée ici était de voir si je pouvais faire un seul très gros fetch.

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. Async récupère sur toute la plage de temps

L'idée ici est de reconnaître que les échantillons sont assez bien espacés dans le temps afin que je puisse créer un ensemble de requêtes indépendantes qui divisent la région temporelle globale en morceaux et essayez d'exécuter chacune d'elles en parallèle en utilisant async:

# 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. Mappage asynchrone

J'ai essayé cette méthode car la documentation donnait l'impression que ndb pouvait exploiter automatiquement un certain parallélisme lors de l'utilisation de la méthode Query.map_async.

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()

Résultat

J'ai testé un exemple de requête pour collecter le temps de réponse global et les traces d'appstats. Les résultats sont:

A. Itération unique

réel: 15,645 s

Celui-ci passe séquentiellement par la récupération des lots les uns après les autres, puis récupère chaque session de memcache.

Method A appstats

Grande taille

réel: 12.12s

Effectivement identique à l'option A mais un peu plus rapide pour une raison quelconque.

Method B appstats

C. Async récupère sur toute la plage de temps

réel: 15.251s

Semble fournir plus de parallélisme au début mais semble être ralenti par une séquence d'appels au suivant lors de l'itération des résultats. Il ne semble pas non plus pouvoir chevaucher les recherches de memcache de session avec les requêtes en attente.

Method C appstats

D. Mappage asynchrone

réel: 13.752s

Celui-ci est le plus difficile à comprendre pour moi. Il semble qu'il y ait beaucoup de chevauchements, mais tout semble s'étendre dans une cascade plutôt qu'en parallèle.

Method D appstats

Recommandations

Sur la base de tout cela, que me manque-t-il? Suis-je en train de frapper une limite sur App Engine ou existe-t-il un meilleur moyen de supprimer un grand nombre d'entités en parallèle?

Je ne sais pas quoi essayer ensuite. J'ai pensé à réécrire le client pour faire plusieurs demandes au moteur d'application en parallèle, mais cela semble assez brutal. Je m'attendrais vraiment à ce que le moteur d'application puisse gérer ce cas d'utilisation, donc je suppose qu'il manque quelque chose.

Mise à jour

En fin de compte, j'ai trouvé que l'option C était la meilleure pour mon cas. J'ai pu l'optimiser pour terminer en 6,1 secondes. Pas encore parfait, mais bien mieux.

Après avoir reçu des conseils de plusieurs personnes, j'ai trouvé que les éléments suivants étaient essentiels pour comprendre et garder à l'esprit:

  • Plusieurs requêtes peuvent s'exécuter en parallèle
  • Seuls 10 RPC peuvent être en vol à la fois
  • Essayez de dénormaliser au point qu'il n'y a pas de requêtes secondaires
  • Ce type de tâche est préférable de mapper les files d'attente de réduction et de tâche, et non les requêtes en temps réel

Donc, ce que j'ai fait pour le rendre plus rapide:

  • J'ai partitionné l'espace de requête depuis le début en fonction du temps. (note: plus les partitions sont égales en termes d'entités retournées, mieux c'est)
  • J'ai dénormalisé davantage les données pour supprimer le besoin de requête de session secondaire
  • J'ai utilisé les opérations asynchrones ndb et wait_any () pour chevaucher les requêtes avec le traitement

Je n'obtiens toujours pas la performance que j'attendais ou aimerais, mais elle est réalisable pour l'instant. Je souhaite juste que leur soit une meilleure façon d'extraire rapidement un grand nombre d'entités séquentielles en mémoire dans les gestionnaires.

62
Allen

Un traitement volumineux comme celui-ci ne doit pas être effectué dans une demande utilisateur, qui a un délai de 60 secondes. Au lieu de cela, cela doit être fait dans un contexte qui prend en charge les demandes de longue durée. La file d'attente des tâches prend en charge les requêtes jusqu'à 10 minutes et (je crois) les contraintes de mémoire normales (les instances F1, par défaut, ont 128 Mo de mémoire ). Pour des limites encore plus élevées (pas de délai d'attente de demande, 1 Go + de mémoire), utilisez backends .

Voici quelque chose à essayer: configurer une URL qui, lorsqu'elle est consultée, déclenche une tâche de file d'attente de tâches. Il renvoie une page Web qui interroge toutes les ~ 5 secondes vers une autre URL qui répond par vrai/faux si la tâche de file d'attente des tâches est déjà terminée. La file d'attente des tâches traite les données, ce qui peut prendre environ 10 secondes, et enregistre le résultat dans la banque de données sous forme de données calculées ou de page Web rendue. Une fois que la page initiale détecte qu'elle est terminée, l'utilisateur est redirigé vers la page, qui récupère les résultats désormais calculés à partir du magasin de données.

8
mjibson

J'ai un problème similaire et après avoir travaillé avec l'assistance Google pendant quelques semaines, je peux confirmer qu'il n'y a pas de solution magique au moins à partir de décembre 2017.

tl; dr: On peut s'attendre à un débit de 220 entités/seconde pour SDK standard fonctionnant sur une instance B1 jusqu'à 900 entités/seconde pour un SDK patché exécuté sur une instance B8.

La limitation est liée au processeur et la modification du type instancié a un impact direct sur les performances. Ceci est confirmé par des résultats similaires obtenus sur les instances B4 et B4_1G

Le meilleur débit que j'ai obtenu pour une entité Expando avec environ 30 champs est:

SDK GAE standard

  • Instance B1: ~ 220 entités/seconde
  • Instance B2: ~ 250 entités/seconde
  • Instance B4: ~ 560 entités/seconde
  • Instance B4_1G: ~ 560 entités/seconde
  • Instance B8: ~ 650 entités/seconde

SDK GAE patché

  • Instance B1: ~ 420 entités/seconde
  • Instance B8: ~ 900 entités/seconde

Pour le SDK GAE standard, j'ai essayé diverses approches, notamment le multi-threading, mais la meilleure s'est avérée être fetch_async avec wait_any . La bibliothèque NDB actuelle fait déjà un excellent travail en utilisant async et futures sous le capot, donc toute tentative de pousser cela en utilisant des threads ne fait qu'empirer les choses.

J'ai trouvé deux approches intéressantes pour optimiser cela:

Matt Faus explique très bien le problème:

GAE SDK fournit une API pour lire et écrire des objets dérivés de vos classes dans la banque de données. Cela vous évite le travail ennuyeux de validation des données brutes renvoyées par la banque de données et de reconditionnement dans un objet facile à utiliser. En particulier, GAE utilise des tampons de protocole pour transmettre les données brutes du magasin à la machine frontale qui en a besoin. Le SDK est alors responsable du décodage de ce format et du retour d'un objet propre à votre code. Cet utilitaire est génial, mais parfois il fait un peu plus de travail que vous ne le souhaiteriez. [...] A l'aide de notre outil de profilage, j'ai découvert que 50% du temps passé à récupérer ces entités était pendant la phase de décodage d'un objet protobuf vers python. Cela signifie que le CPU sur le serveur frontal était un goulot d'étranglement dans ces lectures de magasin de données!

GAE-data-access-web-request

Les deux approches tentent de réduire le temps passé à faire du protobuf à Python décodage en réduisant le nombre de champs décodés.

J'ai essayé les deux approches mais je ne réussis qu'avec Matt. Les internes du SDK ont changé depuis qu'Evan a publié sa solution. J'ai dû changer un peu le code publié par Matt ici , mais c'était assez facile - s'il y a un intérêt, je peux publier le code final.

Pour une entité Expando régulière avec environ 30 champs, j'ai utilisé la solution de Matt pour décoder seulement quelques champs et obtenu une amélioration significative.

En conclusion, il faut planifier en conséquence et ne pas s'attendre à pouvoir traiter bien plus que quelques centaines d'entités dans une demande GAE "en temps réel".

1
blueCat

Les opérations de données volumineuses sur App Engine sont mieux implémentées à l'aide d'une sorte d'opération mapreduce.

Voici une vidéo décrivant le processus, mais incluant BigQuery https://developers.google.com/events/io/sessions/gooio2012/307/

Il ne semble pas que vous ayez besoin de BigQuery, mais vous souhaiterez probablement utiliser à la fois les parties Map et Reduce du pipeline.

La principale différence entre ce que vous faites et la situation mapreduce est que vous lancez une instance et parcourez les requêtes, où sur mapreduce, vous auriez une instance distincte exécutée en parallèle pour chaque requête. Vous aurez besoin d'une opération de réduction pour "résumer" toutes les données et écrire le résultat quelque part.

L'autre problème que vous rencontrez est que vous devez utiliser des curseurs pour itérer. https://developers.google.com/appengine/docs/Java/datastore/queries#Query_Cursors

Si l'itérateur utilise un décalage de requête, il sera inefficace, car un décalage émet la même requête, ignore un certain nombre de résultats et vous donne le jeu suivant, tandis que le curseur passe directement au jeu suivant.

0
dragonx