Le chemin de redirection d'un réducteur d'URL n'a qu'une seule mission : résoudre un slug vers une destination et renvoyer un 301 en quelques millisecondes. Tout le reste n'est que de la comptabilité. Analyses de clics, attribution, enrichissement géo, score de fraude, fan-out de webhook — rien de tout cela ne peut se trouver sur le chemin de la requête. Le budget de latence ne le permet pas.
C'est l'astuce d'ingénierie qui permet au pipeline analytique de coexister avec la pierre angulaire du p95 de redirection < 15ms : l'edge émet un événement de clic dans Redpanda et l'oublie. Un worker séparé — click-ingester — le récupère plus tard, l'enrichit et l'écrit dans ClickHouse par lots. Le processus de redirection ne bloque jamais. Le pipeline analytique ne touche jamais au chemin critique. Le compromis est la durabilité, et c'est un compromis moins important qu'il n'y paraît au premier abord.
Ce que signifie réellement « fire and forget » ici#
Le gestionnaire edge-redirect, après avoir extrait l'URL de destination du cache à deux niveaux, fait trois choses avant que le header Location ne parte :
- Construit une structure
click.Eventen mémoire à partir de la requête (slug, ID de l'espace de travail, user agent, referer, IP, géo à partir du mmdb GeoLite2-City local, analyse de l'appareil/navigateur, drapeaux de suspicion). - Appelle
producer.Emit(ctx, event)sur le producteur Kafka franz-go. - Écrit
HTTP/1.1 301et le headerLocationdans le tampon de réponse.
L'appel au producteur revient immédiatement. Il n'attend pas d'ack de la part d'un broker Redpanda. La bibliothèque franz-go met l'enregistrement en tampon dans le processus et l'envoie sur une goroutine en arrière-plan ; le callback de production est invoqué plus tard, sur un pool de workers qui ne possède pas la goroutine de la requête. Si la production échoue, le callback consigne l'erreur et l'événement est abandonné. La redirection a déjà été servie.
func (p *Producer) Emit(ctx context.Context, e Event) {
if p == nil {
return
}
b, err := json.Marshal(e)
if err != nil {
p.log.Warn("click marshal", zap.Error(err))
return
}
rec := &kgo.Record{Topic: p.topic, Value: b}
p.client.Produce(ctx, rec, func(_ *kgo.Record, err error) {
if err != nil && p.log != nil {
p.log.Warn("click produce", zap.Error(err))
}
})
}
C'est l'intégralité de l'interface. Pas de file d'attente de relecture à l'intérieur du processus edge, pas d'attente d'ack synchrone, pas de spool sur disque. Le contrat avec le reste du système est simple : émission au mieux, journalisation des échecs, ne jamais bloquer.
Une garde sur le récepteur nul permet au développement local de fonctionner sans broker Kafka. Sans cela, chaque contributeur aurait besoin d'un conteneur Redpanda en cours d'exécution juste pour tester le chemin de redirection par rapport aux gestionnaires fasthttp.
Pourquoi nous n'avons pas choisi l'écriture synchrone#
L'alternative évidente est d'écrire chaque clic directement dans ClickHouse depuis l'edge. Nous l'avons envisagé. Nous l'avons rejeté pour trois raisons qui se cumulent.
Latence. L'aller-retour INSERT ClickHouse depuis le POP de Francfort vers un cluster ClickHouse de la même région se situe entre 3 et 6ms p50 sur un réseau calme, et 12-20ms p95 sous charge. C'est l'intégralité du budget de redirection. L'ajouter au chemin de réponse ferait passer le p95 au-delà de l'SLO de 15ms avant même que quoi que ce soit d'autre ne tourne mal. Le billet sur la stratégie de cache explique à quel point le budget est serré en pratique.
Contre-pression (Backpressure). ClickHouse est à l'aise avec l'ingestion de lots de 1000 à 10000 lignes par INSERT. Il est mal à l'aise avec l'ingestion de lignes uniques dans des boucles serrées — le moteur MergeTree écrit un fichier de partie par insertion et un processus en arrière-plan fusionne les parties. Un modèle d'écriture directe à partir d'une flotte edge multi-région créerait des millions de petites parties et la file d'attente de fusion ne rattraperait jamais son retard. La documentation de ClickHouse est explicite : insérez par lots d'au moins 1000 lignes, pas plus d'une fois par seconde.
Isolation des pannes. Un redémarrage du cluster ClickHouse, un problème réseau ou une requête lente qui verrouille un réplica se propagerait directement en échecs de redirection. Le processus edge commencerait soit à expirer (aggravant le p95), soit à abandonner des clics (dégradant la qualité des données). Placer un bus de messages entre les deux permet à chaque côté d'échouer indépendamment — l'edge continue de rediriger même lorsque ClickHouse est dégradé, et ClickHouse continue d'ingérer même lorsqu'un POP est hors ligne.
Redpanda absorbe ces trois pressions. Il est compatible avec le protocole Kafka, donc franz-go communique avec lui de manière transparente. Il a une empreinte binaire unique sans JVM. Il met en tampon sur disque, de sorte qu'une panne de ClickHouse de plusieurs heures ne fait pas perdre d'événements tant que la fenêtre de rétention du topic tient le coup.
Le worker click-ingester#
click-ingester est un service Go qui fonctionne comme un groupe de consommateurs sur le topic des événements de clic. Un réplica par région, trois régions, pas de partitionnement par slug ou espace de travail — le groupe de consommateurs se rééquilibre si un réplica redémarre et les partitions sont assignées par Redpanda. Le travail du consommateur est limité :
- Récupère les données du topic via des polls.
- Décode le JSON de chaque enregistrement dans un
Eventtypé. - Pousse l'événement dans le tampon en mémoire d'un écrivain.
- Parfois : déclenche des webhooks, transfère vers Klaviyo / Mixpanel / GA4 MP, publie dans le flux de clics en direct de l'application.
L'écrivain regroupe par nombre ou par temps, selon la première éventualité. Valeurs par défaut : 1000 événements par lot, intervalle de vidage de 5 secondes. Un lot est construit dans un appel PrepareBatch INSERT INTO click_events contre ClickHouse et validé comme un seul ajout côté serveur. En cas de succès, l'écrivain marque les offsets des enregistrements Kafka sous-jacents comme validés ; en cas d'échec, rien n'est validé et le consommateur récupère à partir du dernier offset réussi lors de son prochain poll.
Le contrat offset-après-vidage est la garantie de durabilité. Le consommateur ne dit jamais à Redpanda « j'ai traité cet enregistrement » tant que l'enregistrement n'est pas arrivé dans ClickHouse dans le cadre d'un lot réussi. Un crash entre la consommation et le vidage signifie que le groupe de consommateurs se rééquilibre, que le nouveau propriétaire effectue un poll à partir du dernier offset validé et que les événements sont retraités. Le retraitement est sûr car la table click_events est un ReplacingMergeTree indexé sur un ID d'événement synthétique — les insertions en double fusionnent lors de la fusion.
Les mauvais messages ne sont pas retestés. Un échec de décodage JSON est marqué comme validé immédiatement afin que le consommateur ne reste pas bloqué sur un enregistrement empoisonné. C'est une source petite mais réelle de perte de données ; le taux se situe à quelques événements isolés par jour sur l'ensemble de la flotte, et les événements concernés apparaissent dans le compteur Prometheus decode_error_total du consommateur.
Le compromis de durabilité en chiffres#
Le mode fire-and-forget abandonne certains événements. La question est de savoir combien, et si cela a de l'importance pour le cas d'utilisation.
Nous avons mesuré le taux de perte en production sur une fenêtre de 90 jours. Le chiffre est d'environ 0,04 % des événements émis — environ quatre clics perdus sur dix mille. La répartition :
- Redémarrage du processus edge avec tampon en vol. franz-go met en tampon jusqu'à quelques centaines de millisecondes d'enregistrements avant de les vider vers un broker. Un SIGTERM pendant un déploiement peut abandonner ce qui se trouve dans le tampon. Le script de déploiement émet un arrêt propre qui vide le tampon avec un timeout de 2 secondes, ce qui gère la plupart des cas mais pas tous.
- Indisponibilité du broker Redpanda au-delà de la fenêtre de tentative du producteur. franz-go retente les échecs de production, mais le budget de tentative est limité. Si le cluster Redpanda d'une région est instable pendant plus de 30 secondes environ, le tampon déborde et les nouveaux enregistrements sont abandonnés à la périphérie du producteur.
- Partition réseau entre le POP edge et le cluster Redpanda régional. Même effet que ci-dessus. Le producteur consigne des avertissements et abandonne les événements jusqu'au retour de la connectivité.
Pour la charge de travail d'un réducteur d'URL, une perte de 0,04 % est acceptable. Les clics sont des signaux statistiques, pas des transactions financières. L'analyse de cohorte, l'attribution de conversion et la distribution géographique s'agrègent bien sur un échantillon avec ce taux d'échec. Les cas d'utilisation qui ne le toléreraient pas — industries réglementées avec des exigences d'audit, décomptes de clics liés à la facturation — ne sont pas ce que le niveau de redirection sert directement.
Pour les espaces de travail qui nécessitent une plus grande durabilité, nous proposons un mode journal d'audit séparé qui écrit chaque clic de manière synchrone dans Postgres en plus du chemin fire-and-forget. L'écriture synchrone ajoute 3-5ms p95 à la redirection, sur option, désactivée par défaut. Le guide d'exportation ClickHouse documente la forme du journal d'audit pour les équipes de conformité qui doivent réconcilier les décomptes.
Stratégie de relecture lorsque ClickHouse est en panne#
Le producteur est en mode fire-and-forget, mais le côté consommateur a une véritable histoire de relecture.
Lorsque ClickHouse est indisponible, les appels de vidage de l'écrivain échouent. Le consommateur continue de poller — la boucle de poll de franz-go est indépendante de la boucle de vidage de l'écrivain — mais les offsets ne sont pas validés car le vidage n'a pas réussi. La rétention de Redpanda est fixée à 72 heures, ce qui est la durée maximale de panne tolérable avant que les événements ne commencent à expirer.
Lors d'une panne réelle (nous en avons eu trois d'une durée significative en 18 mois), la séquence de récupération est la suivante :
- ClickHouse revient en ligne.
- La prochaine tentative de vidage réussit et valide les offsets.
- Le consommateur rattrape son retard en vider le backlog au taux de lot configuré. Avec un lot de 1000 événements et un vidage toutes les 5 secondes, le consommateur peut vider environ 200 événements par seconde par réplica ; trois réplicas signifient environ 36k événements par minute.
- Le tableau de bord Grafana pour la table
click_eventsmontre la courbe de rattrapage — le taux d'insertion de lignes reste élevé jusqu'à ce que le backlog soit vidé.
La rétention de 72 heures est dimensionnée pour absorber une reconstruction de ClickHouse de plusieurs jours sans perte de données. Nous n'en avons jamais utilisé plus de 4 heures en production. Le disque sur les brokers Redpanda est le coût, et il est faible par rapport à la perte de données analytiques.
Une relecture à partir des archives est également possible. Redpanda dispose d'un stockage hiérarchisé envoyant des segments fermés vers un stockage d'objets compatible S3. Nous l'avons configuré mais n'en avons pas eu besoin — la relecture à chaud couvre chaque incident que nous avons vu.
Ce que fait aussi le consommateur#
L'ingestion de clics ne se résume pas à des écritures dans ClickHouse. Le consommateur est le point central de fan-out pour chaque système en aval qui s'intéresse aux clics.
- Répartiteur de webhooks. Les webhooks configurés par le client sont déclenchés depuis le consommateur, pas depuis l'edge. Le consommateur met en file d'attente un travail de webhook par clic correspondant à un filtre configuré. Les tentatives, la signature et la livraison se produisent dans
webhook-dispatcher. - Transfert d'événements côté serveur. Klaviyo, Mixpanel, GA4 Measurement Protocol, Meta CAPI. Le consommateur détient un cache de configuration par espace de travail et déclenche le POST approprié pour chaque clic que l'espace de travail a configuré. Les transferts se font au mieux avec une petite tentative en mémoire ; les échecs persistants atterrissent dans une table de lettres mortes.
- Flux de clics en direct. La vue « suivre une campagne en direct » de l'application s'abonne à un canal Redis pub/sub. Le consommateur publie un événement de forme minimale pour chaque clic correspondant à une session en direct active. C'est la seule partie du pipeline qui semble synchrone, et elle se fait au mieux — abandon des événements lorsque le canal est encombré.
- Déclenchement de pixels. Les pixels de conversion (reciblage et conversion hors ligne) sont déclenchés depuis le consommateur en fonction de la configuration par lien. Le déclenchement de pixels est son propre domaine de faille ; les échecs sont consignés mais ne créent pas de contre-pression sur l'écrivain ClickHouse.
Tout cela s'exécute après la validation de l'offset mais avant le prochain poll. Un endpoint de pixel lent peut ralentir le débit effectif du consommateur. Un timeout par transféreur (limite stricte d'une seconde) et une limite de simultanéité par lot (16 en vol) empêchent le chemin lent de dominer.
Pourquoi cette forme et non Kinesis ou une file d'attente#
Quelques formes alternatives de bus d'événements ont été évaluées et non retenues.
SQS ou RabbitMQ comme file d'attente. Aucun des deux n'offre le débit par broker que propose Redpanda au volume d'événements de clic. SQS facture à la requête, ce qui rend les flux à haut volume coûteux ; RabbitMQ exerce une contre-pression sur les topics denses.
AWS Kinesis. Raisonnable si nous résidions sur AWS. Nous ne le sommes pas — Hetzner FRA, Hetzner ASH, OVH SGP. Le Kafka ou Redpanda auto-hébergé est la bonne forme pour un déploiement axé sur l'UE.
Kafka standard. Fonctionne. Nous avons choisi Redpanda pour son profil opérationnel — binaire unique, pas de Zookeeper, pas de réglage JVM. Le protocole filaire est identique et franz-go ne peut pas faire la différence. Un déploiement Elido auto-hébergé peut remplacer Redpanda par Apache Kafka sans changement de code.
Services managés comme Confluent Cloud. Pas résidents dans l'UE de la manière que nous souhaitons. Le niveau de redirection nécessite une latence de bus de messages dans la même région.
La décision est documentée plus en détail dans la page d'architecture edge-redirect, qui est la source de vérité pour les choix de configuration du niveau de redirection.
Ce que nous ferions différemment la prochaine fois#
Le modèle fire-and-forget est correct. L'implémentation présente des aspérités qu'il vaut la peine de signaler pour quiconque copierait la conception.
Vidage à l'arrêt. Le timeout de vidage de 2 secondes de franz-go a perdu des événements lors des déploiements lorsque le tampon est occupé. La solution est un hook SIGTERM qui vide de manière synchrone avant que le processus ne quitte, avec un timeout plus long et un arrêt forcé si le broker est injoignable.
Chemin de lettres mortes pour les échecs de décodage. Marquer les enregistrements empoisonnés comme validés et passer à autre chose est correct pour le débit, mais fait perdre en observabilité. Une future itération écrira les octets bruts ainsi que l'erreur de décodage dans une table click_events_decode_failures afin que l'équipe puisse auditer ce qui s'y trouve.
Simultanéité des transféreurs par espace de travail. Aujourd'hui, les transféreurs de chaque espace de travail partagent le pool global du consommateur. Un espace de travail bruyant avec un endpoint Mixpanel lent peut affamer les autres. Un plafond par espace de travail est la solution évidente ; nous ne l'avons pas encore construit.
Aucun de ces problèmes n'a causé d'incident de production. C'est le genre de choses que l'on consigne dans le backlog ADR et que l'on traite petit à petit.
Lectures connexes#
- Atteindre un p95 < 15ms pour les redirections depuis FRA, ASH et SGP — la pièce maîtresse sur le budget de latence à laquelle ce post se rattache.
- Stratégie de cache pour les redirections d'URL : L1 LRU et L2 Redis — l'autre moitié de l'histoire du chemin critique.
- Pourquoi nous utilisons ClickHouse pour l'analyse des clics (et non Postgres) — la décision en aval de ce pipeline.
- Les liens intelligents expliqués — ce vers quoi le champ d'URL de destination se résout réellement avant que l'événement de clic ne soit émis.
- Les liens courts en tant que Terraform — présentation opérationnelle de la configuration du niveau de redirection.
- Câbler Sentry à travers 12 services Go — le chemin de capture des paniques et des erreurs 5xx qui s'exécute parallèlement au consommateur.
- Architecture :
/docs/architecture/edge-redirect. - Guide opérationnel :
/docs/guides/clickhouse-export— le mode journal d'audit pour les espaces de travail qui nécessitent une durabilité par clic. - Externe : Redpanda tiered storage, ClickHouse bulk inserts, fasthttp.