Optimisation des performances d’un plugin Kafka : le cas d’école

Temps de lecture : 11 min

Points clés à retenir

  • Mesurer avant d’optimiser : sans benchmark reproductible, vous faites de l’astrologie de performance.
  • Le coupable caché : la recompilation de schéma était 12x plus coûteuse que le décodage des données.
  • Le micro-batching : regrouper les messages en lots de 32 réduit le trafic réseau de 86 %.

Concrètement, qu’est-ce que ce plugin fait ?

Quand on m’a demandé combien de messages par seconde ce plugin pouvait traiter, je n’avais pas de réponse chiffrée. Juste une intuition. Et les intuitions, c’est bien joli, mais ça cache souvent des bugs bien plus profonds.

Concrètement, ce plugin est une source de données Apache Kafka pour Grafana. Chaque message qui arrive passe par quatre étapes :

  • Désérialisation : selon le format (Avro, Protobuf, JSON, plaintext).
  • Aplatissement : transformation des JSON imbriqués en colonnes.
  • Conversion en séries temporelles : les champs numériques deviennent des métriques, les chaînes des étiquettes.
  • Construction d’un data frame : une ligne par message, envoyé via WebSocket au navigateur.

Le problème, c’est que chaque opération est exécutée une fois par message. Une microseconde gaspillée ici est multipliée par le débit complet du topic.

Pourquoi ne pas commencer par optimiser directement ?

Parce que vous auriez tort, comme je l’étais. Si vous m’aviez demandé où se cachait le goulot d’étranglement, j’aurais parié sur le décodage des bytes. Le profileur disait l’inverse : le décodage était quasi gratuit. C’était la compilation des schémas qui bouffait tout le temps CPU, répétée pour chaque message.

A Lire :  Quilbot : Avis Complet, Test Pratique & Guide d'Utilisation 2026

Voici ma boucle en quatre étapes :

  1. Benchmark : quelle est la vitesse actuelle, en chiffres reproductibles ?
  2. Profilage : quelle ligne de code est responsable ?
  3. Correction : ne modifiez que ce que les données justifient.
  4. Preuve : la correction est-elle significative au-delà du bruit de mesure ?

Sautez l’étape 4, et vous ne faites pas de l’optimisation, mais de l’astrologie de performance.

Étape 0 : assainir le benchmark

Avant toute chose, un petit nettoyage. Le plugin appelait log.DefaultLogger.Debug(...) sur chaque message. En production, ça passe. Dans un benchmark qui exécute la même fonction plusieurs millions de fois, ça noyait complètement les mesures. Mes premiers résultats mesuraient surtout la vitesse du terminal.

Un simple log.SetOutput(ioutil.Discard) dans un TestMain() a tout réglé. Les logs de débogage ne sont pas gratuits non plus en production : avant de faire confiance à vos benchmarks, vérifiez ce que fait votre code en dehors du chemin critique.

Étape 1 : des benchmarks qui collent à la réalité

J’ai écrit des micro-benchmarks pour isoler chaque fonction : DecodeAvroMessage, DecodeProtobufMessage, ParseProtobufSchema. Mais les micro-benchmarks mentent par omission. J’ai donc aussi benchmarké le chemin complet, pour tous les formats supportés, avec deux modes de sortie : noop et sendframe_json.

Le résultat s’obtient avec une commande Go classique :

go test -bench=BenchmarkWorkflow -run '^$' -benchmem

Le paramètre -benchmem ajoute les allocations mémoire, souvent plus parlantes que le simple temps CPU.

Étape 2 : profilage et lecture des profils

Ne vous arrêtez pas à la première fonction dans go tool pprof -top. Cherchez la ligne précise avec -list. Et distinguez alloc_objects du alloc_space : un million de micro-allocations ne pèsent pas lourd sur la mémoire totale, mais détruisent le garbage collector.

A Lire :  Gemini sur Android Auto : L'IA hallucine, l'utilisateur trinque

Correctif n°1 : le codec Avro recompilé à chaque message

Le problème. DecodeAvroMessage appelait goavro.NewCodec(schema) pour chaque message, ce qui parse le schéma JSON et construit un codec interne. Le schéma ne change jamais entre deux messages du même topic, mais on le reconstruisait à l’infini.

La correction. Un cache LRU limité à 256 entrées suffit : le codec est immuable et thread-safe. Résultat : débit multiplié par 4,3 (de 152k à 654k msg/s).

Correctif n°2 : la compilation du schéma Protobuf répétée aussi

Même type de bug. ParseProtobufSchema exécutait protocompile.Compiler.Compile(...) à chaque appel. C’est une compilation complète du fichier .proto.

La correction. Même cache LRU, avec une fonction helper dédiée. Résultat : de 52k à 656k msg/s, soit un gain de 12,5x — le plus important de toute l’étude. Les benchmarks JSON et plaintext (groupe de contrôle) n’ont pas bougé, validant ainsi la mesure.

Correctif n°3 : le tri des colonnes redondant

Pour chaque message, on triait les clés des champs avec sort.Strings(keys) afin d’avoir un ordre stable. Si le schéma ne change pas, c’est inutile.

La correction. Mise en cache de l’ordre trié et réutilisation si le jeu de clés est identique. Gain de 8,1 % (p=0,002, significatif). Pas un 12x, mais c’est réel.

Correctif n°4 : unité de travail inadaptée

Le problème fondamental : un data frame par message signifie que chaque envoi WebSocket comprend le schéma complet pour une seule ligne. À haut débit, le schéma pèse plus lourd que les données.

La correction. Un système de micro-batching qui accumule jusqu’à 32 frames compatibles avant d’envoyer un multi-frame. Deux triggers : nombre de lignes (32) ou délai maximal (5 ms).

A Lire :  PReS Connect en 2026 : Le Guide Ultime pour Automatiser vos Communications Clients

Résultats : débit quasi doublé, et surtout trafic réseau réduit de 86 % pour le format json_100fields. La métrique clé : 22,58 % du CPU passé dans FrameToJSON tombe à 2,05 %. Le coût : un peu plus d’allocations mémoire (+4,32 %), mais beaucoup moins de données envoyées.

Étape 4 : la preuve par benchstat

Un seul run de benchmark ne prouve rien. benchstat compare statistiquement plusieurs runs et donne une p-value. Avec -count=6, on obtient une image fiable. Sans ça, on risque de croire qu’un coup de chance est une optimisation.

Feature flags : la cerise sur le gâteau

Chaque correction est cachée derrière un flag d’environnement. Par défaut, le plugin est en mode optimisé. En positionnant un flag, on revient au comportement d’origine, sans git checkout ni recompilation. Avantages :

  • Reproductibilité : tout le monde peut vérifier les gains.
  • Échappatoire : si un cache bug chez un client, on peut le désactiver à distance.
  • Benchmarks faciles : plus besoin de deux branches Git.

Ce que je n’ai pas corrigé (à dessein)

Le profilage montrait que FieldBuilder.AddValueToFrame était le plus gros résidu coûtant 40 % du temps de ProcessMessage. Le coupable : trois allocations par champ pour gérer la nullabilité. Je ne les ai pas supprimées, car Grafana Live exige une stabilité de schéma. Sans un harnais de test de bout en bout, je ne peux pas garantir qu’un passage en non-nullable ne briserait rien.

Les 4 corrections combinées doublent le débit géométrique moyen en mode sendframe_json, sans toucher au line protocol (groupe de contrôle parfait).

Le résumé en quatre points

  1. Benchmark : sans chiffre reproductible, vous n’avez que des opinions.
  2. Profilage : cherchez la ligne, pas la fonction.
  3. Correction : prouvez-la avec benchstat -count=6.
  4. Feature flag : gardez l’ancien comportement accessible d’une variable d’environnement.

Quel travail redondant se cache sur votre chemin critique ? Une recompilation, un re-parse ou un re-tri par requête ? Votre unité de travail est-elle la bonne ? Et la dernière fois que vous avez affirmé un gain de performance, aviez-vous une p-value ?