Utilisateur d’Elasticsearch depuis la version 0.9.8, Geneanet a débuté un chantier en fin d’année 2016 pour passer Elasticsearch de la version 1.7 à la version 2 puis 5 lors de sa sortie. Tout ne s’est pas passé comme prévu et (attention, spoiler !) nous avons finalement dû prendre la décision de revenir en arrière pour rester en version 1.7 jusqu’à une date ultérieure. Durant ce projet, nous avons effectué plusieurs benchmarks et nous en avons acquis une expérience que nous souhaitons partager avec la communauté.

Préambule

Plusieurs points d’attention pour la lecture de cet article :

  • Les benchmarks étudiés ici n’utilisent que la charge CPU sur nos serveurs Elasticsearch comme indicateur. C’est à ce niveau que se situe le blocage auquel nous avons été confrontés. Les autres paramètres comme le temps d’exécution des requêtes restaient dans nos critères d’acceptabilité quelle que soit la version d’Elasticsearch.
  • Lors de nos benchmarks, nous n’avons jamais utilisés d’aggrégations. Le gros de nos requêtes ne les utilisent pas.
  • A la base, ces requêtes ont été générées avec la librairie open source Elastica. Nous avons récupéré la version JSON d’origine et nous avons par la suite travaillé directement le JSON pour tester nos différentes hypothèses.
  • Les benchmarks ont principalement eu lieu sur les versions d’Elasticsearch 5.2 et 5.3. Les performances étant sensiblement les mêmes entre les deux versions, nous ne ferons pas de distinction dans cet article.
  • La 5.4 étant sortie durant l’écriture de cet article, nous terminerons ce dernier avec une partie qui lui est dédiée.

Déroulement des événements

Pour mieux comprendre le contexte de ce chantier, voici un résumé de la chronologie des événements :

  • Septembre 2016 :
    • Veille technologique pour migrer vers Elasticsearch 2.x (Changelog Elasticsearch et Elastica).
    • Benchmarks sur extrait de notre jeu de données à l’aide d’un générateur de requêtes types correspondant à nos cas d’usage. Les performances ne semblent pas souffrir de la mise à jour.
  • Octobre 2016 :
    • Code mis à jour et tests effectués. Nous effectuons la migration l’esprit tranquille…
    • Nous constatons alors une forte hausse de la charge CPU sur nos serveurs (environ 4 fois supérieure par rapport à la version 1.7).
    • Dans la foulée, nous réindexons nos données pour qu’elles bénéficient des améliorations du nouveau format de la version 2 en espérant constater de meilleurs performances, sans résultat bénéfique.
    • C’est à ce moment qu’Elasticsearch 5 sort en version officielle.
  • Novembre 2016 :
    • Code mis à jour et tests effectués pour la version 5 d’Elasticsearch. Nous effectuons une première série de benchmarks avec les indexes préalablement reconstruits avec cette version. Les benchmarks semblent meilleurs au niveau performance. Nous effectuons la migration.
    • Suite à la mise en production de la version 5, nous continuons à constater les mêmes problèmes sur la charge CPU.
  • Décembre 2016 :
    • Nous soupçonnons une régression dans la gestion du cache d’Elasticsearch. Plusieurs tentatives de patchs sont effectuées dans notre code et dans Elasticsearch afin de tenter de rétablir le comportement de la version 1.7, sans résultat probant.
    • Force est de constater que nous n’arriverons pas à une solution acceptable rapidement. Nous reconstruisons donc nos indexes en version 1.7 et rebasculons notre code dans cette version. La charge CPU revient à la normale, nous allons pouvoir étudier le problème plus tard à tête reposée.
  • Mars 2017 :
    • Nous sommes en mesure d’effectuer des tests de performances plus poussés. Nous avons 2 serveurs identiques et très performants dédiés aux benchmarks avec l’un sous la version 1.7 et l’autre sous la version 5.2. Nous allons pouvoir tester avec une charge (volume de données et de requêtes) plus représentative de la production.
    • Nous récupérons deux jeux de requêtes (pour la 1.7 et pour la 5) conséquents issus de vraies requêtes de production et non plus générés aléatoirement.
  • Avril 2017 :
    • Nous réalisons plusieurs benchmarks en testant les nouveautés de la version 5 et des solutions alternatives.
    • Le problème est alors clairement identifié.
    • Nous obtenons de meilleures performances, mais toujours insuffisantes par rapport à la version 1.7 pour imaginer une nouvelle migration.

Testons Elasticsearch

Comparons Elasticsearch 1.7 et 5

Dans un premier temps, nous avons souhaité nous rassurer en reproduisant le problème de performances. En effet, lors de notre première série de benchmarks entre la version 2 et 5, nous avions constaté des améliorations non retrouvées en production dû à des jeux de requêtes et de données qui n’étaient pas assez représentatifs de la réalité de la production.

Nous avons débuté nos tests avec une vélocité de 4 requêtes par secondes sur la version 1.7. Le serveur a encaissé sans broncher. Nous avons réalisé la même sur la version 5 et nous avons été “rassuré” par le fait que le serveur ne tenait pas la charge. Au final, pour réussir à faire s’exécuter les requêtes sans écrouler le serveur, nous avons dû passer à 1 requête par seconde sur la version 5.

Le premier objectif est donc atteint : Nous avons un problème de performances avec Elasticsearch 5 résultant d’une consommation 4 fois supérieure en charge CPU que la version 1.7. Nous avons donc pu en chercher la cause.

Pour pouvoir comparer dans la suite de l’article, voici les graphiques de la charge CPU en version 1.7 qui nous ont servi d’indicateurs pour comparer avec la version 5.

1 requêtes par secondes :
1 requêtes par secondes

6 requêtes par secondes :
6 requêtes par secondes

En résumé, pour une requête par seconde, nous avions au niveau charge CPU :

  • En version 1.7 : 6% de charge.
  • En version 5 : 25% de charge.

Match, Terms, Range, la réponde D - qui est le fautif ?

A partir de ce moment, nous avons pu nous passer du serveur en version 1.7 et nous concentrer sur le découpage de nos requêtes pour identifier le problème. Nous sommes parti de requêtes simples que nous avons enrichies à chaque nouvelles étapes par des critères supplémentaires :

  • Les premières requêtes testées ne comportaient que les éléments en match de la query : Nous avons constaté de bonnes performances (nous avons par ailleurs effectués des benchmarks de ce côté là et avons vu une nette amélioration par rapport à la version 1.7).
  • Par la suite, nous avons ajouté les éléments term/terms (boolean, tags, etc.) : Les performances sont restées correctes.
  • Pour terminer, nous avons ajouté nos deux critères comprenant des ranges : Serveur en feu !

Nous avions donc trouvé le fautif : Nos recherches avec des ranges. Pour remettre dans le contexte fonctionnel :

Sur Geneanet, les utilisateurs peuvent chercher des individus dans une période cloisonnée par deux années. Cependant, un individu ne vit pas qu’une seule année mais également sur une période. Notre recherche porte donc sur deux champs représentant une date de début et une date de fin pour l’individu. La requête se traduit donc par deux ranges mis en commun par une query bool.

Nous avons ainsi :

  • Des individus possédants deux champs représentant leur période de vie :
    • annee_debut : Représente la première année où l’on sait que l’individu a vécu (la naissance si on connaît sa date de naissance, mais éventuellement un autre événement comme le mariage si l’on n’a pas mieux).
    • annee_fin : Représente la dernière année où l’on sait que l’individu a vécu (le décès si on connaît sa date de décès, mais éventuellement un autre événement comme le mariage si l’on n’a pas mieux).
    • Note : Les champs sont toujours renseignés. Si un individu n’a qu’une seule date (naissance par exemple) c’est l’année associée à cette date qui sera placée dans les deux champs.
  • Si je cherche les individus ayant vécus entre 1900 et 1950. On va donc rechercher :
    • Les individus ayant commencés leur vie avant 1950 ET
    • Les individus ayant terminés leur vie après 1900
    • Note : On trouve ainsi tous les individus ayant vécu dans cette période, aussi bien ceux étant nés et décés dans cette période que ceux qui seraient nés en 1890 et décédés en 1960.

Trahi par son propre code ?

Notre première réaction a été de partir du principe que le problème venait de nous et de notre code. Un code pourtant rudement mis à l’épreuve et dans lequel nous avions confiance.

Trust me !

Nous avons donc tenté d’améliorer notre requête au niveau du point de blocage. Nous avons également testé les nouveaux types de requêtes offerts par Elasticsearch 5. Dans le doute, nous avons testé le plus possible de cas.

ltg et gte

Une amélioration que nous avons apporté dans un premier temps, c’est une meilleure configuration des ranges. Nous étions partis à l’origine sur l’utilisation de from/to au lieu de lte/gte.

Si l’on reprend l’exemple, nous avions nos ranges configurés ainsi :

must
    range
        annee_debut
            from: 0
            to: 1950
    range
        annee_fin
            from: 1900
            to: 33000 // arbitraire tant que c'est loin de l'année courante.

Nous l’avons ainsi transformé en :

must
    range
        annee_naissance
            lte: 1950
    range
        annee_deces
            gte: 1900

Nous avons par la suite relancé notre benchmark. Nous n’avons malheureusement constaté qu’une amélioration de ~1% sur l’utilisation du CPU pour une requête par seconde. Nous étions donc passé de 25% à 24% en moyenne ce qui était encore loin des 6% d’origine.

short ou date ?

Notre seconde option a été de regarder du côté du mapping. Jusqu’à présent, nous avions indexé nos champs annee_debut et annee_fin sous forme de short alors qu’il s’agit d’année. Nous avons donc tenté de passer le tout sous forme de date avec pour format YYYY. Sans grande surprise, cela n’a eu aucun impact sur les performances.

date_range ? integer_range ?

Après avoir essayés les solutions déjà présentes en version 1.7 d’Elasticsearch, nous nous sommes attardés sur les nouveaux mapping introduits avec Elasticsearch 5 : date_range et integer_range.

De prime abord, cela semble pertinent dans notre cas. En effet, avec ces nouveaux types, il nous est possible en un seul champ de stocker les deux années représentant la période de vie d’un individu. Ensuite, avec une seule requête range, on peut rechercher nos individus.

Cependant, une fois encore, cela n’a pas permis de constater de gain significatif. Nous avons pu descendre de 2% en moyenne la charge CPU (22% donc) mais nous étions encore loin de nos précieux 6%.

Et si ce n’était pas nous ?

Après ces essais, nous avons effectué d’autres tests par dépit en modifiant quelques valeurs du mapping (doc_values à false, etc.) sans aucun résultat encourageant. Et là, nous nous sommes demandés : Et si ce n’était pas nous ?

Whaaat ?

Nous avons donc commencé à chercher d’autres solutions un peu moins orthodoxes.

Entropie ?

Munis de ce nouvel axe de recherche, nous avons essayé d’ajouter de l’entropie sur nos dates pour disperser un peu plus nos valeurs et ainsi avoir une cardinalité plus grande sur les champs concernés. Nous passions donc d’une période entre 1900 et 1950 à une période entre 1900XXXX et 1950XXXX dans le but d’étaler nos individus sur des valeurs plus variées. Une nouvelle fois sans surprise, le gain fut trop minime pour être certain et nous suffire.

La communauté à la rescousse ! ou presque…

Suite à une recherche dans les différentes remontées de bugs sur le GitHub d’Elasticsearch, nous avons trouvés un problème de performance remonté pour la version 2 qui était similaire à notre cas. Associé à ce problème, l’utilisateur proposait une solution alternative permettant d’obtenir des performances respectables.

La solution consiste, dans notre cas, à indexer un individu avec deux champs supplémentaires où nous aurons arrondi l’année sur la dizaine. Ainsi, un individu sera indexé ainsi :

  • annee_debut: 1905
  • annee_fin: 1954
  • annee_debut_arrondie: 1900
  • annee_fin_arrondie: 1950

Ensuite pour une recherche entre 1908 et 1952, nous allons utiliser les terms pour les périodes de 10 ans et compléter avec des ranges pour obtenir les extrémités :

must
    should
        terms
            annee_debut_arrondie: [0, 10, 20, 30, ..., 1910, 1920, 1930, 1940]
        range
            annee_debut:
                from: 1950
                to: 1952
    should
        terms
            annee_fin_arrondie: [1910, 1920, 1930, 1940, ..., 2000, 2010]
        range
            annee_fin:
                from: 1908
                to: 1910

Malgré le fait que la requête est plus compliquée visuellement, nous avons pu constater une nette amélioration des performances. Nous sommes passé en moyenne à 10-15% de charge CPU pour une requête par seconde. A titre de comparaison, voici quelques graphes avec 6 requêtes par secondes.

Pour rappel, en version 1.7 :
6 requêtes par secondes

En version 5 associée à la solution alternative :
6 requêtes par secondes

Il n’y a bien sûr pas de graphe pour la version sans la solution alternative. Comme mentionné précédemment, avec des solutions “officielles”, nous arrivions à peine à faire tourner 2 requêtes par seconde.

On constate que la solution n’est pas encore au même niveau que la version 1.7 mais au moins on s’en rapproche.

Plus c’est bizarre, mieux ça fonctionne !

Face à cette idée saugrenue qui pourtant a fonctionné, nous nous sommes dit : pourquoi ne pas aller plus loin dans le bizarre ?

More and more

Nous avons donc modifié nos requêtes pour remplacer la partie range permettant de récupérer les extrémités par des terms. Nous avons également découpé nos dates par siècle en plus de la dizaine. Nous avons au final 6 champs pour notre individu mais il est possible de tout placer dans un seul champs sous forme d’array et de prefixer pour déterminer s’il s’agit d’un siècle, d’une dizaine ou une date exacte comme présenté dans la solution alternative sur GitHub. Nous avions donc ceci (pour rappel, nous cherchons entre 1908 et 1952) :

must
    should
        terms
            annee_debut_siecle: [0, 100, 200, 300, 400, 500, 600, 700, 800, 900, 1000, 1100, 1200, 1300, 1400, 1500, 1600, 1700, 1800]
        terms
            annee_debut_dizaine: [1900, 1920, 1930, 1940]
        terms
            annee_debut: [1950, 1951, 1952]
    should
        terms
            annee_fin_siecle: [2000]
        terms
            annee_fin_dizaine: [1910, 1920, 1930, 1940, 1950, 1960, 1970, 1980, 1990]
        terms
            annee_fin: [1908, 1909]

Dans un premier temps, nous avions laissé les champs sous forme de short ce qui ne s’est pas révélé concluant. Nous avons réindexé nos données en passant les champs au format keywords. Une fois les benchmarks relancés, nous avons constaté une amélioration avec le format keywords.

En version 5.3, la solution sans range :
6 requêtes par secondes

On se rapproche fortement de la version 1.7. Malheureusement, ce n’est toujours pas aussi bien que la version 1.7 et sans autres idées pour améliorer le tout, nous avons stopé pour le moment nos recherches.

Épisode 5.4 : Un nouvel espoir ?

Malgré nos recherches qui n’ont pas abouti à un résultat acceptable, nous avons continué à surveiller les mises à jour d’Elasticsearch. Lorsque nous avons vu la version 5.4 sortir, un élément du changelog a attiré notre attention sans vraiment vouloir y croire : range queries, nested queries, and large term queries have all shipped with optimizations in this release.

Nous avons donc relancé nos benchmarks après avoir mis nos serveurs de test à jour. Dans un premier temps, nous avons testé notre solution alternative (à base de terms donc) sans constater aucune amélioration. Pour finir, nous avons lancé le benchmark avec les requêtes de bases (comportant donc les ranges en lte/gte). Nous avons été fortement surpris de constater que les performances étaient bonnes.

6 requêtes par secondes

Avec la version 5.4 d’Elasticsearch et les critères par range, nous avons réussi à obtenir une charge CPU identique voire légèrement inférieure à la solution alternative. C’est encourageant ! Malheureusement pour nous, ce n’est toujours pas suffisant par rapport à la version 1.7.

Le mot de la fin

Comme mentionné au début, suite à ce chantier technique, nous avons dû revenir en version 1.7 d’Elasticsearch.

Après ces différents benchmarks, nous sommes un peu rassurés des dernières évolutions d’Elasticsearch au niveau des critères par range. Nous allons continuer à surveiller les évolutions pour vérifier que les futures versions stabiliseront ces performances et nous relancerons ce chantier une fois totalement rassurés.

Commentaires