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
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.