03 septembre 2014 Technique

Considérations sur la performance pour l'indexation Elasticsearch

Par Michael McCandless

Update November 2, 2015: If you're running Elasticsearch 2.0, check out this updated post about performance considerations for Elasticsearch 2.0 indexing.

Les utilisateurs d'Elasticsearch présentent une parfaite variété de cas d'utilisation. De l'ajout de minuscules documents composés de lignes de logs, à l'indexation de recueils de documents volumineux à l'échelle du Web, en passant par l'optimisation du débit de l'indexation, tous ces sujets forment souvent un important objectif commun. Bien que notre but soit de mettre en place des paramètres par défaut de qualité pour les applications « typiques », vous pouvez rapidement améliorer la performance de votre indexation en suivant quelques exemples simples de bonnes pratiques, comme décrits ici.

Pour commencer, n'utilisez pas de mémoire heap Java trop volumineuse si possible. Taillez-la juste à la bonne dimension (idéalement, pas plus de la moitié de la RAM de la machine) pour contenir le volume opérationnel maximum total défini pour votre utilisation d'Elasticsearch. Ainsi, la RAM restante (normalement suffisante) permettra au système d’exploitation de gérer la mise en cache des fichiers. Assurez-vous que le système d'exploitation ne swappe pas le processus Java.

Mettez à niveau vers la dernière version d'Elasticsearch (actuellement 1.3.2) : un grand nombre de problèmes liés à l'indexation ont été résolus sur les versions récentes.

Avant de se pencher sur les détails, un avertissement : rappelez-vous que toutes les informations disponibles ici sont à jour à l'heure d'écriture de cet article (1.3.2), mais étant donné qu'Elasticsearch évolue en permanence, elles ne seront peut-être plus d'actualité lorsque vous, futur utilisateur de Google, les trouverez. En cas de doute, il vous suffit de venir poser la question sur le forum de discussion.

Marvel est particulièrement utile lorsque vous réglez le débit d'indexation de votre cluster : au moment de l'itération pour chaque paramètre décrit ici, vous pourrez facilement visualiser l'impact de chaque modification sur le comportement de votre cluster.

Côté client

Utilisez toujours l'API bulk, qui permet d'indexer plusieurs documents grâce en un seul appel, et procédez à des tests pour connaître le nombre adéquat de documents à envoyer lors de chaque requête de bulk. La taille optimale dépend de plusieurs facteurs, mais tentez de faire en sorte d'envoyer trop peu plutôt que trop de documents. Utilisez des requêtes groupées concurrentes avec des threads côté client ou bien des requêtes asynchrones séparées.

Avant de conclure que l'indexation est trop lente, assurez-vous d'utiliser pleinement le matériel de votre cluster : utilisez des outils comme iostat, top et ps pour confirmer que vous saturez le processeur ou les entrées/sorties sur tous les noeuds. Si vous ne saturez pas, il vous faudra davantage de requêtes concurrentes, mais si vous obtenez la réponse EsRejectedExecutionException du client Java ou la réponse HTTP TOO_MANY_REQUESTS (429) aux requêtes REST, cela signifiera que vous envoyez trop de requêtes concurrentes. Si vous utilisez Marvel, vous pourrez voir le nombre de rejets dans la section THREAD POOLS - BULK du Node Statistics Dashboard. Il n'est généralement pas conseillé d'augmenter la taille du pool des bulks (se règle par défaut sur le nombre de coeurs) car cela risque de réduire le débit global d'indexation. Il est préférable de diminuer la concurrence côté client ou d'ajouter des noeuds.

Étant donné que les paramètres évoqués ici sont axés sur la maximisation du débit d'indexation pour un seul shard, il est préférable de tester tout d'abord sur un seul noeud, avec un seul shard et pas de réplicat, afin de mesurer l'étendue d'action d'un unique index Lucene sur vos documents, puis de procéder par réglages progressifs, avant d'appliquer la procédure à l'ensemble du cluster. Cela vous fournira également une base vous permettant d'estimer grossièrement le nombre de noeuds qui seront nécessaires pour le cluster entier afin de répondre à vos critères de débit d'indexation.

Une fois qu'un shard fonctionne correctement, vous pouvez bénéficier pleinement de l'évolutivité d'Elasticsearch et des nombreux noeuds présents dans votre cluster, en augmentant le nombre de shards et le nombre de réplicats.

Avant de tirer des conclusions, assurez-vous de mesurer les performances du cluster entier sur une période relativement longue (disons 60 minutes) afin que votre test couvre un cycle complet, y compris divers événements comme les opérations de merge à grande échelle, cycles de GC, déplacements de shards, dépassements du cache entrées/sorties du système d'exploitation, potentiellement du swap imprévu, etc.

Dispositifs de stockage

Naturellement, les dispositifs de stockage contenant l'index jouent un rôle important sur la performance d'indexation :

  • Utilisez des disques SSD : ils sont bien plus rapides que le disque dur classique même le plus rapide. Ils bénéficient non seulement d'une latence plus faible pour l'accès aléatoire et de plus d'entrées/sorties en séquence, mais ils sont également plus adaptés aux entrées/sorties très concurrentes qui sont nécessaires pour l'indexation, les opérations de merge et la recherche, le tout en simultané.
  • Ne placez pas l'index sur un système fichier monté à distance (par ex. NFS ou SMB/CIFS), utilisez plutôt un stockage local sur la machine.
  • Méfiez-vous des stockages virtuels, comme Elastic Block Storage d'Amazon. Les stockages virtuels fonctionnent très bien avec Elasticsearch, et ils peuvent être attractifs en raison de la rapidité et de la facilité d'installation, mais malheureusement ils sont également plus lents en règle générale par rapport à un stockage local dédié. Au cours d'un test que nous avons réalisé récemment, même l'option EBS sauvegardée sur SSD avec les meilleures performances en termes d'IOPS était toujours plus lente que les SSD locaux, et souvenez-vous que celui-ci est toujours partagé entre toutes les machines virtuelles sur cette machine physique. Vous observerez ainsi des ralentissements inexplicables si les autres machines virtuelles sur cette machine physique reçoivent soudainement beaucoup d'entrées/sorties.
  • Répartissez votre index sur plusieurs SSD en paramétrant plusieurs répertoires path.data, ou configurez simplement une matrice RAID 0. Ces deux solutions sont similaires, seulement au lieu de faire la répartition au niveau d'un bloc de fichier, Elasticsearch fait un découpage « en bandes » au niveau des fichiers d'index individuellement. Souvenez-vous simplement que les deux approches augmentent le risque de dysfonctionnement d'un shard seul (prix à payer pour les entrées/sorties plus rapides) étant donné qu'une panne de n'importe lequel des SSD détruirait l'index. C'est exactement le genre de solutions adéquates : optimiser les shards individuels pour une performance maximale, puis ajouter des réplicats sur différents noeuds afin de parvenir à une redondance en cas de dysfonctionnements d'un noeud. Vous pouvez également utiliser le module snapshot et restore pour sauvegarder l'index, par précaution supplémentaire.

Segments et opérations de merge

Sous le capot, les documents récemment indexés sont tout d'abord conservés en mémoire RAM par l'IndexWriter de Lucene. À intervalles réguliers, lorsque le tampon de mémoire RAM est plein ou bien lorsqu'Elasticsearch déclenche une action de flush ou de refresh, ces documents sont écrits dans de nouveaux segments sur disque. Il y a, au bout d'un moment, un trop grand nombre de segments, et ils sont alors fusionnés conformément à la politique et planification des opérations de merge. Ce processus fonctionne en cascade : les segments fusionnés créent un segment plus volumineux, et après un nombre suffisant de petites fusions, ces segments volumineux sont également fusionnés. Voici une bonne visualisation de ce processus.

Les opérations de merge, en particulier à grande échelle, peuvent être très longues à exécuter. Cela ne pose normalement pas de problèmes, car celles-ci sont rares, aussi les coûts amortis demeurent-ils faibles. Mais si le merge n'arrive plus à suivre le rythme d'indexation, Elasticsearch pilotera les requêtes d'indexation entrantes vers un thread unique (comme dans la version 1.2) afin d'éviter de graves problèmes pouvant survenir lorsqu'il y a beaucoup trop de segments dans l'index.

Si vous voyez des messages de log au niveau INFO indiquant "throttling indexing" ou si vous constatez que le nombre de segments ne fait qu'augmenter dans Marvel , alors vous savez que les opérations de merge n'arrivent pas à suivre le rythme. Marvel reporte le nombre de segments dans la section MANAGEMENT EXTENDED du tableau de bord Index Statistics, et, au fur et à mesure que les plus grandes opérations de merge sont réalisées, ce nombre devrait augmenter à un taux logarithmique très lent, parfois en dents de scie :

Pourquoi les opérations de merge ne suivraient-elles pas le rythme ? Par défaut, Elasticsearch limite l'ensemble des opérations de merge à un niveau dérisoire de 20 Mo/sec. Pour les disques rotatifs, cela garantit que la fusion ne saturera pas la capacité habituelle des entrées/sorties du disque, permettant ainsi d'exécuter des recherches concurrentes tout aussi performantes. Mais si vous ne réalisez pas de recherche pendant votre indexation, l'importance de la performance de la recherche ne sera que secondaire au débit d'indexation, ou si votre index est sur des SSD, il serait préférable de désactiver complètement cette limite en configurant index.store.throttle.type sur none ; voir Stockage pour plus d'informations. Sachez que les versions antérieures à 1.2 contenaient un méchant bug qui provoquait une gestion beaucoup plus restrictive des merges que ce que vous demandiez. Alors mettez-vous à niveau !

Si, malheureusement, vous utilisez encore des disques rotatifs, qui ne prennent pas aussi bien en charge des entrées/sorties concurrentes que les SSD, alors vous devrez configurer index.merge.scheduler.max_thread_count sur 1. Le cas échéant, la valeur par défaut (qui favorise les SSD) autorisera beaucoup trop d'opérations de merge simultanées.

Ne faites pas d'appel à l'API optimize sur un index qui est en cours de mise à jour. Cela pourrait vous coûter très cher (tous les segments seraient alors fusionnés). Toutefois, si vous avez terminé d'ajouter des documents à un index donné, il est conseillé de l'optimiser à ce moment précis, afin de réduire les ressources requises pendant la recherche. Par exemple, si vous utilisez des index temporels, où les logs de chaque journée sont ajoutés à un nouvel index, une fois que la journée est terminée, l'optimisation de l'index est alors vivement conseillée, particulièrement si les noeuds doivent contenir les index de plusieurs jours.

Voici d'autres paramètres à configurer :

  • Réglez les mappings afin de désactiver les champs dont vous n'avez pas besoin, par exemple le champ _all. Pour les champs que vous souhaitez garder, vous pouvez aussi choisir de les indexer ou de les stocker, ainsi que la manière dont ils sont indexés ou stockés.
  • Vous pourriez être tenté de désactiver le champ _source, mais son coût en matière d'indexation est assez faible (en stockage seul, pas indexé), et il s'avère très utile pour les futures mises à jour ou la réindexation complète d'un index précédent. En règle générale, il ne vaut donc pas la peine de le désactiver, à moins que vous ayez des doutes au niveau de l'utilisation du disque, mais là encore, cela ne devrait pas être le cas en raison du coût peu élevé en matière de disque.
  • Si un petit délai dans les recherches de documents récemment indexés ne vous gêne pas, augmentez l'intervalle index.refresh_interval à 30 sec, ou désactivez-le complètement, en le configurant à -1. Cela permettra aux segments plus volumineux d'être flushés, et diminuera la pression sur les opérations de merge à venir.
  • À partir du moment où vous avez mis à niveau la version Elasticsearch 1.3.2, qui résoud les problèmes pouvant provoquer une utilisation excessive de la RAM lorsque les flush ne sont qu'occasionnels, augmentez le paramètre index.translog.flush_threshold_size pour passer du paramètre par défaut (actuellement 200 Mo) à 1 Go, et ainsi diminuer la fréquence de l'appel fsync aux fichiers d'index.
    Marvel retrace le rythme des flushs dans la section MANAGEMENT du tableau de bord Index Statistics.
  • Utilisez 0 réplicat lors de la première initialisation d'un index volumineux, puis activez les réplicats ultérieurement et laissez-les se mettre à jour. Méfiez-vous, simplement, que si un noeud est en défaut lorsque vous avez 0 réplicat, cela signifie que vous avez perdu des données (votre cluster est rouge), puisqu'il n'y a pas de redondance. Si vous prévoyez de faire un appel à l'API optimize (lorsque aucun autre document ne sera ajouté), il est vivement conseillé de le faire après avoir terminé l'indexation et avant d'augmenter le nombre de réplicats. La réplication pourra ainsi copier le(s) segment(s) optimisé(s). Voir la mise à jour des paramètres d'indexation pour plus de détails.

Taille de mémoire tampon de l'index

Si votre noeud effectue uniquement de l'indexation lourde, assurez-vous que la taille de la mémoire tampon indices.memory.index_buffer_size est suffisamment grande pour donner au maximum ~512Mo de mémoire tampon d'indexation par shard actif (au-delà, la performance d'indexation ne s'améliore généralement pas). Ce paramètre (un pourcentage de la mémoire heap Java ou une taille absolue en octets), Elasticsearch le divise équitablement entre tous les shards actifs du node, sous condition des valeurs min_index_buffer_size et max_index_buffer_size ; les valeurs plus grandes signifient que Lucene écrit des segments initiaux plus grands ce qui diminue la pression sur les opérations de merge à venir.

Le paramètre par défaut est de 10 %, ce qui suffit habituellement : par exemple, si vous avez 5 shards actifs sur un node, et que votre heap est de 25 Go, alors chaque shard reçoit 1/5ème de 10 % de 25 Go, soit 512 Mo (le maximum est atteint). Après avoir effectué une indexation importante spécifique, diminuez ce paramètre à son niveau par défaut (actuellement 10 %) afin que les structures de données du temps de recherche disposent de toute la RAM nécessaire. Sachez que ce paramètre n'est pas encore dynamique ; un ticket a été ouvert afin de trouver une solution.

Le nombre d'octets actuellement utilisés par la mémoire tampon de l'index a été ajouté à l'API indices stats dans la version 1.3.0. Vous pouvez le voir dans la valeur indices.segments.index_writer_memory. Cela n'est pas encore répertorié dans Marvel, et sera ajouté dans la prochaine version, mais vous pouvez ajouter votre propre graphique (Marvel recueille déjà ces données).

Dans la version 1.4.0, l'API indices stats montrera également la quantité exacte de mémoire tampon RAM attribuée à chaque shard actif, sous forme de valeur indices.segments.index_writer_max_memory. Pour voir ces valeurs pour chacun des shards dans un index donné, utilisez http://host:9200/<indexName>/_stats?level=shards ; cela affichera les statistiques par shard ainsi que les totaux pour tous les shards.

Utilisation d'un identifiant automatique ou choix de l'identifiant

Si l'identifiant de vos documents vous est indifférent, laissez Elasticsearch l'assigner automatiquement : ce cas est optimisé (à compter de la version 1.2) pour enregistrer un ID et consulter sa version par document. Vous verrez la différence de performance dans les benchmarks d'indexation nocturnes d'Elasticsearch (comparez les lignes Fast et FastUpdate).

Si vous avez vos propres identifiants, essayez d'en choisir un qui soit rapide pour Lucene si cela vous est permis, et mettez à niveau la version 1.3.2, au minimum, puisque d'autres optimisations ont eu lieu pour la fonction de recherche par identifiant. N'oubliez pas que l'ID de Java UUID.randomUUID() est le plus mauvais choix d'identifiant car il ne suit aucune logique et n'est pas prévisible en terme d'attribution d'identifiants aux segments, provoquant une recherche par segment dans le pire des cas.

Vous constaterez la différence du taux d'indexation, tel que signalé par Marvel , avec l'utilisation d'identifiants de type Flake :

par rapport à l'utilisation d'identifiants UUID aléatoires :

Dans la prochaine version 1.4.0, nous avons modifié les identifiants Elasticsearch générés automatiquement, pour passer d'identifiants aléatoires UUID à des identifiants Flake.

Si vous souhaitez connaître le nombre d'opérations de bas niveau que Lucene effectue sur votre index, essayez d'activier le niveau TRACE pour les logs enablinglucene.iw (disponible dans la version 1.2). Cela produit un résultat très étoffé, mais il peut être utile pour comprendre ce qui se passe au niveau de l'IndexWriter de Lucene. Ce résultat est de bas niveau ; Marvel fournit un graphique en temps réel beaucoup plus parlant de ce qui se passe dans l'index.

Scalabilité horizontale

Nous avons, ici, choisi de nous concentrer sur le réglage de la performance d'un shard unique (index Lucene), mais une fois que vous êtes satisfait de cette performance, et là où Elasticsearch excelle, vous pouvez développer votre indexation et vos recherches pour inclure un cluster entier de machines. Par conséquent, veillez à augmenter une nouvelle fois le nombre de shards (le nombre par défaut étant de 5), ce qui vous fournira la simultanéité d'exécution pour toutes les machines, une taille maximum d'index plus importante et une plus faible latence dans les recherches. N'oubliez pas également d'augmenter le nombre de réplicats à au moins 1 pour avoir de la redondance en cas de défaillance des équipements.

Enfin, si vous avez d'autres difficultés, contactez-nous, par ex. via le forum Elasticsearch. Qui sait, il y a peut-être un super bug à résoudre (les patchs sont toujours les bienvenus !).