Aéronautique / plateforme de données

Découpler deux charges de travail sur une plateforme d'événements

Une plateforme où l'ingestion machine et le travail humain passaient par le même chemin de code, et la séparation des deux comme seule correction durable.

Rôle
Tech lead, backend & cloud
Période
2025
  • AWS Lambda
  • DynamoDB
  • SNS
  • SQS FIFO
  • API Gateway
  • Java
  • Spring Boot

Contexte

Une plateforme de données industrielles dans l’aéronautique, qui ingère des millions d’événements par jour. Chaque événement porte un état d’avancement qu’un opérateur humain fait progresser dans un cycle de vie fixe :

à revoir → ouvert → planifié → sous surveillance → clôturé

Deux charges de travail très différentes empruntaient donc le même chemin : d’un côté une ingestion machine à fort volume, continue, indifférente à la seconde près ; de l’autre une interaction humaine à faible volume, mais où chaque milliseconde se voit parce qu’un opérateur attend devant son écran.

Contrainte

Les deux charges partageaient le même chemin de code et le même modèle de données. Trois conséquences se cumulaient.

D’abord, les transitions d’état étaient traitées en ligne avec l’ingestion. Chaque ingestion portait un travail de transition dont elle n’avait pas besoin, et chaque mise à jour d’état attendait derrière la file d’ingestion.

Ensuite, les appels de l’API destinée aux opérateurs entraient en concurrence avec le trafic d’ingestion pour les mêmes ressources. Un pic d’ingestion, c’est-à-dire un moment parfaitement normal, dégradait directement l’expérience des utilisateurs.

Enfin, le design des clés DynamoDB ne correspondait ni à l’un ni à l’autre des motifs d’accès. Les lectures dont les opérateurs avaient besoin n’étaient pas servies par les clés, et retombaient donc sur des scans.

Le diagnostic important n’est aucun des trois symptômes pris isolément. Ce n’était pas « DynamoDB est lent », ni « il nous faut plus de Lambdas » : c’était que deux charges de travail aux caractéristiques opposées avaient été modélisées comme une seule. Tant que cette confusion tenait, chaque optimisation locale ne faisait que déplacer le problème, parce qu’accélérer un chemin partagé accélère aussi ce qui n’aurait jamais dû s’y trouver.

Décisions

CHARGE MACHINECHARGE HUMAINEchemins isolésIngestionmillions d’événements / jourSNSSQS FIFOMessageGroupId = une entitéLambda de transitionOpérateurfaible volume, latence visibleAPI GatewayLambda APIDynamoDBPK : identifiant de l’événement · SK : horodatageun item par changement d’état · GSI pour les requêtes opérateur
Les deux charges après séparation. L'ingestion publie une notification et rend la main ; le traitement des transitions est isolé, sérialisé par entité via la file FIFO. L'API des opérateurs a sa propre fonction, et les deux chemins convergent seulement sur la table.

Découpler l’ingestion du traitement des transitions. Quand l’ingestion a fini de traiter un événement, elle publie une notification sur SNS au lieu d’effectuer elle-même le travail de transition. SNS diffuse vers SQS, et SQS déclenche une Lambda de transition dédiée. L’ingestion rend la main dès que son propre travail est terminé, sans plus rien porter qui ne la concerne. Le compromis est explicite : l’état d’avancement devient cohérent à terme avec l’ingestion, et non plus immédiatement. Nous l’avons accepté parce que le consommateur de cet état est un humain dont la boucle de travail se compte en minutes, pas une autre machine qui prendrait une décision automatique dans la seconde. Le même découplage aurait été le mauvais choix si un système aval avait dépendu de cette cohérence immédiate.

Confier la concurrence à la file plutôt qu’à du code. Plusieurs événements concernant la même entité pouvaient être traités simultanément et produire un état final incohérent. La réponse réflexe est un verrou distribué : une table de locks, des baux, des délais d’expiration, et toute la classe de bugs qui vient avec. Nous avons préféré utiliser les garanties de la file elle-même. Un MessageGroupId par entité fait que les messages d’un même groupe sont traités strictement dans l’ordre, un à la fois, tandis que les groupes différents continuent d’être traités en parallèle : l’ordonnancement est garanti exactement là où il compte, et nulle part ailleurs, donc sans sacrifier le débit global. Un MessageDeduplicationId protège des publications en double. C’est le choix dont je suis le plus satisfait dans cette refonte, non parce qu’il est astucieux, mais parce qu’il est ennuyeux : la garantie est fournie par l’infrastructure, elle est testée par AWS, et il n’y a pas de couche de coordination maison à maintenir ni à déboguer à trois heures du matin.

Remodeler la table DynamoDB autour des motifs d’accès réels. La clé de partition est devenue l’identifiant unique de l’événement, la clé de tri l’horodatage d’arrivée. Deux conséquences méritent d’être explicitées. D’une part, chaque changement d’état s’écrit comme un item distinct au lieu de muter une ligne unique : il n’y a plus de cycle lecture-modification- écriture, donc plus de mise à jour perdue quand deux traitements se croisent. D’autre part, l’historique des états devient gratuit : les opérateurs avaient besoin de l’historique complet des états d’un événement, et une requête sur la clé de partition le renvoie déjà trié par date. Pas de table d’audit séparée, pas de second chemin d’écriture à maintenir en cohérence avec le premier. Une fonctionnalité que nous aurions dû construire est devenue une propriété du modèle de données.

Isoler l’API humaine dans sa propre Lambda. API Gateway route vers une fonction dédiée, séparée du chemin d’ingestion. Les pics d’ingestion ne dégradent plus l’expérience des opérateurs, et surtout les deux peuvent être dimensionnées et raisonnées indépendamment : la concurrence réservée, les délais d’expiration et le profil mémoire de l’une n’ont plus à être un compromis avec ceux de l’autre.

Remplacer les scans par des index secondaires. Certaines requêtes des opérateurs ne s’alignaient pas sur la clé primaire. Plutôt que de scanner la table en les optimisant à la marge, nous avons ajouté des index secondaires globaux taillés pour ces requêtes précises. Le principe sous-jacent vaut d’être énoncé, parce qu’il gouverne tout le reste : dans DynamoDB, on modélise la table à partir des requêtes que l’on sait devoir exécuter. Un scan dans un chemin chaud n’est pas une requête à optimiser, c’est le signal que le modèle de données est faux.

Résultat

Les deux charges ne se disputent plus les mêmes ressources : un pic d’ingestion, c’est-à-dire une journée ordinaire, ne dégrade plus l’expérience des opérateurs, et les deux chemins peuvent désormais être dimensionnés et raisonnés séparément. L’ordre de traitement par entité est garanti sans une seule ligne de code de coordination, et donc sans la classe de bugs qui accompagne les verrous distribués. Quant à l’historique des états, qui aurait dû faire l’objet d’un développement dédié avec son propre chemin d’écriture, il est devenu une simple propriété du modèle de données.

Ce que cette refonte a confirmé dépasse le cas particulier : quand deux charges de travail aux contraintes opposées partagent un chemin, aucune optimisation de ce chemin ne peut satisfaire les deux. Il faut d’abord les séparer, après quoi les décisions deviennent simples, parce que chaque moitié n’a plus qu’un seul objectif à servir.