Retour à l'accueil

Optimisation Fastify : Filtres Bloom et Backends Évolutifs

Explorez des solutions pratiques pour créer un backend à haute charge sur Fastify : IDs personnalisés, filtres Bloom pour les utilisateurs et les images, autorisation sans login, écritures DB asynchrones, protection API, et typage TypeScript.

Optimisation Backend Fastify : Filtres Bloom & Architectures Évolutives
Advertisement 728x90

Optimisation des backends Fastify pour les applications à forte charge : Solutions pratiques

Concevoir des backends évolutifs et performants exige une compréhension approfondie des compromis architecturaux. Dans cet article, nous partagerons notre expérience dans le développement d'une application à forte charge utilisant Fastify, en nous concentrant sur les stratégies visant à minimiser la charge de la base de données, à identifier efficacement les utilisateurs sans authentification traditionnelle et à se protéger contre les requêtes malveillantes, en tirant parti d'outils tels que les filtres de Bloom et les opérations asynchrones.

Stratégies de génération d'ID et gestion efficace des données

Lors de la conception de systèmes gérant un grand nombre d'entités, comme des utilisateurs ou des images, le choix du format d'identifiant (ID) est d'une importance capitale. Les GUIDs ou UUIDs standards, bien qu'assurant l'unicité, peuvent être excessivement gourmands en ressources pour l'indexation des bases de données et le traitement côté backend. Pour réduire cette surcharge, nous avons mis en œuvre un format d'ID hybride : 19bc19e00ab-eh5kkeh99q7. La première partie représente un horodatage Unix converti en hexadécimal, et la seconde est une séquence aléatoire de caractères. Le tiret sert uniquement à améliorer la lisibilité.

Voici un exemple de fonction pour générer un tel ID :

Google AdInline article slot
function generateID(): string {
  const timestamp = Date.now().toString(16);
  const randomPart = generateRandomSequence( 11 );
  return timestamp + '-' + randomPart;
}

Cette approche fournit un ID « conditionnellement incrémental », car sa partie initiale change à chaque milliseconde, ce qui contribue à une indexation plus efficace de la base de données. Un avantage supplémentaire est la possibilité de décoder la date de création de l'entité directement à partir de l'ID, éliminant ainsi le besoin d'un champ created_at séparé dans les tables. Cela réduit le volume de données stockées et le nombre d'opérations d'écriture, bien que cela impose de légères limitations sur la commodité de la construction de requêtes basées sur la date.

Mise à l'échelle avec les filtres de Bloom et l'authentification sans mot de passe

Le développement d'une application conçue pour des milliers de sessions concurrentes et des dizaines de milliers d'images nécessite des approches qui minimisent la consommation de CPU et de mémoire. Les requêtes SQL traditionnelles pour vérifier les images vues ou les utilisateurs existants deviennent rapidement un goulot d'étranglement. Dans ce contexte, les filtres de Bloom jouent un rôle inestimable.

Un filtre de Bloom est une structure de données probabiliste qui peut déterminer efficacement (par exemple, avec une probabilité de 99,9999 %) si un élément n'est pas dans un ensemble. Son principal avantage est une surcharge mémoire et CPU extrêmement faible par rapport aux structures de données classiques lors de la vérification d'appartenance. Les principales applications des filtres de Bloom dans notre projet incluent :

Google AdInline article slot
  • Suivi des images vues : Pour chaque utilisateur, un filtre de Bloom est maintenu, stockant les ID des images déjà affichées. Cela permet des vérifications rapides pour voir si une image a déjà été présentée à l'utilisateur, éliminant les doublons des nouveaux lots.
  • Identification des utilisateurs existants : Au démarrage du backend, tous les ID d'utilisateurs enregistrés sont chargés dans un filtre de Bloom global. Cela permet une vérification instantanée de la connaissance par le système d'un device_id entrant.

Pour améliorer la commodité de l'utilisateur et simplifier la modération des boutiques d'applications, un système d'identification sans authentification explicite a été mis en œuvre. Lors de l'installation de l'application, un device_id unique est généré, qui est ensuite utilisé pour identifier l'utilisateur sur le backend. Pour se protéger contre la génération de faux device_id et les abus, un processus de vérification en plusieurs étapes est utilisé :

  • Validation du schéma JSON Fastify : La validation initiale du format du device_id a lieu au niveau de Fastify à l'aide d'un schéma JSON qui définit minLength, maxLength et un pattern.

```javascript

fastify.post( '/backend', {

Google AdInline article slot

  schema: {

    body: {

      type: 'object',

      required: [ 'device_id' ],

      properties: {

        device_id: {

          type: 'string',

          minLength: 23,

          maxLength: 23,

          pattern: '^[a-z0-9]{11}-[a-z0-9]{11}$'

        }

      }

    }

  }

} );

```

  • Filtre de Bloom utilisateur : Après avoir passé le schéma Fastify, le device_id est vérifié par rapport au filtre de Bloom des utilisateurs existants.
  • Vérification de la validité de l'ID :

* Date de création : La première partie de l'ID, encodant l'horodatage, doit se situer dans la période entre le lancement du projet et la date actuelle.

* Entropie de la partie aléatoire : La deuxième partie de l'ID, représentant une séquence aléatoire, est vérifiée pour son entropie. Cela empêche l'utilisation d'ID simples et prévisibles (par exemple, aaaaaaaaaaaa). L'entropie doit être supérieure à un certain seuil (par exemple, 2).

```javascript

function getEntropyString( str: string ): number {

  const frequencies = new Map();

  for (const char of str) {

    frequencies.set(char, (frequencies.get(char) || 0) + 1);   }

  return [...frequencies.values()].reduce((acc, count) => {

    const p = count / str.length;

    return acc - p * Math.log2(p);

  },

0);

}

```

  • Insertion asynchrone d'utilisateurs : Si l'ID est jugé nouveau et valide, il est ajouté au filtre de Bloom des utilisateurs existants, et une requête INSERT est ensuite envoyée de manière asynchrone à la base de données. L'omission de await empêche le blocage du traitement des requêtes, réduisant considérablement la charge du backend. Cette approche accepte une probabilité extrêmement faible de perdre de nouvelles données utilisateur lors d'un redémarrage inattendu du backend, ce qui est un compromis acceptable pour ce type d'application, car la « perte » signifie simplement la réinitialisation de l'historique des balayages pour cet utilisateur.

Optimisation de l'interaction client et protection de l'API

Une interaction efficace entre l'application mobile et le backend est d'une importance capitale pour la performance. Pour servir du contenu statique, comme des images, Nginx est utilisé, opérant sur un domaine distinct. Cela décharge au maximum le serveur Fastify, lui permettant de se concentrer sur la logique métier, et permet une mise en cache efficace du contenu.

L'application mobile reçoit les images par lots de 10. Les ID de ces images sont également ajoutés à un filtre de Bloom « Images vues » spécifique à chaque utilisateur. Cette approche minimise le nombre de requêtes API : l'application charge deux lots au démarrage, puis demande un nouveau lot après chaque série de 10 « j'aime ». Cela assure un flux de contenu continu pendant que l'utilisateur interagit avec le lot actuel.

Pour protéger l'API contre une charge excessive et les abus, une limitation de débit (rate limiting) est mise en œuvre à l'aide du plugin rateLimit pour Fastify. Selon l'architecture, il peut utiliser Redis pour le stockage de données distribué ou fonctionner en mémoire de l'application Node.js pour les instances uniques.

Un aspect important est également la validation des réactions des utilisateurs (j'aime/je n'aime pas), qui sont envoyées par lots de 10. Ce processus implique plusieurs niveaux de validation :

  • Validation du schéma Fastify : L'ID utilisateur, l'ID image et la réaction elle-même (en tant qu'enum 'like' ou 'dislike') sont vérifiés par rapport à un schéma JSON.
  • Filtre de Bloom utilisateur : Confirmation de l'existence du user_id via le filtre de Bloom « Utilisateurs existants ».
  • Filtre de Bloom des images vues : Vérification que l'image_id a bien été montré à l'utilisateur actuel, en utilisant son filtre de Bloom personnel « Images vues ».
  • Vérification de l'authenticité du lot de réactions : Une fonction, trustLikeInBatch10, est implémentée pour analyser les motifs au sein d'un lot de 10 réactions. Si toutes les réactions sont identiques (tous des « j'aime » ou tous des « je n'aime pas ») ou s'il y a une alternance excessivement fréquente (par exemple, 8 changements ou plus), le lot est considéré comme non fiable et ignoré. Cela aide à filtrer les actions automatisées ou malveillantes.

```javascript

function trustLikeInBatch10( batch: string[] ): boolean {

  if( batch.length != feedLimit ) return false;

  const likes = batch.filter( v => v === 'like' ).length;

  if( likes === 0 || likes === feedLimit ) return false;

  const switches = batch.slice(1).reduce( ( acc, val, i ) => acc + ( val !== batch[ i ] ? 1 : 0 ), 0 );

  if( switches >= 8 ) return false;

  return true;

}

```

  • Écritures de réactions asynchrones : Similaire aux nouveaux utilisateurs, les réactions sont écrites de manière asynchrone dans la base de données sans attendre la fin de l'opération et sans utiliser de contraintes de FOREIGN KEY. Cette approche, bien qu'inconventionnelle, réduit considérablement la charge de la base de données lors de flux de données intenses, sacrifiant la cohérence stricte au profit du débit. Dans ce contexte, la perte potentielle de quelques réactions n'est pas critique pour les statistiques globales et le système de recommandation.

Pour renforcer la sécurité et prévenir les exploits liés aux détails de validation de l'API, les erreurs générées par Fastify en raison d'incompatibilités de schéma JSON sont masquées. Au lieu d'une description détaillée du problème, le backend renvoie une réponse générique. Cela rend plus difficile pour les attaquants de faire de la rétro-ingénierie des schémas de données.

fastify.setErrorHandler((
  error: FastifyError,
  request: FastifyRequest,
  reply: FastifyReply ) =>
{
  console.error( 'setErrorHandler', error );
  countErrorStatistics();
  reply.code(200).send( 'Hello World!' );
});

L'application cliente doit être préparée à gérer de telles réponses génériques, ce qui fait partie de la stratégie de sécurité globale.

Tirer parti de TypeScript pour la fiabilité du code

L'utilisation de TypeScript améliore considérablement la fiabilité et la maintenabilité du code. Plus précisément, lorsque vous travaillez avec des ensembles fixes d'états de chaîne (par exemple, les statuts d'image), vous pouvez créer élégamment un type à partir d'un tableau de chaînes. Cela garantit un typage strict pour les variables, éliminant les erreurs liées aux fautes de frappe ou à l'utilisation de valeurs invalides.

// Usually it's like this:
const imageStateArray1 = [ 'new', 'broken', 'gpt', 'ready', 'download', 'done', 'bad' ];
type tpImageState1 = 'new' | 'broken' | 'gpt' | 'ready' | 'download' | 'done' | 'bad';

// or better yet:
const imageStateArray2 = [ 'new', 'broken', 'gpt', 'ready', 'download', 'done', 'bad' ] as const;
type tpImageState2 = ( typeof imageStateArray2 )[ number ];

La deuxième approche, utilisant as const et (typeof array)[number], est plus préférable car elle infère automatiquement le type littéral du tableau, assurant la synchronisation entre les valeurs du tableau et son type sans duplication.

Points clés à retenir

  • L'utilisation des filtres de Bloom réduit considérablement la charge de la base de données lors de la vérification de l'existence d'éléments, ce qui est d'une importance capitale pour les systèmes à forte charge.
  • La génération d'ID personnalisés, intégrant un horodatage, permet d'intégrer des métadonnées et d'optimiser l'indexation, tout en éliminant les champs created_at redondants.
  • Les opérations d'écriture asynchrones dans la base de données et l'omission des contraintes de FOREIGN KEY augmentent le débit du backend au prix d'une perte de données potentielle, mais acceptable, lors de défaillances dans les applications à forte intensité.
  • La validation détaillée des requêtes entrantes au niveau de Fastify et les vérifications d'entropie des données protègent l'API contre les abus et les données malformées.
  • Le masquage des erreurs de validation de Fastify améliore la sécurité de l'API en obscurcissant la structure interne des schémas et en rendant la rétro-ingénierie plus difficile pour les attaquants potentiels.

— Editorial Team

Advertisement 728x90

Lire ensuite