Development

Protocoles RPC de référence

Nous avons fait deux séries distinctes de protocoles RPC de référence et nous avons trouvé une solution idéale pour notre architecture de microservices.

January 26 2016

La plate-forme Infobip, n’est pas un grand monolithe. Dans un exemple typique d'architecture de microservices, cela se compose d'une multitude de petits services qui représentent différentes parties de la plate-forme. Chaque service a un seul but, bien défini et n'est concerné que par la façon de faire son travail, de la meilleure manière possible.

Avoir un ensemble de petits services rend plus facile la gestion de la plate-forme complète. Chaque service peut être mis à jour à tout moment sans perturber les autres. Ils peuvent être écrits dans différents langages de programmation (et ils le sont) correspondant le mieux au problème qu'ils essaient de résoudre. Les parties de la plate-forme qui traitent un important trafic peuvent être vérifiées indépendamment de celles qui ont une charge légère. Ce sont les avantages d'une architecture de microservices qui font du développement de la plate-forme Infobip, une grande expérience.

Mais aucun service n'est isolé des autres. Être responsable seulement d'une petite partie signifie que les services qui fonctionnent ensemble ont besoin de communiquer. Par exemple, lancer une campagne SMS à partir de notre Infobip Portal sera pris en charge par le service responsable des campagnes. Si une campagne comprend des contacts du client ou des groupes, ils seront extraits d'un service différent qui gère les contacts. Et il y en a aussi un autre pour programmer les messages, d'autres pour les acheminer, pour la facturation et pour les livrer aux abonnés des services mobiles.

Choix et décisions

Nos services communiquent entre eux par différents protocoles RPC (Remote Procedure Call). Beaucoup ont été essayés pendant un certain nombre d'années, mais seulement trois sont encore utilisés actuellement.

  • Le bon vieux HTTP/1.1. Il y a de nombreuses implémentations clients et serveurs dans n'importe quel langage de programmation, ce qui convient bien à notre plate-forme polyglotte. Nous envoyons généralement le format JSON plein texte par ce protocole, ce qui rend plus facile de déboguer les services puisque nous avons besoin seulement d'un navigateur Internet ou d'un client REST.
  • Notre second protocole est encore un HTTP, mais cette fois, il envoie et reçoit des objets Java sérialisés. C'était la valeur par défaut lorsque deux services écrits en Java communiquaient, puisqu'ils sont capables de lire et d'écrire ce type de données.
  • MML, l'abréviation de langage machine à machine, est notre protocole RPC interne conçu pour un haut débit. Il présente des similitudes avec HTTP/2 qui n'existait pas encore lorsque nous avons commencé à travailler sur MML. Il utilise une connexion TCP persistante entre le client et le serveur et permet aux clients de partager la charge entre les serveurs et de faire basculer lorsque c'est nécessaire. MML utilise Typed JSON comme charge utile - il ressemble au format normal JSON mais chaque objet est étiqueté avec le type Java. pour cette raison, MML est utilisé seulement entre les services Java.

Avec trois façons différentes de communiquer il est difficile de faire le bon choix. Chaque protocole a ses propres avantages et inconvénients lorsqu'il s'agit de performance, de débogabilité et d'interopérabilité avec les services non-Java. MML est supposé être le plus performant mais il est limité à la communication entre deux services Java. Lorsque d'autres langages de programmation entrent dans le processus, ils n'ont qu'une seule option : JSON à travers le HTTP.

Avoir trois solutions pour un seul problème est loin d'être idéal. Tout effort pour améliorer la communication entre services doit être fait trois fois pour chaque protocole et chacun a ses excentricités ce qui rend difficile de mettre en œuvre les mêmes caractéristiques pour tous. Idéalement, nous aimerions avoir un seul protocole - à la fois performant, facile à déboguer et utilisable à partir de différents langages de programmation. Avoir le beurre et l'argent du beurre.

Nous avions besoin d'une façon quelconque de noter les référenciations les unes par rapport aux autres pour voir laquelle est « la meilleure » et dans quelle situation. Nous avons choisi la performance comme première priorité. Nous pouvons toujours fabriquer des outils pour déboguer des protocoles et écrire notre propre programmation s'il n'y en a pas une pour un langage spécifique. Mais si un protocole est fondamentalement lent, il n'y a pas d'autre choix.

Nous avions aussi besoin d'une référenciation. Et il est facile d'en écrire une qui ferait un ensemble d'appels dans une boucle et mesurerait le débit. Il est même plus facile de faire des erreurs avec de telles mesures de performances, par ex. ne pas mesurer ce qui nous intéresse, ou ses résultats qui peuvent être faussés, les rendant inutiles.

Écrire une (bonne) référenciation

Deux de nos trois protocoles sont limités au langage de programmation Java, donc cela prend du sens d'écrire toute la référenciation en Java. Mais en écrire qui fonctionnent sur Java Virtual Machine (JVM) sans faire des erreurs est difficile. Il y a tellement de détails sur lesquels il faut avoir l'œil :

  • Il faut un certain temps à JVM pour profiler et optimiser un code généré. Il serait vain d'effectuer ses mesures de performance avant que JVM ne finisse les optimisations.
  • JVM profilera et optimisera le code pour le modèle d'utilisation réelle. Si nous mesurons deux protocoles l'un après l'autre, le second pourrait obtenir des résultats pires parce que JVM a déjà un code spécialisé pour le premier. Pour empêcher cela, chaque référenciation doit être réalisée dans un nouveau processus propre.
  • En cas de mesure multi-thread, une attention spéciale doit être portée pour assurer que tous les fils d'exécution commencent et terminent en même temps.
  • Et ce n'est même pas la moitié des problèmes éventuels. Heureusement, il existe des outils qui peuvent les résoudre pour nous. L'un d'entre eux est Java Microbenchmark Harness (JMH). Écrit par les développeurs JVM, nous pouvons supposer sans nous tromper qu'ils savent ce qu'ils font.

Écrire des référenciations avec JMH est facile. Nous avons seulement besoin d'écrire un code que nous voulons réellement mesurer et JMH s'occupera du reste - faire fonctionner le code en boucle pour mesurer le débit ou des processus séparés pour chaque référenciation en donnant à JVM assez de temps pour se « chauffer » et l'améliorer correctement ainsi que traiter tous les autres détails fastidieux (mais important).

L'ébauche de notre référenciation ressemble à ceci :

public class ProtocolBenchmark {
 
    @Param({"http_json", "http_java", "mml"})
    private String protocol;
 
    @Param({"0", "10", "100"})
    private int payloadSize;
 
    @Setup
    public void setup() {
        // Spin-up the server and the clients
    }
 
    @Benchmark
    @Threads(1)
    public Object threads_1() {
        // Perform a RPC call
        // This will be invoked from one thread (client) only
    }
 
    @Benchmark
    @Threads(32)
    public Object threads_32() {
        // Perform a RPC call
        // This will be concurrently invoked from 32 threads (clients)
    }

}

JHM est assez flexible et peut être adapté à différents scénarios. Les options les plus utiles pour notre référenciation étaient les paramètres et le nombre de fils d'exécution (threads). Les paramètres nous permettent de réutiliser la même référenciation pour différents scénarios. Il n'y a pas de différence de logique dans une mesure de protocoles différents ou en utilisant des charges utiles de taille différente, donc avec ces mêmes paramètres nous pouvons éviter de copier et de passer le code. Et en variant le nombre de fils d'exécution qui fonctionnent simultanément, nous pouvons mesurer comment les protocoles s'en sortent en face d'un nombre croissant de clients concomitants.

Faire une référenciation

Il est temps de venir à bout de notre petite référenciation ! En fait, nous avons fait deux séries distinctes dans des conditions différentes.

Dans la première série, nous avions à la fois le serveur et les clients sur la même machine, communiquant par une interface localhost. Nous avons utilisé ce scénario pour mesurer le temps de gestion du protocole avec aucun facteur externe ne perturbant les mesures.

Dans la seconde série, nous avons placés nos protocoles dans des conditions plus réalistes. Nous avions un serveur qui fonctionnait sur une machine distincte des clients, communiquant grâce à un véritable réseau. Et dans le cas de protocoles HTTP, tout le trafic était acheminé par HAProxy puisque c'est comme cela que nous l'utilisons dans un environnement de production. Puisque MML accepte l'équilibrage de charge du côté client, un client peut se connecter directement au serveur, sans passer par aucun intermédiaire.

La mesure de performance du côté client a accéléré un certain nombre de clients et chacun, de manière répétée a effectué un appel à distance avec une charge utile d'une certaine taille, puis a attendu que le serveur répercute la même charge. La charge utile était une simple liste d'objets avec la taille comme le nombre d'objets dans la liste.

Le nombre de clients et la taille de la charge utile étaient paramétrés pour couvrir les modèles de communication typique de nos services. Nous avons mesuré avec 1, 32, 64 et 128 clients concomitants et une taille de charge utile variant de zéro (liste vide), à 10 et 100 éléments.

Résultats

Après déroulement complet, JHM a produit des données tabulaires avec des mesures pour chaque combinaison de paramètres donnés. Nous avons visualisé ces résultats sous forme de graphiques à barres simples pour une comparaison plus facile.

En premier, en mesurant la communication de l'hôte local nous avons obtenu ceci:

Engineering

Pour les petites et moyennes charges utiles le protocole MML est le gagnant évident quel que soit le nombre de clients concomitants. Mais cela a changé lorsque nous sommes passés à une charge utile importante, lorsque les protocoles basés sur HTTP sont devenus presque égales à MML en termes de débit. Une autre observation intéressante est qu'utiliser des objets sérialisés Java est en fait plus lent qu'utiliser JSON. Il ressemble à Jackson, qui est la bibliothèque de JSON que nous utilisons et il est mieux optimisé pour notre cas d'application.

Pour la seconde série faite avec un réseau réel, les résultats ont donné ceci :

Engineering

MML mène toujours pour les petites et moyennes charges utiles mais avec une plus petite marge. Et pour les charges utiles importantes, la situation a changé. Transférer JSON plein texte sur HTTP donne réellement de meilleurs résultats que MML, ceci de façon importante. Typed-JSON est probablement la cause fondamentale de ce résultat - toutes ces étiquettes de catégorie qu'il ajoute augmentent sensiblement le nombre d'octets qui doivent être envoyés sur le réseau, provoquant des problèmes de débit.

Conclusion

Qu'avons-nous appris de cette expérience ? Notre protocole interne, alors qu'il se comporte très bien pour les petites et moyennes charges, n'est plus un bon choix lorsque les services envoient des demandes et des réponses volumineuses. D'autre part, utiliser JSON à travers le HTTP prèsente une performance convenable et peut être utilisé par des services écrits dans n'importe quelle langue, ce qui est définitivement un plus. Enfin, utiliser la sérialisation Java sur HTTP a donné des résultats moindres dans tous les scénarios testés, donc c'est un protocole que nous retirerons définitivement.

Pour finir, nous n'avons toujours pas de grand gagnant. Nous aimerions optimiser JSON sur HTTP - ou peut-être produire une solution prototype basée sur HTTP/2 et voir comment cela se passe. Il reste encore beaucoup de travail à faire. Restez à l'écoute !

Par Hrvoje Ban, Software Engineer