Vous avez une barre de recherche produit qui est « correcte » jusqu’au jour où elle ne l’est plus. Soudain, des clients ne trouvent
pas ce qu’ils viennent de créer.
Le support ouvre des tickets. La direction demande pourquoi les résultats de recherche sont « aléatoires ».
L’équipe d’ingénierie invoque la « cohérence éventuelle ». Et l’équipe SRE se retrouve coincée au milieu, tenant le pager et un postmortem à moitié rédigé.
Le vrai problème n’est généralement pas de choisir PostgreSQL ou OpenSearch. C’est de déployer une architecture hybride avec de mauvaises
délimitations : confondre exactitude et pertinence, greffer l’indexation sans gestion du backpressure, et traiter la « recherche » comme une seule
fonctionnalité au lieu de deux systèmes avec deux contrats différents.
Ce que « recherche hybride » signifie réellement (et ce que ce n’est pas)
« Recherche hybride » est une expression surchargée. Dans les slides des fournisseurs, elle signifie souvent mélanger recherche vectorielle et par mots-clés. Dans les systèmes de production,
elle signifie le plus souvent : PostgreSQL reste le système de référence, OpenSearch sert les requêtes de recherche, et vous
exécutez un pipeline pour les tenir suffisamment synchrones pour que les utilisateurs fassent confiance aux résultats.
Cette partie « suffisamment synchrone » est tout le travail. Si vous ne pouvez pas la définir — par les attentes produit et le budget opérationnel — vous finirez
par vous disputer sur l’exactitude pendant les incidents, ce qui est un moment terrible pour découvrir que vos définitions étaient seulement des impressions.
La recherche hybride, ce sont deux problèmes, pas un
- Exactitude : « Cet objet peut-il être renvoyé ? Existe-t-il ? L’utilisateur est-il autorisé ? »
- Pertinence : « Parmi les objets autorisés, lesquels sont les meilleures correspondances et comment sont-ils classés ? »
PostgreSQL est très bon pour l’exactitude et les contraintes relationnelles. OpenSearch est très bon pour la pertinence et la récupération
sur de grands champs textuels avec un classement flexible. Quand les équipes essaient de faire faire les deux à un seul système, elles en paient le prix en latence,
complexité, ou perte de confiance. Souvent les trois.
Deux blagues, parce que la production a besoin d’humour
Blague 1 : La recherche n’est qu’une requête de base de données avec des opinions. Malheureusement, ces opinions ont tendance à avoir des exigences de disponibilité.
PostgreSQL vs OpenSearch : contrats différents, modes de panne différents
Le contrat de PostgreSQL
PostgreSQL promet l’intégrité transactionnelle, des sémantiques prévisibles, et un planificateur de requêtes qui fera de son mieux — jusqu’à ce que vous
lui donniez une requête taillée comme un artefact maudit. C’est votre source de vérité. C’est là que les données sont écrites, validées,
dédupliquées et gouvernées.
PostgreSQL peut faire de la recherche en texte intégral. Pour de nombreuses charges, c’est suffisant. Mais il a des limites : la racinisation et le classement sont
moins flexibles ; le scale horizontal demande plus de travail ; le réglage de pertinence est possible mais délicat ; et vous souffrirez lorsque votre produit commencera à exiger une « recherche comme une appli grand public » au‑dessus de schémas relationnels.
Le contrat d’OpenSearch
OpenSearch (et sa lignée Elasticsearch) est un moteur de recherche distribué conçu pour la récupération et le classement à grande échelle.
Il accepte volontiers des documents dénormalisés, tokenize le texte, calcule des scores de pertinence, et répond rapidement à des requêtes complexes — à condition que vous lui fournissiez des mappings stables, contrôliez votre stratégie de shards, et évitiez de l’utiliser comme base de données transactionnelle.
OpenSearch ne promet pas la cohérence transactionnelle avec votre base de données principale. Il promet un indexage quasi‑temps réel
et une durabilité au niveau du cluster basée sur la réplication, la fusion de segments, et ses propres journaux. Si vous avez besoin d’un « lecture‑après‑écriture » correct à travers les systèmes, vous devez le concevoir.
Faits intéressants et contexte (parce que l’histoire prédit les incidents)
- L’ancêtre de PostgreSQL remonte au projet POSTGRES à UC Berkeley dans les années 1980 ; son biais vers l’exactitude et l’extensibilité n’est pas un hasard.
-
La recherche en texte intégral dans PostgreSQL est devenue une fonctionnalité à part entière au milieu des années 2000, et elle a mûri régulièrement — mais c’est toujours
une fonctionnalité de base de données relationnelle, pas l’identité centrale d’un moteur de recherche. - Lucene (la bibliothèque sous-jacente derrière Elasticsearch et OpenSearch) a démarré autour de 1999 ; ses hypothèses de conception sont « documents et index inversés », pas « tables et jointures ».
- L’indexation quasi‑temps réel est un compromis délibéré : les documents deviennent recherchables après refresh, pas immédiatement après l’écriture, sauf si vous payez pour des refreshs plus fréquents.
- La suppression du concept de « type » dans Elasticsearch (ère 7.x) a forcé de nombreuses équipes à affronter la conception du schéma/mapping ; OpenSearch en hérite : les mappings sont un contrat, et les casser coûte cher.
- La pratique courante d’utiliser des « suppressions logiques au niveau application » (un booléen) peut silencieusement empoisonner la pertinence et l’exactitude si les filtres ne sont pas appliqués de manière cohérente dans les deux systèmes.
- Le pattern outbox a gagné en popularité après que des équipes aient été brûlées par les problèmes de double‑écriture ; c’est désormais une réponse par défaut pour « garder deux systèmes synchrones » quand on tient à ne pas perdre d’écritures.
- Les clusters de recherche tombent en panne de façons étrangement physiques : un seuil disque met en lecture seule, les merges de segments saturent l’IO, et un changement de mapping « simple » peut provoquer des coûts massifs de reindexation.
Si vous ne retenez rien d’autre : PostgreSQL est votre grand livre. OpenSearch est votre catalogue. N’essayez pas de payer vos impôts avec votre catalogue.
Règles de décision : quand interroger Postgres, quand interroger OpenSearch
Utilisez PostgreSQL lorsque
- Vous avez besoin d’exactitude stricte : facturation, permissions, conformité, déduplication, idempotence.
- Vous avez des contraintes relationnelles et des jointures difficiles à aplatir sans risque de corruption.
- Vous filtrez et triez sur des champs structurés avec des index sélectifs.
- Votre « recherche » est en réalité une liste filtrée avec un léger appariement textuel et un volume prévisible.
Utilisez OpenSearch lorsque
- Vous avez besoin de classement par pertinence, de fuzziness, de synonymes, de stemming, de boosting, de « voulez‑vous dire », ou de requêtes textuelles multi‑champs.
- Vous avez besoin d’accès rapide à de grands champs textuels pour de nombreux utilisateurs concurrentiels.
- Vous pouvez accepter la cohérence éventuelle, ou concevoir l’UX/les flux autour de cela.
- Vous avez besoin d’agrégations sur d’énormes jeux de résultats où un moteur de recherche excelle.
La règle hybride qui vous évite des ennuis
Utilisez OpenSearch pour produire des ID candidats et des scores ; utilisez PostgreSQL pour appliquer la vérité et l’autorisation.
Cela signifie que votre API de recherche devient souvent en deux étapes : requête OpenSearch → liste d’IDs → récupération dans Postgres (et filtrage).
Quand la performance compte, vous optimisez soigneusement cette jonction. Quand l’exactitude compte, vous ne la sautez jamais par négligence.
Si vous êtes tenté de stocker les permissions dans OpenSearch et de ne jamais vérifier PostgreSQL : vous construisez un incident de sécurité avec une belle interface.
Une architecture de référence qui survit en production
Les pièces en mouvement
- PostgreSQL : source de vérité. Les écritures s’y produisent. Les transactions ont du sens ici.
- Table outbox : enregistrement durable des « choses à indexer » écrit dans la même transaction que l’écriture métier.
- Worker indexeur : lit l’outbox, récupère l’état complet de l’entité (ou une projection), écrit dans OpenSearch, marque l’outbox comme traité.
- OpenSearch : documents dénormalisés et recherchables. Mappings réglés. Nombre de shards contrôlé.
- API de recherche : interroge OpenSearch pour obtenir des candidats ; hydrate depuis Postgres ; renvoie les résultats.
- Job de backfill/reindex : reconstruction en masse de l’index à partir de snapshots Postgres ou de lectures cohérentes.
- Observabilité : métriques de lag, budget d’erreur, logs de requêtes lentes, débit d’indexation, et santé du cluster.
Pourquoi l’outbox bat « simplement publier un événement »
Le pattern outbox est l’ami peu glamour qui arrive à l’heure. Vous écrivez vos ligne(s) dans la table métier et une
ligne outbox dans la même transaction base de données. Si la transaction commit, la ligne outbox existe. Si elle rollback,
elle n’existe pas. C’est tout l’enjeu : pas de mises à jour perdues à cause d’un « commit DB réussi mais publication Kafka échouée », ou l’inverse.
Un système CDC (replication logique, Debezium, etc.) peut aussi fonctionner. Mais vous devez quand même gérer l’ordre, les suppressions, les changements de schéma,
et les sémantiques de replay. Outbox est plus simple à raisonner pour beaucoup d’équipes, surtout quand l’indexeur doit calculer une projection de toute façon.
Comment gérer les suppressions sans mentir
Les suppressions sont l’endroit où les systèmes hybrides meurent silencieusement. Vous devez décider :
- Suppression dure dans Postgres + suppression du document dans OpenSearch.
- Suppression logique dans Postgres + filtrage dans les deux systèmes + job de purge éventuelle.
La posture opérationnelle la plus sûre est : traiter Postgres comme autorité, toujours. Si OpenSearch renvoie un ID candidat qui n’existe plus ou est supprimé, votre étape d’hydratation le supprime.
C’est moins « efficace » mais beaucoup plus « on peut dormir tranquille ».
Une citation fiabilité (idée paraphrasée)
Idée paraphrasée — John Allspaw : la fiabilité vient de la façon dont les systèmes se comportent sous stress, pas de leur apparence sur les diagrammes.
Modélisation des données : source de vérité, dénormalisation et pourquoi les jointures n’ont pas leur place dans la recherche
Concevez le document OpenSearch comme une projection
Votre document OpenSearch doit être une projection stable de l’entité telle que l’utilisateur la recherche. Cela signifie généralement :
champs aplatis, données répétées (dénormalisées), et quelques champs calculés pour la pertinence (comme « popularity_score »,
« last_activity_at », ou « title_exact »).
La projection doit être générée par du code que vous pouvez versionner et tester. Si votre indexeur fait un « SELECT * and hope »,
vous allez livrer des explosions de mapping et des régressions de qualité.
N’implémentez pas de jointures relationnelles dans OpenSearch sauf si vous aimez les surprises de performance
OpenSearch a des fonctionnalités proches de jointures (nested, parent-child). Elles peuvent être valides. Elles sont aussi faciles à mal utiliser et difficiles à
exploiter à l’échelle. Pour la plupart des recherches produits, vous serez plus heureux en dénormalisant dans un seul document par entité recherchable et en payant le coût d’indexation une fois.
Quand un objet lié change (par ex. le nom d’une organisation), vous réindexez les documents impactés. C’est un coût connu. Vous le budgétez et le limitez.
Vous ne prétendez pas que cela n’arrivera pas.
La stabilité des mappings est une préoccupation SRE
Traitez les mappings comme des migrations de base de données. Un changement de mapping bâclé peut déclencher :
- Conflits de type de champ inattendus (l’index refuse les documents).
- Champs dynamiques implicites qui font exploser la taille de l’index et la pression mémoire.
- Reindexation coûteuse qui entre en concurrence avec le trafic de production.
Configurez le mapping dynamique avec précaution. La plupart des équipes de production ne devraient pas permettre un libre‑cours. « Mais c’est pratique » est comment vous finissez avec un index contenant 40 versions d’un même champ mal orthographié.
Cohérence, latence et le seul SLA qui compte
Définissez la « fraîcheur de recherche » comme un SLO mesurable
Votre vrai SLA est : combien de temps après une écriture un utilisateur peut la trouver via la recherche. Ce n’est pas la même chose que la latence p99 de l’API. C’est un SLO pipeline.
Vous le mesurez comme un lag : timestamp d’écriture vs timestamp d’indexation vs timestamp de disponibilité en recherche. Puis vous décidez ce qui est acceptable
(par ex. 5 secondes, 30 secondes, 2 minutes). Si le produit a besoin de « instantané », concevez l’UX pour compenser l’écart : affichez le nouvel objet
directement après la création, contournez la recherche pour ce flux, et évitez d’apprendre aux utilisateurs que la recherche est la seule vérité.
Intervalle de refresh : le bouton qui coûte de l’argent
Un intervalle de refresh plus bas rend les documents recherchables plus vite, mais augmente le churn de segments et l’IO. Augmenter l’intervalle de refresh améliore
le débit d’indexation et réduit la charge mais rend la recherche moins fraîche. Réglez-le en fonction des attentes utilisateur et du volume d’écritures. Et : ne le touchez pas pendant un incident à moins de comprendre les effets en aval.
Garde‑fous d’exactitude
- Filtrage d’autorisation : appliquez lors de l’hydratation Postgres, ou répliquez les règles d’authentification avec soin et testez-les.
- Éléments supprimés/cachés : filtrez partout ; traitez Postgres comme vérité finale.
- Frontières multi‑locataires : tenant_id doit être un filtre de première classe dans les requêtes OpenSearch et une clé dans la récupération Postgres.
Blague 2 : La cohérence éventuelle, c’est génial jusqu’à ce que votre CEO recherche ce qu’il vient de créer cinq secondes plus tôt. Là, ça devient « une panne ».
Tâches pratiques (commandes + sorties + décisions)
Ce ne sont pas des commandes jouets. Ce sont celles que vous exécutez quand la recherche est lente, périmée, ou menteuse — et que vous devez décider quoi faire ensuite. Chaque tâche inclut : commande, ce que la sortie signifie, et la décision à prendre.
Tâche 1 : Vérifier la réplication / la pression d’écriture Postgres (sommes‑nous à la traîne avant même que l’indexation commence ?)
cr0x@server:~$ psql -d appdb -c "select now(), xact_commit, xact_rollback, blks_read, blks_hit from pg_stat_database where datname='appdb';"
now | xact_commit | xact_rollback | blks_read | blks_hit
-------------------------------+-------------+---------------+-----------+----------
2025-12-30 18:41:12.482911+00 | 19403822 | 12031 | 3451201 | 98234410
(1 row)
Signification : Si les commits montent en flèche mais que les hits cache chutent (blks_read augmente), vous effectuez plus de lectures physiques — souvent signe de pression IO.
Décision : Si l’IO est chaude, mettez en pause les reindex/backfill, vérifiez les index, et envisagez des replicas en lecture pour la charge d’hydratation.
Tâche 2 : Trouver les requêtes Postgres qui dominent le temps (l’étape d’hydratation fait souvent cela)
cr0x@server:~$ psql -d appdb -c "select query, calls, total_time, mean_time, rows from pg_stat_statements order by total_time desc limit 5;"
query | calls | total_time | mean_time | rows
----------------------------------------------------+--------+------------+-----------+--------
select * from items where id = $1 and tenant_id=$2 | 982144 | 842123.11 | 0.857 | 982144
select id from items where tenant_id=$1 and ... | 1022 | 221000.44 | 216.243 | 450112
...
(5 rows)
Signification : L’hydratation par clé primaire devrait être rapide. Si elle domine le temps total, vous le faites peut‑être trop souvent, ou vous manquez d’index sur (tenant_id, id).
Décision : Batcher l’hydratation (WHERE id = ANY($1)) et ajouter des index composites alignés sur les frontières de locataire.
Tâche 3 : Confirmer que l’index Postgres critique existe et est utilisé
cr0x@server:~$ psql -d appdb -c "explain (analyze, buffers) select * from items where tenant_id='t-123' and id=987654;"
QUERY PLAN
------------------------------------------------------------------------------------------------------------------------
Index Scan using items_tenant_id_id_idx on items (cost=0.42..8.44 rows=1 width=512) (actual time=0.041..0.042 rows=1 loops=1)
Index Cond: ((tenant_id = 't-123'::text) AND (id = 987654))
Buffers: shared hit=5
Planning Time: 0.228 ms
Execution Time: 0.072 ms
(6 rows)
Signification : Index Scan + faible temps d’exécution + buffers hit signifie que l’hydratation est saine pour ce chemin.
Décision : Si vous voyez Seq Scan, corrigez l’indexation ou réduisez la largeur des lignes récupérées (SELECT des colonnes nécessaires seulement).
Tâche 4 : Inspecter le lag de l’outbox (le SLO de fraîcheur en une requête)
cr0x@server:~$ psql -d appdb -c "select count(*) as pending, max(now()-created_at) as max_lag from search_outbox where processed_at is null;"
pending | max_lag
---------+-------------
18234 | 00:07:41.110
(1 row)
Signification : 18k en attente et lag max ~8 minutes : le pipeline d’indexation est en retard. Les utilisateurs vont le remarquer.
Décision : Scalez les workers indexeurs, vérifiez la latence d’ingest OpenSearch, et assurez‑vous que les consommateurs outbox ne sont pas bloqués sur des poison pills.
Tâche 5 : Détecter les enregistrements outbox poison‑pill (retries bloqués)
cr0x@server:~$ psql -d appdb -c "select id, entity_id, attempts, last_error, updated_at from search_outbox where processed_at is null and attempts >= 10 order by updated_at asc limit 10;"
id | entity_id | attempts | last_error | updated_at
------+-----------+----------+---------------------------------+----------------------------
9981 | 7712331 | 14 | mapping conflict on field tags | 2025-12-30 18:20:01+00
...
(10 rows)
Signification : Des erreurs répétées de conflit de mapping ne sont pas « transitoires ». Ce sont des bugs de schéma.
Décision : Quarantainez ces enregistrements, corrigez la projection/mapping, puis reprocesser après un correctif contrôlé.
Tâche 6 : Vérifier la santé du cluster OpenSearch (red/yellow n’est pas « juste une couleur »)
cr0x@server:~$ curl -s http://opensearch.service:9200/_cluster/health?pretty
{
"cluster_name" : "search-prod",
"status" : "yellow",
"number_of_nodes" : 6,
"active_primary_shards" : 120,
"active_shards" : 232,
"unassigned_shards" : 8,
"initializing_shards" : 0,
"relocating_shards" : 2
}
Signification : Yellow signifie que les shards primaires sont alloués mais que les réplicas ne le sont pas totalement. Vous avez une redondance réduite ; la performance peut être affectée pendant la récupération.
Décision : Si le statut jaune persiste, investiguez l’allocation de shards, les watermarks disque, et la capacité des nœuds avant d’augmenter le trafic de requête.
Tâche 7 : Identifier les indices avec trop de shards (un impôt classique de performance)
cr0x@server:~$ curl -s http://opensearch.service:9200/_cat/indices?v
health status index uuid pri rep docs.count store.size
green open items_v12 a1b2c3d4e5 24 1 88441211 310gb
green open items_v12_alias - - - - -
green open audit_v03 f6g7h8i9j0 6 1 120011223 540gb
Signification : 24 shards primaires pour un index peut être correct — ou être une explosion de shards selon le nombre de nœuds et les patterns de requêtes.
Décision : Si le nombre de shards > (nœuds * 2–4) pour un index chaud unique, planifiez un shrink/reindex avec une taille de shard raisonnable.
Tâche 8 : Vérifier la pression d’indexation OpenSearch (les merges et refreshs tuent‑ils l’ingest ?)
cr0x@server:~$ curl -s http://opensearch.service:9200/_nodes/stats/indices?pretty | head -n 30
{
"cluster_name" : "search-prod",
"nodes" : {
"n1" : {
"name" : "search-n1",
"indices" : {
"refresh" : { "total" : 992112, "total_time_in_millis" : 31212344 },
"merges" : { "current" : 14, "current_docs" : 8812333, "total_time_in_millis" : 92233111 }
}
}
Signification : Une forte concurrence de merges et de grands current_docs signifie que le cluster passe beaucoup de temps à fusionner des segments — souvent lié à l’IO.
Décision : Throttlez l’indexation en masse, augmentez temporairement l’intervalle de refresh (si acceptable), et vérifiez le débit disque/profondeur de queue sur les nœuds de données.
Tâche 9 : Mesurer la latence de recherche au niveau moteur (est‑ce OpenSearch ou votre appli ?)
cr0x@server:~$ curl -s -H 'Content-Type: application/json' http://opensearch.service:9200/items_v12/_search -d '{
"profile": true,
"size": 10,
"query": { "bool": { "filter": [ { "term": { "tenant_id": "t-123" } } ], "must": [ { "match": { "title": "graph api" } } ] } }
}' | head -n 25
{
"took" : 38,
"timed_out" : false,
"hits" : {
"total" : { "value" : 129, "relation" : "eq" },
"max_score" : 7.1123,
"hits" : [
{ "_id" : "987654", "_score" : 7.1123, "_source" : { "title" : "Graph API Gateway" } }
]
}
Signification : « took: 38 » est le temps moteur en millisecondes. Si votre p99 API est à 800ms, votre goulot d’étranglement est probablement l’hydratation, le réseau, la sérialisation ou des appels descendants.
Décision : Utilisez ceci pour arrêter le ping‑pong de reproches. Optimisez la couche réellement lente.
Tâche 10 : Vérifier le mapping d’un champ risqué (éviter les incompatibilités silencieuses keyword/text)
cr0x@server:~$ curl -s http://opensearch.service:9200/items_v12/_mapping?pretty | grep -n "title" -n | head
132: "title" : {
133: "type" : "text",
134: "fields" : {
135: "keyword" : { "type" : "keyword", "ignore_above" : 256 }
136: }
137: },
Signification : title est text avec un sous‑champ keyword. C’est standard : utilisez title pour le full‑text, title.keyword pour les correspondances exactes/le tri (dans la mesure du raisonnable).
Décision : Si vous triez sur title (text), corrigez vos requêtes ; triez sur title.keyword ou sur un champ de tri normalisé.
Tâche 11 : Vérifier les watermarks disque (les clusters de recherche deviennent en lecture seule quand le stockage est serré)
cr0x@server:~$ curl -s http://opensearch.service:9200/_cluster/settings?include_defaults=true | grep -n "watermark" | head -n 20
412: "cluster.routing.allocation.disk.watermark.low" : "85%",
413: "cluster.routing.allocation.disk.watermark.high" : "90%",
414: "cluster.routing.allocation.disk.watermark.flood_stage" : "95%",
Signification : Au flood_stage, les indices peuvent être marqués en lecture seule pour protéger le cluster. Les échecs d’indexation suivront.
Décision : Si vous êtes proche du flood_stage, augmentez le stockage, supprimez les anciens indices, ou réduisez la rétention. Ne faites pas que « retenter ».
Tâche 12 : Confirmer que votre alias pointe vers l’index prévu (les erreurs de reindex ressemblent à des bugs de pertinence)
cr0x@server:~$ curl -s http://opensearch.service:9200/_cat/aliases?v | grep items
alias index filter routing.index routing.search is_write_index
items_current items_v12 - - - true
Signification : L’alias d’écriture et l’alias de lecture doivent être intentionnels. Si votre appli lit items_v11 tandis que les indexeurs écrivent dans items_v12, vous « perdrez » des documents en recherche.
Décision : Corrigez la chorégraphie des alias : écrivez dans le nouvel index, backfill, puis basculez atomiquement l’alias de lecture.
Tâche 13 : Inspecter les thread pools OpenSearch (saturez‑vous les threads de recherche ou d’écriture ?)
cr0x@server:~$ curl -s http://opensearch.service:9200/_cat/thread_pool/search?v
node_name name active queue rejected completed
search-n1 search 18 120 3421 91822311
search-n2 search 17 110 3302 91011210
Signification : File d’attente élevée et rejets croissants indiquent une surcharge ; OpenSearch refuse du travail.
Décision : Réduisez le coût des requêtes (filtres, moins de shards touchés), ajoutez des nœuds, ou implémentez un throttling côté client et des modes de secours.
Tâche 14 : Valider le comportement d’indexation bulk (envoyez‑vous des batches trop gros ?)
cr0x@server:~$ curl -s -H 'Content-Type: application/json' http://opensearch.service:9200/_cat/nodes?v
ip heap.percent ram.percent cpu load_1m load_5m load_15m node.role master name
10.0.2.11 92 78 86 12.11 10.42 9.88 dimr - search-n1
10.0.2.12 89 76 80 11.22 9.90 9.31 dimr - search-n2
Signification : Heap > ~85–90% soutenu est un avertissement. L’ingestion bulk peut déclencher du GC thrash et des pics de latence.
Décision : Réduisez la taille des requêtes bulk, limitez la concurrence, et assurez‑vous que votre nombre de shards ne force pas trop de surcharge par nœud.
Playbook de diagnostic rapide
Quand « la recherche est cassée », vous avez besoin d’un chemin qui évite les débats sans fin. Voici l’ordre qui trouve le goulet d’étranglement rapidement.
Il est optimisé pour les configurations hybrides où Postgres est la vérité et OpenSearch la couche de récupération.
Première étape : est‑ce périmé, incorrect ou lent ?
- Périmé : Nouveaux/objets mis à jour n’apparaissant pas.
- Incorrect : Objets non autorisés/supprimés apparaissant, ou éléments manquants qui devraient correspondre.
- Lent : Pics de latence ou timeouts.
Ce sont des classes de panne différentes. Ne dépannez pas le réglage de pertinence quand votre outbox a 20 minutes de retard.
Deuxième étape : établissez quelle couche est lente
- Mesurez le temps moteur (OpenSearch « took ») pour la même requête.
- Mesurez le temps API pour la requête.
- Mesurez le temps de la requête d’hydratation dans Postgres (EXPLAIN ANALYZE).
Si OpenSearch est rapide mais l’API est lente, le coupable est généralement l’hydratation (N+1), les vérifications d’autorisation, ou la sérialisation.
Si OpenSearch est lent mais Postgres va bien, vous êtes en territoire shards/merges/heap.
Troisième étape : vérifiez le lag du pipeline et les taux d’erreur
- Nombre d’outbox en attente et lag max.
- Logs d’erreur de l’indexeur (conflits de mapping, rejets 429, timeouts).
- Santé du cluster OpenSearch, watermarks disque, rejets de thread pool.
Quatrième étape : décidez d’une mitigation sûre
- Périmé : scalez les indexeurs, throttlez les backfills, corrigez les poison pills, évitez le refresh manuel massif.
- Incorrect : appliquez la vérité Postgres lors de l’hydratation ; resserrez les filtres ; auditez les alias ; validez la propagation des suppressions.
- Lent : réduisez le coût des requêtes, réduisez le nombre de shards touchés (routing/filtres), limitez la concurrence, ajoutez des nœuds en dernier recours.
Erreurs courantes : symptôme → cause racine → correction
1) « Je l’ai créé mais la recherche ne le trouve pas. »
Symptôme : Les éléments nouvellement créés sont introuvables pendant des minutes ; le lien direct fonctionne.
Cause racine : Retard d’indexation (backlog outbox), intervalle de refresh long, ou indexeur throttlé par des rejets OpenSearch.
Correction : Mesurez le lag outbox ; scalez les consommateurs ; réduisez la taille des bulk ; assurez‑vous qu’OpenSearch n’est pas en flood‑stage disque ; définissez l’intervalle de refresh intentionnellement et communiquez le SLO de fraîcheur.
2) « La recherche affiche des éléments d’autres locataires. »
Symptôme : Fuite inter‑locataires dans les résultats — généralement découverte par un client en colère.
Cause racine : Filtre tenant_id manquant dans la requête OpenSearch ou la requête d’hydratation ; alias pointant vers un index mixte ; couche de cache non keyée par locataire.
Correction : Exiger tenant_id dans chaque requête (contrat API) ; ajouter des tests de requêtes ; vérifier la stratégie de partitionnement d’index ; corriger les caches pour inclure les clés de locataire.
3) « OpenSearch est rapide mais l’API est lente. »
Symptôme : OpenSearch took est faible (<50ms) mais p99 API est élevé.
Cause racine : Hydratation N+1, SELECT * large, vérifications de permission par ligne, ou sérialisation lente de payloads gigantesques.
Correction : Batcher l’hydratation avec WHERE id = ANY($1) ; récupérer uniquement les colonnes nécessaires ; précalculer les flags de permission ; imposer des limites strictes sur la taille des résultats et les champs retournés.
4) « L’indexation échoue aléatoirement. »
Symptôme : Certains documents ne sont jamais indexés ; les retries tournent en boucle ; les erreurs semblent inconsistantes.
Cause racine : Conflits de mapping dus à des champs dynamiques ou des types incohérents (string vs array, integer vs keyword).
Correction : Verrouillez les mappings ; validez la sortie de la projection ; quarantainez les poison pills ; reindexez avec le mapping corrigé et une validation stricte des entrées.
5) « On a affiné la pertinence et ça a empiré. »
Symptôme : La qualité des résultats baisse après l’ajout de boosting/synonymes ; le support se plaint de résultats « absurdes ».
Cause racine : Changer les analyseurs sans reindexer, booster des champs bruyants, synonymes trop larges, ou mélanger logique de filtre et scoring.
Correction : Considérez les changements d’analyseur comme des événements de reindex ; validez avec des jeux d’évaluation hors‑ligne ; séparez les filtres (bool.filter) du scoring (bool.must/should).
6) « Le cluster est passé en lecture seule et l’indexation est morte. »
Symptôme : Les requêtes bulk commencent à échouer ; les logs mentionnent des blocages en lecture seule.
Cause racine : Le watermark flood_stage disque a été atteint.
Correction : Libérez du disque (supprimez les anciens indices), ajoutez de la capacité, réduisez la rétention ; puis retirez les blocages en lecture seule après résolution de la capacité — pas avant.
7) « Le reindex a fait tomber la recherche. »
Symptôme : Pics de latence et timeouts pendant le backfill ; CPU/IO du cluster au maximum.
Cause racine : Reindex en concurrence avec le trafic live ; trop de concurrence ; intervalle de refresh trop bas ; nombre de shards trop élevé.
Correction : Throttlez l’ingestion bulk ; augmentez l’intervalle de refresh pendant le backfill ; planifiez hors‑pics ; isolez des nœuds d’indexation si possible ; maintenez une taille de shard raisonnable.
Trois mini-récits du monde corporate issus du terrain
Mini‑récit 1 : L’incident causé par une mauvaise hypothèse
Une plateforme B2B de taille moyenne a livré une « recherche instantanée » pour les enregistrements nouvellement créés. L’équipe a supposé que le moteur de recherche se comporterait comme la base de données : une fois l’appel d’indexation retournant 200, le document serait recherchable. Cette hypothèse a vécu dans un commentaire, puis dans une promesse client, puis dans une démo commerciale.
Un lundi chargé, le support a signalé que les utilisateurs ne trouvaient pas les enregistrements qu’ils venaient de créer. Les ingénieurs ont vérifié les logs API : les appels d’indexation étaient réussis. OpenSearch avait l’air vert. L’équipe a donc blâmé le « cache client » et a livré un patch anti‑cache qui n’a rien fait du tout.
Le vrai problème était le comportement de refresh combiné à la charge. Sous forte indexation, les refresh étaient retardés et les merges coûteux. Le pipeline d’indexation était correct, mais la « recherchabilité » n’était pas immédiate. Les utilisateurs utilisaient la barre de recherche quelques secondes après la création, et le système n’avait pas de parcours UX pour afficher les nouveaux enregistrements en dehors de la recherche.
La correction n’était pas héroïque. Ils ont défini un SLO de fraîcheur et implémenté un flux post‑création qui affiche directement l’enregistrement créé, plus une bannière indiquant que la recherche peut prendre un court instant à rattraper.
Ils ont aussi ajusté l’intervalle de refresh et créé une métrique « time to searchable ». L’incident s’est fini lorsque tout le monde s’est mis d’accord sur le contrat réel : la recherche est quasi‑temps réel, pas transactionnelle.
Mini‑récit 2 : L’optimisation qui a mal tourné
Une autre entreprise a tenté de sauver de la latence en sautant l’hydratation Postgres. La logique : « OpenSearch a déjà tous les champs dont on a besoin. Pourquoi fetch depuis Postgres ? C’est du réseau et de la charge en plus. » Sur le papier, c’était séduisant. Ils ont même célébré la baisse du QPS de lecture en base.
Puis sont venus les bugs subtils. Un changement de permissions dans Postgres mettait du temps à atteindre OpenSearch, donc des utilisateurs voyaient brièvement des résultats qu’ils ne devaient pas voir. Les enregistrements soft‑deleted persistaient. Un flag « hidden » n’était pas appliqué de manière cohérente. L’équipe produit a reçu des rapports d’« éléments fantômes » et d’« éléments privés dans la recherche ».
Le pire : le système est devenu difficile à raisonner. L’exactitude dépendait désormais d’un OpenSearch parfaitement synchronisé et d’une projection parfaite. Quand ce n’était pas le cas, le mode de défaillance était une exposition de données, pas seulement une recherche périmée.
Ils ont rollbacké vers l’hydratation pour les entités protégées et ont gardé un mode « OpenSearch‑only » limité pour le contenu public. La latence a légèrement augmenté. Le risque d’incident a beaucoup baissé. L’optimisation a échoué parce qu’elle optimisait le mauvais indicateur : le p95, pas la confiance.
Mini‑récit 3 : La pratique ennuyeuse mais correcte qui a sauvé la mise
Une autre organisation exécutait des exercices trimestriels de reindex. Pas parce que c’était amusant — parce que ce n’était pas le cas. Ils le traitaient comme un exercice incendie : construire une nouvelle version d’index, backfill depuis Postgres, double écriture, valider des échantillons, puis basculer l’alias de lecture.
Un jour, un changement de mapping a été déployé avec un mauvais type de champ. L’indexation a commencé à échouer pour un sous-ensemble de documents, mais le pipeline ne s’est pas effondré car les échecs étaient isolés à des enregistrements outbox spécifiques. Les alertes se sont déclenchées sur le « taux de poison pill outbox » et le « taux d’erreur d’indexation ». L’oncall voyait exactement ce qui avait cassé et où.
Ils ont avancé en créant une version d’index corrigée et en rejouant l’outbox depuis un offset connu. Parce que la bascule d’alias et le backfill avaient été pratiqués, l’équipe a évité une longue indisponibilité et une chirurgie manuelle des données. Les clients ont en grande partie rien remarqué.
Ce n’était pas de l’ingénierie glamour. C’était une checklist, un runbook, et la discipline de tester la chose effrayante avant qu’elle ne devienne urgente. C’est le genre de pratique qui ne reçoit pas d’applaudissements — jusqu’à ce qu’elle sauve un week‑end.
Checklists / plan étape par étape
Étape par étape : livrer une recherche hybride qui ne vous trahira pas
-
Définir le contrat : SLO de fraîcheur (« écriture → recherchable »), règles d’exactitude (auth, suppressions, frontières locataire),
et staleness acceptable par fonctionnalité. - Faire de Postgres la source de vérité : toutes les écritures s’y font ; pas d’exceptions « pour la perf » sans revue de risque écrite.
- Créer une table outbox : la transaction métier écrit aussi un enregistrement outbox contenant entity_id, entity_type, operation et timestamps.
- Construire un worker indexeur idempotent : retries sûrs, clés de déduplication, concurrence bornée, et quarantaine pour poison‑pill.
- Concevoir la projection du document OpenSearch : déterministe, versionnée, testée ; éviter les surprises de mapping dynamique.
- Utiliser des alias dès le premier jour : alias de lecture + alias d’écriture, pour pouvoir reindexer sans changer le code applicatif.
- Implémenter le pattern de requête de recherche : OpenSearch retourne des IDs candidats ; Postgres hydrate les enregistrements autoritaires et filtre.
- Construire un job de backfill : indexation bulk depuis Postgres, throttlée, résumable, observable. Planifiez‑le ; vous en aurez besoin.
- Instrumenter le lag du pipeline : outbox lag, débit d’indexation, taux d’erreur ; pager sur violations soutenues du SLO de fraîcheur.
- Tester la charge réalistement : inclure indexation, merges, refresh, et mélanges de requêtes semblables à la production — surtout filtres locataire et agrégations.
- Pratiquer les drills de reindex : au moins trimestriellement, et après tout changement d’analyzer/mapping nécessitant reindex.
- Écrire le mode « casser la vitre » : si OpenSearch est dégradé, basculer sur une recherche Postgres FTS limitée ou renvoyer des résultats partiels avec UX claire.
Checklist : avant d’accuser OpenSearch d’une recherche lente
- Comparer OpenSearch « took » à la latence API.
- Vérifier les patterns de requêtes d’hydratation Postgres (N+1 ?) et les index.
- Vérifier que vous ne sélectionnez pas des payloads énormes inutilement.
- Confirmer que les filtres locataire et la logique de permission ne font pas d’appels par ligne.
Checklist : avant de lancer un reindex en production
- La taille des shards est‑elle raisonnable pour le nouvel index ?
- Avez‑vous assez d’espace disque pour des indices parallèles ?
- L’intervalle de refresh est‑il adapté pour le backfill ?
- Avez‑vous des limites de débit et du backpressure face aux rejets OpenSearch ?
- Avez‑vous un plan de validation (échantillons, comptages, vérifications ponctuelles) ?
- La bascule d’alias est‑elle atomique et répétée en exercice ?
FAQ
1) PostgreSQL full‑text search peut‑il remplacer complètement OpenSearch ?
Parfois. Si votre recherche est surtout du filtrage structuré avec un appariement textuel modéré, le FTS de Postgres est plus simple et correct par défaut. Si vous avez besoin d’une pertinence sophistiquée, de fuzziness, de boosting multi‑champs, ou d’une très forte concurrence sur de grands corpus, OpenSearch justifie son coût.
2) Pourquoi ne pas tout stocker dans OpenSearch et arrêter d’utiliser Postgres pour les lectures ?
Parce que vous redécouvririez tôt ou tard les transactions, les contraintes et l’audit à la dure. OpenSearch n’est pas conçu pour être votre grand livre. Si vous sautez les vérifications Postgres pour l’autorisation et les suppressions, vous misez votre posture de sécurité sur la cohérence éventuelle et des projections parfaites.
3) Outbox vs CDC : lequel est meilleur ?
Outbox est plus simple à raisonner et tester dans le code applicatif. CDC est puissant quand vous avez besoin d’une capture large à travers de nombreuses tables et services. Pour l’indexation de recherche, outbox gagne souvent parce que vous avez typiquement besoin d’une projection de toute façon, et vous voulez le contrôle explicite de ce qui est indexé et quand.
4) Dois‑je hydrater les résultats depuis Postgres à chaque fois ?
Pour les entités protégées ou mutables, oui — au moins comme garde‑fou d’exactitude. Pour du contenu public et à faible risque, vous pouvez renvoyer directement les sources OpenSearch, mais il faut une discipline stricte autour des suppressions, du masquage et des frontières locataires.
5) Comment gérer l’UX « recherche juste après création » ?
Ne promettez pas une recherchabilité immédiate sauf si vous êtes prêt à en payer le coût (et même alors, les systèmes distribués ont des cas limites). Après création d’un objet, envoyez l’utilisateur sur la page de l’objet, affichez‑le dans « récemment créés », ou épinglez‑le dans l’UI jusqu’à ce qu’il devienne recherchable.
6) Quelle est la cause la plus courante d’échecs d’indexation ?
Les conflits de mapping dus à des types de champ incohérents, généralement causés par le mapping dynamique et des projections négligentes. En deuxième position : les watermarks disque rendant les indices en lecture seule. Troisième : la taille et la concurrence des requêtes bulk provoquant des rejets.
7) Comment éviter les problèmes de performance liés aux shards ?
Gardez le nombre de shards aligné avec votre nombre de nœuds et la taille attendue des données. Évitez les shards trop petits (surcharge) et les shards géants (douleur de récupération). Utilisez des alias et reindexez quand vous dépassez l’estimation initiale. Les shards ne sont pas gratuits ; ils coûtent du heap et de la complexité opérationnelle.
8) Ai‑je besoin de clusters OpenSearch séparés pour l’indexation et les requêtes ?
Pas toujours, mais la séparation aide quand vous avez des backfills lourds, des reindex fréquents, ou des pics de trafic marqués. Au minimum, contrôlez le débit d’indexation avec du backpressure pour que les merges n’étouffent pas la latence des requêtes. Si votre activité dépend de la recherche, envisagez une architecture qui isole le rayon d’explosion.
9) Comment tester la pertinence sans casser la production ?
Construisez un jeu d’évaluation hors‑ligne (requêtes + résultats attendus). Faites des évaluations A/B sur des snapshots. Déployez les changements de pertinence derrière des feature flags ou des versions d’index. La plupart des « bugs » de pertinence viennent de changements sans baseline.
10) Quel est le meilleur fallback quand OpenSearch est dégradé ?
Une recherche Postgres limitée pour les workflows essentiels (par correspondance exacte, préfixe, ou FTS contraint) suffit souvent. L’important est de définir ce que signifie le « mode dégradé » et de le garder dans la capacité de la base de données.
Prochaines étapes que vous pouvez faire cette semaine
Si vous avez déjà Postgres et OpenSearch en production, votre objectif n’est pas la « recherche parfaite ». C’est la recherche prévisible.
Voici des actions pratiques qui rapportent vite :
- Ajouter des métriques de fraîcheur : outbox lag, débit d’indexeur, « time to searchable », et taux d’erreur d’indexation.
- Auditer vos filtres locataire/auth : appliquez‑les en un seul endroit (de préférence l’hydratation) et testez‑les comme des contrôles de sécurité.
- Trouver et éliminer les N+1 d’hydratation : batchez les fetchs par IDs et récupérez uniquement les colonnes nécessaires.
- Verrouiller les mappings : empêcher les champs dynamiques accidentels d’exploser et provoquer des conflits.
- Pratiquer la bascule d’alias : répétez un reindex, même si vous n’en avez pas besoin aujourd’hui. Vous en aurez besoin.
- Rédiger un contrat d’une page : ce qui est frais, ce qui est correct, et que faire en cas de dégradation.
La recherche hybride fonctionne quand vous arrêtez d’essayer de faire faire deux choses à un seul système. Laissez PostgreSQL être exact. Laissez OpenSearch être pertinent. Puis construisez un pipeline et une API qui acceptent la réalité et servent toujours bien les utilisateurs.