Construire une application 100% ScyllaDB Shard-Aware en utilisant Rust

Découvrez notre projet innovant récompensé par le Technical Achievement Award de ScyllaDB.

Cette conception innovante a été récompensée

Nous sommes fiers d’avoir reçu le Technical Achievement Award for Outstanding Innovative Project de ScyllaDB en concevant une application de traitement de données impliquant ScyllaDB et s’appuyant sur sa conception architecturale bas niveau. Cette conception innovante présente des qualités remarquables telles que l’idempotence, le traitement déterministe et distribué des données, ainsi qu’une scalabilité infinie, qui profitent à nos clients. Nous sommes très touchés par cette reconnaissance qui récompense à la fois l’expertise technique de nos équipes et les contributions de la communauté Open-Source.

L’application est maintenant en production chez Numberly, apprenez-en plus dans cet article !

Certificat de l'award Technical Achievement de ScyllaDB avec le logo Numberly

Contexte du projet

Chez Numberly, l’équipe Omnichannel Delivery est propriétaire de tous les types de messages que nous prenons en charge et gérons pour nos clients.

De l’e-mail bien connu et bien établi au RCS encore émergent, sans oublier les plateformes OTT telles que WhatsApp.

L’équipe a récemment eu l’occasion de construire une “plateforme pour les gouverner tous” dans le but de rationaliser la façon dont tous nos composants envoient et suivent les messages, quelle que soit leur forme.

La logique générale est la suivante : les clients ou les plateformes programmatiques envoient des messages ou des lots de messages par API REST à des gateways qui sont responsables de la validation et du rendu du message.

Ensuite, ces gateways convergent tous vers une plateforme centrale de routage des messages qui met en œuvre des fonctions complètes de planification, de comptabilité, de traçage et, bien sûr, de routage des messages à l’aide des connecteurs de la plateforme ou de l’opérateur adéquats.

Schéma des gateways qui convergent vers la plateforme centrale de routage de messages

La Plate-forme de messagerie centrale

Contraintes élevées

Mettre tous ses œufs dans le même panier est toujours risqué, n’est-ce pas ?

Ce type de décision impose de nombreuses contraintes à nos exigences en matière de plateforme. Elle doit être très fiable !

Tout d’abord, elle doit être hautement disponible et résiliente, car elle deviendra un point de défaillance unique pour tous nos messages.

Ensuite, elle doit pouvoir évoluer rapidement pour s’adapter à la croissance d’un ou de plusieurs canaux à la fois, au fur et à mesure que nos besoins en matière de routage changent.

Garanties solides

La haute disponibilité et la scalabilité semblent faciles à obtenir si on les compare à nos exigences d’observabilité et d’idempotence.

Lorsque vous imaginez que tous vos messages passent par un seul endroit, la capacité de retracer ce qui est arrivé à chacun d’entre eux (ou à un groupe d’entre eux) devient un véritable défi.

Pire encore, l’un des plus grands défis, encore plus dans un système distribué, est la garantie d’idempotence qui nous manquait jusqu’à présent sur les autres pipelines.

Garantir qu’un message ne peut pas être envoyé deux fois est plus facile à dire qu’à faire !

Design Thinking & Concepts Clés

Nous avons divisé nos objectifs en trois concepts principaux que nous nous sommes engagés à respecter scrupuleusement afin de respecter les contraintes et les garanties de notre plateforme.

 

Fiabilité

  • Simple : peu de composants qui ne se partagent (presque ?) rien.
  • Faible couplage : réduire au minimum les dépendances extérieures.
  • Langage de codage : efficace avec des modèles explicites et des paradigmes stricts.

 

Scalabilité

  • Couche adaptative : facile à déployer et à mettre à échelle avec une forte résilience.
  • Bus de données : bus de messages à haut débit, hautement résilient, extensible horizontalement, avec des capacités de préservation du temps et de l’ordre.
  • Requêtage des données : faible latence, prise en charge d’une ou plusieurs requêtes.

 

Idempotence

  • Isolation du traitement : la distribution de la charge de travail doit être déterministe.

Considérations sur l'architecture

Le possible choix par défaut

Compte tenu du stack de Numberly, le premier choix d’architecture aurait pu être quelque chose comme ceci :

Schéma de l'architecture de choix par défaut comprenant les passerelles API REST et la plateforme centrale d'acheminement des messages
  • Couches applicative fonctionnant sur Kubernetes.
  • Kafka comme bus de transmission de messages à partir des API de la passerelle.
  • Kafka utilisé comme log des messages à traiter et à envoyer.
  • ScyllaDB comme couche de stockage pour interroger l’état d’un message individuel ou d’un groupe de messages.
  • Redis comme hot cache pour certaines optimisations.
  • Kafka est utilisé comme bus de messagerie entre notre plateforme centrale de routage des messages et les agents de routage des canaux individuels.

Sur le papier, cela semble être une conception solide et prouvée, n’est-ce pas ?

 

Pas tant que cela un choix par défaut

Cette architecture apparemment simple comporte des réserves qui cassent trop de concepts que nous avions promis de respecter !

 

Fiabilité

  • Haute disponibilité avec faible couplage : nous nous appuierions et devrions concevoir notre fiabilité sur trois technologies de données différentes, chacune d’entre elles pouvant tomber en panne pour des raisons différentes que notre logique de plateforme devrait gérer.

 

Scalabilité

Bien que nous ayons la chance de disposer d’une technologie de données pour répondre à chaque contrainte de scalabilité que nous fixons, la combinaison des trois ne répond pas à nos exigences de fiabilité et d’idempotence.

Leur combinaison ajoute trop de complexité et de points de défaillance pour être efficacement mise en œuvre ensemble :

  • Facile à déployer : Kubernetes ferait bien l’affaire.
  • Scalabilité horizontale des données : alors que ScyllaDB pourrait scaler à coup sûr, la scalabilité de Kafka avec sa logique de partition est à prendre avec précaution, tandis que Redis ne scale pas si bien que cela de base.
  • Faible latence de requête : ScyllaDB et Redis sont les grands gagnants dans ce domaine, tandis que Kafka n’est manifestement pas conçu pour “interroger” facilement un élément de données.
  • Bus de données ordonné : c’est là que Kafka excelle et que Redis expose une capacité de mise en file d’attente qui scalera aléatoirement. ScyllaDB, d’un autre côté, pourrait être capable d’agir comme un bus ordonné si nous y réfléchissons un peu ?

 

Idempotence

L’idempotence, comme prévu, devient un cauchemar lorsque vous imaginez la réaliser sur un écosystème aussi complexe mélangeant de nombreuses technologies.

  • Distribution déterministe de la charge de travail : peut-on y parvenir en additionnant ScyllaDB+Kafka+Redis ?

 

L’architecture audacieuse

Nous avons donc décidé d’être audacieux et de faire une grande déclaration : nous n’utiliserons qu’UNE seule technologie de données pour tout maintenir ensemble !

ScyllaDB était la meilleure solution pour relever le défi :

  • Elle est hautement disponible.
  • Elle scale de manière incroyable.
  • Elle offre des requêtes extrêmement rapides, qu’il s’agisse de requêtes simples ou de requêtes sur des ranges.

Ce qui signifie qu’il peut également être considéré comme un cache distribué remplaçant efficacement Redis.

Remplacer kafka comme un bus de données ordonnées n’est pas si trivial en utilisant ScyllaDB mais semble faisable…

La plus grande question que nous devions encore nous poser était la suivante :

  • Comment pouvons-nous obtenir une distribution déterministe de la charge de travail, si possible, gratuitement ?

C’est là que j’ai eu une idée qui s’est avérée pas si folle que ça :

  • Et si j’utilisais l’architecture shard-per-core de ScyllaDB dans ma propre application ?

Faisons un bref détour pour expliquer l’architecture shard-per-core de ScyllaDB.

Architecture Shard-Per-Core de ScyllaDB

La conception architecturale bas niveau de ScyllaDB s’appuie sur une architecture shard-per-core pour distribuer et traiter les données de manière déterministe.

L’idée principale est que la clé de partition dans la conception de votre table de données détermine non seulement quel nœud est responsable d’une copie des données, mais aussi quel core CPU est chargé de gérer son traitement E/S.

Schéma du cluster ScyllaDB, du calcul du shard de la clé de partition et des shards par cœur

En effet, ScyllaDB distribue les données de manière déterministe jusqu’à un seul core CPU !

Mon idée naïve était donc de distribuer le traitement de notre plate-forme de messagerie en utilisant exactement la même logique que ScyllaDB :

Schéma du cluster ScyllaDB, des shards par cœur et des pods d'application

L’effet escompté serait d’aligner le traitement de ScyllaDB par core CPU sur celui de notre application et de bénéficier de toute la latence, de la scalabilité et de la fiabilité qui en découlent.

L'application 100% Shard-Aware

C’est ainsi que nous avons créé une application 100% shard-aware, et elle offre des propriétés étonnantes !

  • Distribution déterministe de la charge de travail
  • Capacité de traitement des données très optimisée, alignée de l’application à la couche de stockage
  • Fortes garanties de latence et d’isolation par instance d’application (pod)
  • Scalabilité infinie grâce à la capacité de ScyllaDB à croître de manière fluide.

Création d'une application Shard-Aware

Choisir le bon langage de programmation

Maintenant que nous avons trouvé notre inspiration en matière d’architecture, il est temps de répondre à l’éternelle question “Quel langage utiliser ?”.

  • Nous avons besoin d’un langage moderne qui reflète notre désir de construire une plateforme fiable, sécurisée et efficace.
  • L’algorithme de calcul du Shard nécessite des capacités de hachage rapides et une grande synergie bas niveau avec le pilote ScyllaDB.

Une fois cela établi, Rust s’est imposé comme une évidence.

 

L’ingestion déterministe de données

Les messages entrants sont traités par un composant que nous appelons ingester.

Pour chaque message que nous recevons, après les validations habituelles, nous calculons le shard auquel le message appartient car il sera stocké dans ScyllaDB. Pour ce faire, nous utilisons les fonctions internes du pilote ScyllaDB Rust (que nous avons contribué à développer).

Schéma de la première étape de l'ingestion de données

Plus précisément, nous calculons une clé de partition qui correspond aux nœuds de réplique de stockage de ScyllaDB et au core CPU à partir de la clé de partition de notre message, alignant ainsi le traitement de notre application sur le core CPU de ScyllaDB.

Une fois que cette clé de partition est calculée pour correspondre à la couche de stockage de ScyllaDB, nous persistons le message avec toutes ses données dans la table des messages et ajoutons en même temps ses métadonnées à une table nommée buffer avec la clé de partition calculée.

Schéma de la seconde étape de la data ingestion

Le traitement déterministe des données

Maintenant que les données sont stockées dans ScyllaDB, parlons du deuxième composant que nous appelons les schedulers.

Les schedulers consomment les données ordonnées de la buffer table et procèdent effectivement à la logique d’acheminement des messages.

Suivant l’architecture shard-to-component, un scheduler consommera exclusivement les messages d’un shard spécifique, de la même manière qu’un core CPU est assigné à une tranche de données ScyllaDB.

Schéma de la première étape du traitement déterministe des données

Un scheduler récupère une tranche des données dont il est responsable dans la buffer table.

Schéma de la deuxième étape du traitement déterministe des données

À ce stade, le scheduler dispose des identifiants des messages qu’il doit traiter. Il récupère ensuite les détails du message dans la table des messages.

Schéma de la troisième étape du traitement déterministe des données

Le scheduler traite ensuite le message et l’envoie au canal approprié dont il est responsable.

Schéma de la quatrième étape du traitement déterministe des données

Chaque composant de la plateforme est responsable d’une tranche de messages par canal en s’appuyant sur l’algorithme shard-aware de ScyllaDB. Nous obtenons un traitement des données aligné à 100 %, du point de vue de l’application jusqu’à la base de données.

 

Remplacer Kafka par ScyllaDB

Remplacer Kafka en tant que data bus ordonné n’est pas si trivial en utilisant ScyllaDB, mais c’était certainement faisable.

Voyons plus en détail comment cela fonctionne du point de vue du composant du scheduler.

Nous stockons les métadonnées des messages sous forme de séries temporelles dans la buffer table, ordonnées en fonction de l’heure d’ingestion de ScyllaDB (il s’agit d’un détail important). Chaque scheduler conserve un décalage d’horodatage du dernier message qu’il a traité avec succès.

Ce décalage est stocké dans une table dédiée. Lorsqu’un scheduler démarre, il récupère le décalage de l’horodatage du groupe de données auquel il est assigné.

Schéma du remplacement de Kafta par ScyllaDB

Un scheduler est une boucle infinie qui récupère les messages qui lui sont assignés dans une fenêtre de temps déterminée et configurable.

En fait, un scheduler ne récupère pas les données strictement à partir du dernier décalage d’horodatage, mais plutôt à partir de l’horodatage le plus ancien.

Cela signifie en effet qu’un seul message sera récupéré plusieurs fois, mais cela est géré par notre logique opérationnelle d’idempotence et optimisé par un cache mémoire.

Le chevauchement de la plage temporelle précédente nous permet d’éviter tout message manquant qui pourrait être causé par une latence d’écriture potentielle ou un décalage temporel subtil entre les nœuds (puisque nous nous appuyons sur les horodatages de ScyllaDB).

Schéma du remplacement de Kafta par ScyllaDB

Rétrospective

Atteindre notre objectif n’a pas été facile, nous avons échoué à plusieurs reprises, mais nous avons finalement réussi et prouvé que notre idée originale ne fonctionnait pas seulement, mais qu’elle était aussi très pratique à utiliser tout en étant incroyablement efficace.

 

Ce que nous avons appris

La première chose que nous voulons souligner est que les tests de charge sont plus qu’utiles.

Assez rapidement au cours du développement, nous avons mis en place des tests de charge, envoyant des dizaines de milliers de messages par seconde. Notre objectif était de tester la conception de notre schéma de données à l’échelle et la garantie d’idempotence.

Cela nous a permis de repérer de nombreux problèmes, parfois non triviaux, comme lorsque le délai d’exécution entre les instructions de notre lot d’insertion était supérieur à notre fenêtre temporelle de récupération. Oui, un cauchemar à débugger…

D’ailleurs, notre première charge de travail était une insertion et une suppression naïves, et les tests de charge ont montré que les grandes partitions étaient très rapides !

Heureusement, nous avons également appris à connaître les stratégies de compactage, et en particulier la stratégie de compactage par fenêtre temporelle, que nous utilisons actuellement et qui nous a permis de nous débarrasser du problème des grandes partitions.

  • La mise en mémoire tampon des messages lors du traitement des séries temporelles nous a permis d’éviter les grandes partitions !

 

Nous avons contribué au pilote Rust de ScyllaDB

Pour rendre ce projet possible, nous avons contribué à l’écosystème ScyllaDB, en particulier au pilote Rust, avec quelques problèmes et pull requests.

Par exemple, nous avons ajouté le code pour calculer les nœuds de réplique d’une clé primaire, car nous en avions besoin pour calculer le shard d’un message :

Nous espérons que cela vous aidera si vous souhaitez utiliser ce modèle de sharding cool dans votre future application shard-aware !

Nous avons également découvert quelques bugs dans ScyllaDB, nous avons donc travaillé avec le support de ScyllaDB pour les corriger (merci pour votre réactivité).

 

Ce que nous aimerions faire

Comme dans tous les systèmes, tout n’est pas parfait, et il y a certains points que nous aimerions améliorer.

ScyllaDB n’est évidemment pas une plateforme de mise en file d’attente de messages, et le long-polling de Kafka manque à l’appel. Actuellement, notre architecture effectue des recherches régulières dans chaque mémoire tampon, ce qui consomme beaucoup de bande passante inutile, mais nous travaillons à l’optimisation de ce processus.

Nous avons également rencontré quelques problèmes de mémoire, pour lesquels nous avons soupçonné le pilote ScyllaDB Rust. Nous n’avons pas pris beaucoup de temps pour enquêter, mais cela nous a poussé à creuser dans le code du pilote, où nous avons repéré beaucoup d’allocations de mémoire.

Dans le cadre d’un projet parallèle, nous avons commencé à réfléchir à certaines optimisations ; en fait, nous avons fait plus que réfléchir, car nous avons écrit un prototype complet d’un pilote ScyllaDB Rust (presque) sans allocation.

Nous en ferons peut-être le sujet d’un prochain article, avec le pilote Rust surpassant le pilote Go !

 

Aller plus loin avec les fonctionnalités de ScyllaDB

Nous avons donc misé sur ScyllaDB, et c’est une bonne chose, car il dispose de nombreuses autres fonctionnalités dont nous voulons bénéficier.

Par exemple, la capture des Data Change : en utilisant le connecteur source CDC Kafka, nous pourrions transmettre nos événements de messages au reste de l’infrastructure, sans toucher au code de notre application. L’observabilité en toute simplicité.

Nous attendons avec impatience le cheminement de ScyllaDB vers des tables fortement constantes avec Raft comme alternative à LWT.

Actuellement, nous utilisons LWT à quelques endroits, en particulier pour l’attribution dynamique de la charge de travail, et nous sommes donc impatients de tester cette fonctionnalité !

Écrit par Alexys Jacob, CTO.