Aller au contenu

Automatisation planifiée avec NestJS

Optimiser les systèmes côté serveur : mon utilisation d’une tâche CRON dans un microservice NestJS

Comment un microservice NestJS planifié a regroupé des demandes répétées de localisation de flotte dans une tâche côté serveur conçue pour une charge client bien plus élevée.

9 min de lecturePar

Contexte : un système serveur de localisation de flotte

Le système serveur alimentait une application de gestion de camions pour des entreprises. Chaque entreprise gérait plusieurs conducteurs et devait suivre la position de ses camions. Les clients des conducteurs envoyaient donc des mises à jour de position à intervalles réguliers, tandis que les clients des entreprises et des administrateurs consommaient ces données.

Le système serveur initial était simple, car l’activité utilisateur était faible. Le problème de conception est apparu lorsque la même API a dû prendre en charge une charge prévue d’environ 10 000 utilisateurs actifs quotidiens et un historique de positions en forte croissance.

Le point de terminaison de localisation est devenu le goulot d’étranglement

La mise à l’échelle horizontale et un équilibreur de charge pouvaient répartir les requêtes entrantes, mais n’éliminaient pas le travail effectué dans le point de terminaison de mise à jour des positions. Chaque conducteur pouvait conserver une fenêtre de cinq jours contenant 1 440 points : 12 points par heure, 288 par jour, espacés de cinq minutes.

Pour conserver l’ordre du tableau, il fallait soit exécuter shift puis push, avec un coût O(n) + O(1), soit exécuter pop puis unshift, avec un coût O(1) + O(n). À cette maintenance du tableau s’ajoutaient les calculs de temps, les contrôles de coordonnées, la validation des données et la persistance en base de données à chaque mise à jour.

Les clients des conducteurs ne constituaient pas une horloge fiable. Un appareil pouvait envoyer cinq requêtes dans un intervalle de cinq minutes ou n’en envoyer aucune dans une zone sans réseau. Le volume des requêtes et le traitement des tableaux augmentaient avec le nombre de conducteurs actifs.

Avec seulement 1 000 clients envoyant une requête par minute, le monolithe recevrait 1 000 requêtes par minute et environ 1,4 million de requêtes par jour.

Séparer la vérification de l’intégration en base de données

La conception séparait la réception et la vérification de l’intégration dans la base de données principale. Un service d’automatisation recueillait les positions et se chargeait d’envoyer au monolithe les mises à jour en attente de chaque camion à un point de passage contrôlé.

Une tâche cron s’exécutait toutes les cinq minutes et envoyait une requête groupée contenant des tableaux de positions. Le changement mesurable concerne la forme des requêtes, pas une accélération prouvée de la base de données : de nombreuses écritures clientes sont devenues une requête groupée par instance du planificateur toutes les cinq minutes.

Le même microservice recevait aussi les appels d’API entrants et pouvait renvoyer des données analytiques, tandis que l’échéance planifiée déterminait quand les données accumulées atteignaient le monolithe.

Créer le microservice NestJS

Installez la CLI Nest, créez le projet et ajoutez les packages officiels de microservices, de planification et HTTP.

npm i -g @nestjs/cli
nest new project-name
# Or create the project in the current directory:
nest new .
npm i --save @nestjs/microservices
npm install --save @nestjs/schedule
npm install --save-dev @types/cron
npm i --save @nestjs/axios axios

L’amorçage ci-dessous exécute un serveur HTTP normal sur le port 8080 et connecte un microservice TCP sur le port 8081. Un service qui n’a pas besoin de HTTP peut omettre l’écouteur HTTP.

import { NestFactory } from '@nestjs/core'
import { AppModule } from './app.module'
import { Transport } from '@nestjs/microservices'

async function bootstrap() {
  const app = await NestFactory.create(AppModule)

  app.connectMicroservice({
    transport: Transport.TCP,
    options: {
      port: 8081
    }
  })

  await app.startAllMicroservices()
  await app.listen(8080)
}

bootstrap()

AppModule réunit les modules de localisation et de planification afin que le traitement des positions entrantes et la persistance planifiée restent séparés.

import { Module } from '@nestjs/common'
import { LocationModule } from './location/location.module'
import { SchedulerModule } from './scheduler/scheduler.module'

@Module({
  imports: [SchedulerModule, LocationModule],
  providers: []
})
export class AppModule {}

Conserver la tâche cron dans un module dédié

Le module de localisation possède son contrôleur et son service. Le contrôleur traite les requêtes GET entrantes et les événements location_updated, tandis que le service peut effectuer les opérations de localisation ou créer des tâches dynamiques en réponse aux événements.

La planification reste dans un module dédié afin que Nest n’enregistre pas de tâches dupliquées inutiles. Le service de localisation doit également être un fournisseur partagé unique. Déclarer de nouveau LocationService dans SchedulerModule créerait une autre instance Nest et pourrait conduire le planificateur à lire un tampon en mémoire différent de celui du contrôleur.

import { Module } from '@nestjs/common'
import { ScheduleModule } from '@nestjs/schedule'
import { HttpModule } from '@nestjs/axios'
import { LocationModule } from '../location/location.module'
import { SchedulerService } from './scheduler.service'

@Module({
  imports: [ScheduleModule.forRoot(), HttpModule, LocationModule],
  providers: [SchedulerService]
})
export class SchedulerModule {}

Planifier et envoyer la mise à jour groupée

Le planificateur utilise CronExpression.EVERY_5_MINUTES. HttpService renvoie un Observable, donc firstValueFrom résout la requête PATCH. Les méthodes suivantes sont du pseudocode : peek lit sans supprimer, et acknowledge ne retire qu’un lot confirmé par le monolithe. L’exemple de 2023 n’implémentait pas d’acquittement durable.

import { HttpService } from '@nestjs/axios'
import { Injectable } from '@nestjs/common'
import { Cron, CronExpression } from '@nestjs/schedule'
import { firstValueFrom } from 'rxjs'
import { externalLinks } from 'src/links'
import { LocationService } from 'src/location/location.service'

@Injectable()
export class SchedulerService {
  constructor(
    private readonly locationService: LocationService,
    private readonly httpService: HttpService
  ) {}

  @Cron(CronExpression.EVERY_5_MINUTES)
  async locationBulkUpdateJob() {
    const batch = this.locationService.peekPendingLocations()
    if (batch.length === 0) return

    await firstValueFrom(
      this.httpService.patch(externalLinks.backendProdLink, { locations: batch })
    )

    this.locationService.acknowledgeLocations(batch)
  }
}
  • RxJS fournit des Observables et des opérateurs pour la réponse asynchrone et le chemin d’erreur.
  • @nestjs/schedule enregistre la tâche cron et fournit l’expression prédéfinie EVERY_5_MINUTES, équivalente à */5 * * * *.
  • @nestjs/axios expose Axios au moyen de HttpService de Nest pour envoyer la requête PATCH au monolithe.

Ce que l’exemple ne résout pas

Un décorateur cron ne fait que planifier le travail. Il n’empêche pas les exécutions en double, ne préserve pas les données en mémoire après un redémarrage et ne garantit pas qu’une seule réplique possède la planification.

  • Stockez durablement les positions en attente afin qu’un redémarrage ne puisse pas effacer les données de l’intervalle.
  • Associez une clé d’idempotence ou de lot afin qu’une nouvelle tentative ne puisse pas écrire deux fois les mêmes positions.
  • N’acquittez un lot qu’après sa confirmation par le monolithe. Conservez-le en cas d’expiration du délai ou d’échec.
  • Empêchez le chevauchement des exécutions lorsqu’une requête PATCH dure plus de cinq minutes.
  • Limitez la taille des lots et définissez une contre-pression lorsque les données arrivent plus vite qu’elles ne sont intégrées.
  • Exécutez la planification au moyen d’un processus élu, d’un verrou distribué ou d’un planificateur externe. Sinon, chaque réplique de l’application enregistre la même tâche cron.

Résultat et références

Le planificateur transforme de nombreuses écritures d’appareils en un lot toutes les cinq minutes. Il fournit un point unique pour vérifier, recommencer et acquitter le travail, mais je ne dispose d’aucune mesure de production sur la charge de la base de données, la disponibilité ou le taux de livraison.

  • Le problème d’origine associait une hausse du trafic client à un point de terminaison de mise à jour des positions toujours plus coûteux.
  • Un microservice d’automatisation distinct est devenu responsable de l’envoi groupé des mises à jour de position au monolithe.
  • La tâche cron s’exécute toutes les cinq minutes et regroupe les positions en attente dans une seule requête.
  • L’implémentation utilise les microservices NestJS, la planification des tâches, le module HTTP et RxJS.