PostgreSQL vs OpenSearch: la configuración de búsqueda híbrida que realmente funciona

¿Te fue útil?

Tienes un cuadro de búsqueda de producto que está «bien» hasta que deja de estarlo. De repente, los clientes no encuentran lo que acaban de crear.
Soporte recibe tickets. Liderazgo pregunta por qué los resultados de búsqueda son «aleatorios». Ingeniería insiste en que es «consistencia eventual».
Y SRE queda atrapado en el medio, con el pager y un postmortem a medio escribir.

El problema real rara vez es elegir PostgreSQL o OpenSearch. Es desplegar una configuración híbrida con límites equivocadas: mezclar corrección con relevancia, añadir indexación sin retropresión y tratar la «búsqueda» como una sola
característica en lugar de dos sistemas con dos contratos distintos.

Qué significa realmente “búsqueda híbrida” (y qué no)

“Búsqueda híbrida” es una frase sobrecargada. En las diapositivas de vendedores a menudo significa mezclar búsqueda vectorial + por palabras clave. En sistemas de producción
suele significar: PostgreSQL sigue siendo el sistema de registro, OpenSearch sirve las consultas de búsqueda y ejecutas una canalización para mantenerlos lo suficientemente sincronizados como para que los usuarios confíen en los resultados.

Esa parte de «lo suficientemente sincronizados» es todo el trabajo. Si no puedes definirla—por expectativa de producto y presupuesto operativo—acabarás
discutiendo sobre corrección durante incidentes, que es un momento terrible para descubrir que tus definiciones eran sensaciones.

La búsqueda híbrida son dos problemas, no uno

  • Corrección: “¿Se puede devolver este objeto? ¿Existe? ¿Está autorizado el usuario?”
  • Relevancia: “Dado el conjunto de objetos permitidos, ¿cuáles son las mejores coincidencias y cómo se ordenan?”

PostgreSQL es muy bueno en corrección y restricciones relacionales. OpenSearch es muy bueno en relevancia y recuperación
sobre grandes campos de texto con ajuste flexible del ranking. Cuando los equipos intentan que un sistema haga ambas cosas, pagan en latencia,
complejidad o confianza. Normalmente en los tres.

Dos chistes, porque la producción necesita humor

Chiste 1: La búsqueda es solo una consulta a la base de datos con opiniones. Desafortunadamente, esas opiniones suelen tener requisitos de disponibilidad.

PostgreSQL vs OpenSearch: contratos diferentes, modos de fallo distintos

El contrato de PostgreSQL

PostgreSQL promete integridad transaccional, semántica predecible y un planificador de consultas que hará lo posible—hasta que le entregues una consulta con forma de artefacto maldito. Es tu fuente de la verdad. Es donde se escribe, valida,
desduplica y gobierna la data.

PostgreSQL puede hacer búsqueda de texto completo. Para muchas cargas de trabajo es suficiente. Pero tiene límites: el stemming y el ranking son
menos flexibles; escalar horizontalmente requiere más trabajo; afinar relevancia es posible pero engorroso; y sentirás dolor cuando tu
producto empiece a exigir “búsqueda como una app de consumo” encima de esquemas relacionales.

El contrato de OpenSearch

OpenSearch (y su linaje Elasticsearch) es un motor de búsqueda distribuido diseñado para recuperación y ranking a escala.
Aceptará gustoso documentos desnormalizados, tokenizará texto, calculará puntajes de relevancia y responderá consultas complejas
rápidamente—siempre que le suministres mappings estables, controles tu estrategia de shards y evites tratarlo accidentalmente como
una base de datos transaccional.

OpenSearch no promete consistencia transaccional con tu base de datos primaria. Promete indexación casi en tiempo real
y durabilidad a nivel de clúster basada en replicación, fusión de segmentos y archivos de registro de escritura en su propio mundo.
Si necesitas “leer después de escribir” correcto a través de sistemas, debes diseñarlo.

Hechos y contexto interesantes (porque la historia predice incidentes)

  1. El linaje de PostgreSQL se remonta al proyecto POSTGRES en UC Berkeley en los años 80; su sesgo hacia la corrección y la extensibilidad no es casualidad.
  2. La búsqueda de texto completo en PostgreSQL se convirtió en una característica de primera clase a mediados de los 2000, y ha madurado gradualmente—pero sigue siendo
    una característica de base de datos relacional, no la identidad central de un motor de búsqueda.
  3. Lucene (la biblioteca subyacente detrás de Elasticsearch y OpenSearch) comenzó alrededor de 1999; sus supuestos de diseño son
    “documentos e índices invertidos,” no “tablas y joins.”
  4. La indexación cerca-en-tiempo real es un compromiso deliberado: los documentos se vuelven buscables después de un refresh, no inmediatamente
    después de la escritura, a menos que pagues por refreshes más frecuentes.
  5. La eliminación del concepto de “type” en Elasticsearch (era 7.x) obligó a muchos equipos a enfrentarse al diseño de esquema/mapping; OpenSearch hereda
    la misma lección: los mappings son un contrato y romperlos es costoso.
  6. La práctica común de usar “borrados suaves a nivel de aplicación” (una bandera booleana) puede envenenar silenciosamente la relevancia y
    corrección de la búsqueda si los filtros no se aplican de forma consistente en ambos sistemas.
  7. El patrón outbox ganó popularidad cuando los equipos sufrieron por problemas de doble escritura; ahora es una respuesta predeterminada para “mantener
    dos sistemas sincronizados” cuando te importa no perder escrituras.
  8. Los clústeres de búsqueda fallan de formas físicamente extrañas: los umbrales de disco activan modo solo-lectura, las fusiones de segmentos saturan IO, y un
    cambio “simple” de mapping puede causar costos masivos de reindexado.

Si no recuerdas nada más: PostgreSQL es tu libro mayor. OpenSearch es tu catálogo. No intentes pagar impuestos con tu catálogo.

Reglas de decisión: cuándo consultar Postgres, cuándo consultar OpenSearch

Usa PostgreSQL cuando

  • Necesitas corrección estricta: facturación, permisos, cumplimiento, desduplicación, idempotencia.
  • Necesitas restricciones relacionales y joins que son difíciles de aplanar sin riesgo de corrupción.
  • Estás filtrando y ordenando por campos estructurados con índices selectivos.
  • Tu “búsqueda” es en realidad una lista filtrada con coincidencia de texto ligera y volumen predecible.

Usa OpenSearch cuando

  • Necesitas ranking por relevancia, fuzziness, sinónimos, stemming, boosting, “quisiste decir” o consultas textuales por campos.
  • Necesitas recuperación rápida sobre grandes campos de texto para muchos usuarios concurrentes.
  • Puedes aceptar consistencia eventual, o puedes diseñar UX/flujos alrededor de ello.
  • Necesitas agregaciones sobre conjuntos enormes donde un motor de búsqueda destaca.

La regla híbrida que te mantiene fuera de problemas

Usa OpenSearch para producir IDs candidatos y puntajes; usa PostgreSQL para aplicar la verdad y autorización.
Eso significa que tu API de búsqueda a menudo se convierte en dos pasos: consulta OpenSearch → lista de IDs → fetch en Postgres (y filtrado).
Cuando importa el rendimiento, optimizas esa unión cuidadosamente. Cuando importa la corrección, nunca la omites a la ligera.

Si te tienta almacenar permisos en OpenSearch y nunca verificar en Postgres: estás construyendo un incidente de seguridad con
una interfaz bonita.

Una arquitectura de referencia que sobrevive en producción

Las piezas móviles

  • PostgreSQL: fuente de la verdad. Las escrituras ocurren aquí. Las transacciones significan algo aquí.
  • Tabla outbox: registro durable de “cosas que deben indexarse” escrito en la misma transacción que la escritura del negocio.
  • Worker indexador: lee el outbox, obtiene el estado completo de la entidad (o una proyección), escribe en OpenSearch y marca el outbox como procesado.
  • OpenSearch: documentos desnormalizados y buscables. Mappings afinados. Conteo de shards controlado.
  • API de búsqueda: consulta OpenSearch para candidatos; hidrata desde Postgres; devuelve resultados.
  • Job de backfill/reindex: reconstrucción masiva del índice desde snapshots de Postgres o lecturas consistentes.
  • Observabilidad: métricas de lag, presupuesto de errores, logs de consultas lentas, throughput de indexación y salud del clúster.

Por qué outbox vence a “simplemente publicar un evento”

El patrón outbox es el amigo poco glamoroso que llega a tiempo. Escribes tus fila(s) en la tabla de negocio y una
fila outbox en la misma transacción de base de datos. Si la transacción hace commit, la fila outbox existe. Si hace rollback,
no existe. Ese es todo el punto: no hay actualizaciones perdidas por “commit de BD tuvo éxito pero la publicación a Kafka falló,” o al revés.

Un sistema CDC (replicación lógica, Debezium, etc.) también puede funcionar. Pero todavía necesitas gestionar orden, eliminaciones, cambios de esquema
y la semántica de replay. Outbox es más simple de razonar para muchos equipos, especialmente cuando el indexador necesita
calcular una proyección de todos modos.

Cómo manejar borrados sin mentir

Los borrados son donde los sistemas híbridos van a morir silenciosamente. Debes decidir:

  • Borrado duro en Postgres + borrado de documento en OpenSearch.
  • Borrado suave en Postgres + filtrado en ambos sistemas + job de purga eventual.

La postura operativa más segura es: trata a Postgres como autoritativo, siempre. Si OpenSearch devuelve un ID candidato que ya
no existe o está borrado, tu paso de hidratación lo descarta. Esto es menos “eficiente” pero mucho más “dormible.”

Una cita sobre fiabilidad (idea parafraseada)

Idea parafraseada — John Allspaw: la fiabilidad viene de cómo se comportan los sistemas bajo estrés, no de cómo se ven en los diagramas.

Modelado de datos: fuente de la verdad, desnormalización y por qué las joins no pertenecen a la búsqueda

Diseña el documento de OpenSearch como una proyección

Tu documento de OpenSearch debe ser una proyección estable de la entidad tal como el usuario la busca. Esto normalmente implica:
campos aplanados, datos repetidos (desnormalizados) y algunos campos calculados para la relevancia (como “popularity_score”,
“last_activity_at” o “title_exact”).

La proyección debe generarse por código que puedas versionar y probar. Si tu indexador es “SELECT * y esperanza,” vas a desplegar
explosiones de mapping y regresiones de calidad.

No modeles joins relacionales en OpenSearch a menos que te gusten las sorpresas de rendimiento

OpenSearch tiene características tipo join (nested, parent-child). Pueden ser válidas. También son fáciles de malusar y difíciles de
operar a escala. En la mayoría de cargas de búsqueda de producto, serás más feliz desnormalizando en un solo documento por entidad
buscable y pagando el coste de indexación una vez.

Cuando un objeto relacionado cambia (por ejemplo, el nombre de una organización), reindexas los documentos afectados. Esto es un coste conocido.
Lo presupuestas y lo limitas. No finges que no sucederá.

La estabilidad del mapping es una preocupación de SRE

Trata los mappings como migraciones de base de datos. Un cambio descuidado de mapping puede desencadenar:

  • Conflictos inesperados de tipo de campo (el índice rechaza documentos).
  • Campos dinámicos implícitos que hinchan el tamaño del índice y la presión de heap.
  • Reindexaciones costosas que compiten con el tráfico de producción.

Ajusta el mapping dinámico con cuidado. La mayoría de equipos en producción no deberían permitir un todo-vale. “Pero es conveniente” es cómo terminas
con un índice que contiene 40 versiones del mismo campo escrito de forma diferente.

Consistencia, latencia y el único SLA que importa

Define “frescura de búsqueda” como un SLO medible

Tu SLA real es: cuánto tiempo después de una escritura hasta que un usuario puede encontrarla vía búsqueda. Esto no es lo mismo que
la latencia p99 del API. Es un SLO de canalización.

Lo mides como lag: timestamp de escritura vs timestamp indexado vs timestamp buscable. Luego decides qué es aceptable
(por ejemplo, 5 segundos, 30 segundos, 2 minutos). Si el producto necesita “instantáneo,” construye la UX para cubrir la brecha: muestra el nuevo objeto
directamente tras la creación, evita la búsqueda para ese flujo y evita enseñar a los usuarios que la búsqueda es la única verdad.

Intervalos de refresh: la perilla que cuesta dinero

Un intervalo de refresh menor significa que los documentos se vuelven buscables más rápido, pero aumenta la rotación de segmentos y la sobrecarga de IO.
Aumentar el intervalo de refresh mejora el throughput de indexación y reduce carga pero hace la búsqueda menos fresca. Ajústalo según
la expectativa del usuario y el volumen de escrituras. Además: no lo ajustes durante un incidente a menos que entiendas los efectos secundarios.

Guardarraíles de corrección

  • Filtrado de autorización: aplícalo en la hidratación de Postgres, o replica las reglas de auth cuidadosamente y pruébalas.
  • Items borrados/ocultos: filtra en todas partes; trata a Postgres como la verdad final.
  • Límites multi-tenant: tenant_id debe ser un filtro de primera clase en consultas OpenSearch y una clave en la obtención desde Postgres.

Chiste 2: La consistencia eventual es genial hasta que tu CEO busca lo que creó hace cinco segundos. Entonces se convierte en «un incidente.»

Tareas prácticas (comandos + salidas + decisiones)

Estos no son comandos de juguete. Son del tipo que ejecutas cuando la búsqueda está lenta, desactualizada o mintiendo—y necesitas decidir qué hacer a continuación. Cada tarea incluye: comando, qué significa la salida y la decisión que tomas.

Tarea 1: Comprobar replicación / presión de escritura en Postgres (¿vamos por detrás antes de que empiece la indexación?)

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)

Significado: Si los commits se disparan pero los hits de caché caen (blks_read sube), estás haciendo más lecturas físicas—a menudo señal de presión de IO.

Decisión: Si el IO está caliente, pausa jobs de reindex/backfill, verifica índices y considera réplicas de lectura para cargas de hidratación.

Tarea 2: Encontrar consultas de Postgres que dominan el tiempo (la hidratación suele ser esto)

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)

Significado: La hidratación por clave primaria debería ser rápida. Si domina el tiempo total, quizás la haces con demasiada frecuencia o falta un índice en (tenant_id, id).

Decisión: Hidrata por lotes (WHERE id = ANY($1)) y añade índices compuestos alineados con límites de tenant.

Tarea 3: Confirmar que el índice crítico de Postgres existe y se usa

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)

Significado: Index Scan + bajo tiempo de ejecución + buffers hit significa que la hidratación está saludable para ese camino.

Decisión: Si ves Seq Scan, corrige el indexado o reduce el ancho de fila recuperada (selecciona solo columnas necesarias).

Tarea 4: Inspeccionar el lag del outbox (el “SLO de frescura” en una consulta)

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)

Significado: 18k pendientes y lag máximo ~8 minutos: la canalización de indexación está atrasada. Los usuarios lo notarán.

Decisión: Escala workers indexadores, verifica latencia de ingest en OpenSearch y asegúrate de que los consumidores outbox no estén atascados en registros «poison pill».

Tarea 5: Detectar registros outbox poison-pill (reintentos atascados)

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)

Significado: Errores repetidos de conflicto de mapping no son “transitorios.” Son bugs de esquema.

Decisión: Cuarentena estos registros, parchea la proyección/mapping y reprocesa después de una corrección controlada.

Tarea 6: Comprobar salud del clúster OpenSearch (red/amarillo no es “solo un color”)

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
}

Significado: Yellow significa que los shards primarios están asignados pero las réplicas no están totalmente asignadas. Tienes redundancia reducida; el rendimiento puede verse afectado durante recuperaciones.

Decisión: Si yellow persiste, investiga asignación de shards, umbrales de disco y capacidad de nodos antes de escalar tráfico de consulta.

Tarea 7: Identificar índices con demasiados shards (un impuesto clásico de rendimiento)

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

Significado: 24 shards primarios para un índice puede estar bien—o puede ser una explosión de shards dependiendo del conteo de nodos y patrones de consulta.

Decisión: Si el número de shards > (nodos * 2–4) para un índice caliente, planifica un shrink/reindex con un tamaño de shard sensato.

Tarea 8: Comprobar presión de indexación en OpenSearch (¿las merges y refreshes matan el 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 }
      }
    }

Significado: Alta concurrencia de merges y current_docs grandes significa que el clúster está pasando mucho tiempo fusionando segmentos—a menudo IO-bound.

Decisión: Raciona el indexado masivo, aumenta temporalmente el intervalo de refresh (si es aceptable) y verifica el throughput de disco/profundidad de cola en nodos de datos.

Tarea 9: Medir latencia de búsqueda en el motor (¿es OpenSearch o tu app?)

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" } }
    ]
  }

Significado: “took: 38” es tiempo del motor en milisegundos. Si tu p99 de API es 800ms, tu cuello de botella probablemente sea hidratación, red, serialización o llamadas downstream.

Decisión: Usa esto para detener el ping-pong de culpas. Optimiza la capa realmente lenta.

Tarea 10: Verificar mapping para un campo riesgoso (evita desajustes keyword/text silenciosos)

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:         },

Significado: title es text con un subcampo keyword. Eso es estándar: usa title para full-text, title.keyword para coincidencias exactas/ordenación (con moderación).

Decisión: Si estás ordenando por title (text), corrige tus consultas; ordena por title.keyword o por un campo de orden normalizado.

Tarea 11: Comprobar umbrales de disco (los clústeres de búsqueda se vuelven solo-lectura cuando el almacenamiento está justo)

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%",

Significado: En flood_stage, los índices pueden marcarse como solo-lectura para proteger el clúster. Seguirán fallos de indexación.

Decisión: Si estás cerca de flood_stage, escala almacenamiento, borra índices antiguos o reduce retención. No “solo reintentes”.

Tarea 12: Confirmar que tu alias apunta al índice previsto (errores de reindex se ven como bugs de relevancia)

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

Significado: El alias de escritura y el alias de lectura deben ser intencionales. Si tu app lee items_v11 mientras los indexadores escriben en items_v12, “perderás” documentos en búsqueda.

Decisión: Arregla la coreografía de aliases: escribe al índice nuevo, backfill, y luego cambia el alias de lectura de forma atómica.

Tarea 13: Inspeccionar thread pools de OpenSearch (¿estás saturando hilos de búsqueda o de escritura?)

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

Significado: Cola alta y rechazos crecientes indican sobrecarga; OpenSearch está rechazando trabajo.

Decisión: Reduce el coste de consulta (filtros, menos shards), añade nodos o implementa limitación a nivel cliente y fallback.

Tarea 14: Validar comportamiento de indexación por lotes (¿envías batches demasiado grandes?)

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

Significado: Heap > ~85–90% sostenido es una advertencia. La ingest masiva puede desencadenar GC thrash y picos de latencia.

Decisión: Reduce tamaño de requests bulk, limita la concurrencia y asegúrate de que tu conteo de shards no esté forzando demasiado overhead por nodo.

Manual de diagnóstico rápido

Cuando “la búsqueda está rota,” necesitas una ruta que evite debate sin fin. Aquí está el orden que encuentra el cuello de botella rápido.
Está optimizado para setups híbridos donde Postgres es la verdad y OpenSearch la recuperación.

Primero: ¿está desactualizada, equivocada o lenta?

  • Desactualizada: objetos nuevos/actualizados que no aparecen.
  • Equivocada: aparecen objetos no autorizados/eliminados, o faltan items que deberían coincidir.
  • Lenta: picos de latencia o timeouts.

Estas son clases de fallo diferentes. No investigues afinamiento de relevancia cuando tu outbox tenga 20 minutos de retraso.

Segundo: establece qué capa es lenta

  1. Mide tiempo del motor (OpenSearch “took”) para la misma consulta.
  2. Mide el tiempo del API para la petición.
  3. Mide tiempo de la consulta de hidratación en Postgres (EXPLAIN ANALYZE).

Si OpenSearch es rápido pero el API es lento, el culpable suele ser la hidratación (N+1), chequeos de permisos o serialización.
Si OpenSearch es lento pero Postgres está bien, estás en territorio de shards/merges/heap.

Tercero: comprueba lag de la canalización y tasas de error

  1. Conteo pendiente y max lag del outbox.
  2. Logs de error del indexador (conflictos de mapping, rechazos 429, timeouts).
  3. Salud del clúster OpenSearch, umbrales de disco, rechazos de thread pool.

Cuarto: decide una mitigación segura

  • Desactualizada: escala indexadores, raciona backfills, arregla poison pills, evita spam de refresh manuales.
  • Equivocada: aplica la verdad de Postgres en la hidratación; ajusta filtros; audita alias; valida la propagación de borrados.
  • Lenta: reduce coste de consulta, reduce shards golpeados (routing/filtros), limita concurrencia, añade nodos como último recurso.

Errores comunes: síntoma → causa raíz → solución

1) “Lo creé pero la búsqueda no lo encuentra.”

Síntoma: Items recién creados faltan por minutos; el enlace directo funciona.

Causa raíz: Lag de indexación (backlog en outbox), intervalo de refresh largo o indexador limitado por rechazos de OpenSearch.

Solución: Mide lag de outbox; escala consumidores; reduce tamaño de bulk; asegura que OpenSearch no esté en flood-stage de disco; define intencionalmente el intervalo de refresh y comunica el SLO de frescura.

2) “La búsqueda muestra items de otros tenants.”

Síntoma: Fugas entre tenants en resultados—generalmente descubierto por un cliente enfadado.

Causa raíz: Falta de filtro tenant_id en la consulta OpenSearch o en la consulta de hidratación; alias apunta a índice mixto; capa de caché no con clave por tenant.

Solución: Requiere tenant_id en cada consulta (contrato de API); añade tests de consulta; verifica estrategia de particionado de índices; arregla caches para incluir claves de tenant.

3) “OpenSearch es rápido pero el API es lento.”

Síntoma: OpenSearch took es bajo (<50ms) pero p99 del API es alto.

Causa raíz: Hydration N+1, SELECT * amplio, chequeos de permiso por fila o serialización lenta de payloads gigantes.

Solución: Hidratación por lotes con WHERE id = ANY($1); trae columnas mínimas; precomputa flags de permiso; establece límites duros en tamaño de resultados y campos devueltos.

4) “La indexación falla aleatoriamente.”

Síntoma: Algunos documentos nunca se indexan; reintentos en bucle; errores inconsistentes.

Causa raíz: Conflictos de mapping por campos dinámicos o tipos inconsistentes (string vs array, integer vs keyword).

Solución: Bloquea mappings; valida salida de la proyección; cuarentena de poison pills; reindex con mapping corregido y validación estricta de entrada.

5) “Afinamos relevancia y empeoró.”

Síntoma: Calidad de resultados degrada tras añadir boosting/sinónimos; soporte se queja de resultados “sin sentido”.

Causa raíz: Cambios de analizadores sin reindexar, boosting en campos ruidosos, sinónimos demasiado amplios o mezclar lógica de filtro en queries de scoring.

Solución: Trata cambios de analizador como eventos de reindex; valida con conjuntos de juicio offline; separa filtros (bool.filter) de scoring (bool.must/should).

6) “El clúster quedó solo-lectura y la indexación murió.”

Síntoma: Requests bulk empiezan a fallar; logs mencionan bloqueos de solo-lectura.

Causa raíz: Umbral flood-stage de disco activado.

Solución: Libera disco (borra índices antiguos), añade capacidad, reduce retención; luego quita bloques solo-lectura después de resolver la capacidad—no antes.

7) “El reindex tiró abajo la búsqueda.”

Síntoma: Picos de latencia y timeouts durante backfill; CPU/IO del clúster saturado.

Causa raíz: Reindex compitiendo con tráfico vivo; demasiada concurrencia; intervalo de refresh muy bajo; conteo de shards demasiado alto.

Solución: Raciona ingest masivo; aumenta intervalo de refresh durante backfill; programa en horas valle; aísla nodos de indexación si puedes; mantén tamaño de shards sensato.

Tres micro-historias corporativas desde las trincheras

Micro-historia 1: El incidente causado por una suposición equivocada

Una plataforma B2B de tamaño medio lanzó “búsqueda instantánea” para registros recién creados. El equipo supuso que el motor de búsqueda se comportaría como la base de datos: una vez que la llamada de indexación devolviera 200, el documento sería buscable. Esa suposición vivió en
un comentario, luego en una promesa al cliente, luego en una demo de ventas.

Un lunes ocupado, soporte reportó que los usuarios no podían encontrar los registros que acababan de crear. Ingenieros revisaron logs del API:
las llamadas de indexación fueron exitosas. OpenSearch parecía verde. Entonces el equipo culpó a “caching del cliente” y desplegó un parche para invalidar caché
que no hizo nada.

El problema real era el comportamiento de refresh combinado con la carga. Bajo indexación intensa, los refreshes se retrasaban y las merges eran costosas. La canalización de indexación estaba correcta, pero la “buscabilidad” no era inmediata. Los usuarios usaban la caja de búsqueda
segundos después de crear, y el sistema no tenía una ruta UX para mostrar registros recién creados fuera de la búsqueda.

La solución no fue heroica. Definieron un SLO de frescura e implementaron un flujo post-creación que muestra el registro creado
directamente, además de un banner que indica que la búsqueda puede tardar un poco en ponerse al día. También ajustaron el intervalo de refresh y crearon
una métrica de “tiempo hasta buscable.” El incidente terminó cuando todos acordaron el contrato real: la búsqueda es casi en tiempo real,
no transaccional.

Micro-historia 2: La optimización que salió mal

Otra compañía intentó ahorrar latencia omitiendo la hidratación desde Postgres. La lógica: “OpenSearch ya tiene todos los campos que necesitamos. ¿Por qué hacer fetch a Postgres? Es red y carga extra.” En papel parecía genial. Incluso celebraron la caída en QPS de lecturas en la base de datos.

Luego llegaron bugs sutiles. Un cambio de permisos en Postgres tardó en llegar a OpenSearch, así que los usuarios vieron temporalmente resultados que no debían. Registros soft-deleted persistieron. Una bandera “hidden” no se aplicó de forma consistente. El equipo de producto recibió reportes
de “items fantasma” y “items privados en búsqueda.”

Lo peor: el sistema se volvió difícil de razonar. La corrección ahora dependía de que OpenSearch estuviera perfectamente sincronizado y la proyección fuera perfecta. Cuando no lo estaba, el modo de fallo era exposición de datos, no solo búsqueda desactualizada.

Revirtieron a hidratación para entidades protegidas y mantuvieron un modo “solo OpenSearch” limitado para contenido público. La latencia subió ligeramente.
El riesgo de incidente bajó mucho. La optimización salió mal porque optimizó la métrica equivocada: p95, no confianza.

Micro-historia 3: La práctica aburrida pero correcta que salvó el día

Otra organización realizaba simulacros trimestrales de reindexado. No porque fuera divertido—porque no lo era. Lo trataban como un simulacro de incendio: construir una nueva versión de índice, backfill desde Postgres, doble escritura, validar muestras y luego voltear el alias de lectura.

Un día un cambio de mapping se despliega con un tipo de campo malo. La indexación empezó a fallar para un subconjunto de documentos, pero la
canalización no colapsó porque las fallas se aislaron a registros específicos del outbox. Sonaron alertas sobre “tasa de poison pill en outbox” y “tasa de errores de indexación.” El oncall pudo ver exactamente qué rompió y dónde.

Avanzaron creando una versión de índice corregida y reproduciendo el outbox desde un offset conocido. Porque practicar el flip de alias y el backfill, el equipo evitó un outage largo y evitó cirugía manual de datos. Los clientes en su mayoría no notaron nada.

No fue ingeniería glamorosa. Fue una lista de verificación, un runbook y la disciplina de probar lo aterrador antes de que fuera urgente. Ese tipo de práctica no recibe aplausos—hasta que salva un fin de semana.

Listas de verificación / plan paso a paso

Paso a paso: desplegar una búsqueda híbrida que no te traicione

  1. Define el contrato: SLO de frescura (“escritura a buscable”), reglas de corrección (auth, borrado, límites de tenant),
    y estaleness aceptable por feature.
  2. Haz de Postgres la fuente de la verdad: todas las escrituras ocurren allí; no hay excepciones “por rendimiento” sin una revisión de riesgos por escrito.
  3. Crea una tabla outbox: la transacción de negocio escribe también un registro outbox con entity_id, entity_type, operation y timestamps.
  4. Construye un worker indexador con idempotencia: reintentos seguros, claves de deduplicación, concurrencia acotada y cuarentena de poison-pill.
  5. Diseña la proyección del documento OpenSearch: determinista, versionada, probada; evita sorpresas de mapping dinámico.
  6. Usa aliases desde el día uno: alias de lectura + alias de escritura, para reindexar sin cambiar código de aplicación.
  7. Implementa el patrón de consulta de búsqueda: OpenSearch devuelve IDs candidatos; Postgres hidrata registros autoritativos y filtra.
  8. Construye un job de backfill: indexado masivo desde Postgres, limitado, reanudable y observable. Planifícalo; lo necesitarás.
  9. Instrumenta lag de la canalización: lag de outbox, throughput de indexación, tasas de error; pagea por violaciones sostenidas del SLO de frescura.
  10. Prueba carga realista: incluye indexación, merges, refresh y mezclas de consultas parecidas a producción—especialmente filtros por tenant y agregaciones.
  11. Practica simulacros de reindex: al menos trimestralmente, y después de cualquier cambio de analyzer/mapping que requiera reindex.
  12. Escribe el modo “romper-cristal”: si OpenSearch está degradado, usa fallback a búsqueda Postgres FTS para funcionalidad limitada o devuelve resultados parciales con UX clara.

Checklist: antes de culpar a OpenSearch por búsqueda lenta

  • Compara OpenSearch “took” con la latencia del API.
  • Revisa patrones de consulta de hidratación en Postgres (¿N+1?) y los índices.
  • Verifica que no estés seleccionando payloads enormes innecesariamente.
  • Confirma que filtros de tenant y lógica de permisos no estén haciendo llamadas por fila.

Checklist: antes de ejecutar un reindex en producción

  • ¿El dimensionamiento de shards es razonable para el índice nuevo?
  • ¿Tienes suficiente espacio de disco para índices paralelos?
  • ¿El intervalo de refresh está ajustado para backfill?
  • ¿Tienes límites de tasa y retropresión por rechazos de OpenSearch?
  • ¿Tienes un plan de validación (muestras, conteos, chequeos)?
  • ¿El flip de alias es atómico y ensayado?

Preguntas frecuentes

1) ¿Puede la búsqueda de texto completo de PostgreSQL reemplazar completamente a OpenSearch?

A veces. Si tu búsqueda es mayormente filtrado estructurado con coincidencia de texto modesta, FTS de Postgres es más simple y por defecto más correcto. Si necesitas relevancia sofisticada, fuzziness, boosting multicanal o alta concurrencia sobre grandes corpus, OpenSearch justifica su uso.

2) ¿Por qué no almacenar todo en OpenSearch y dejar de usar Postgres para lecturas?

Porque eventualmente redescubrirás transacciones, constraints y auditoría de la forma difícil. OpenSearch no está construido para ser tu libro mayor. Si omites chequeos de Postgres para autorización y eliminaciones, apuestas tu postura de seguridad a la consistencia eventual y proyecciones perfectas.

3) Outbox vs CDC: ¿cuál es mejor?

Outbox es más simple de razonar y probar en código de aplicación. CDC es poderoso cuando necesitas captura amplia a través de muchas tablas y servicios. Para indexación de búsqueda, outbox suele ganar porque típicamente necesitas una proyección de todos modos, además quieres control explícito sobre qué y cuándo se indexa.

4) ¿Debería hidratar resultados desde Postgres cada vez?

Para entidades protegidas o mutables, sí—al menos como puerta de corrección. Para contenido público y de bajo riesgo puedes devolver sources de OpenSearch directamente, pero necesitas disciplina estricta alrededor de borrados, ocultación y límites de tenant.

5) ¿Cómo manejo la UX de “buscar justo después de crear”?

No prometas buscabilidad inmediata a menos que estés dispuesto a pagar por ello (y aun así, los sistemas distribuidos tienen límites). Después de crear un objeto, envía al usuario a la página del objeto, muéstralo en “recientes” o púlsalo en la UI hasta que se vuelva buscable.

6) ¿Cuál es la causa más común de fallos de indexación?

Conflictos de mapping por tipos de campo inconsistentes, usualmente causados por mapping dinámico y proyecciones descuidadas. En segundo lugar: umbrales de disco que marcan índices como solo-lectura. Tercero: tamaños y concurrencia de requests bulk que disparan rechazos.

7) ¿Cómo evito problemas de rendimiento relacionados con shards?

Mantén el conteo de shards alineado con tu número de nodos y tamaño esperado de datos. Evita shards diminutos (overhead) y shards gigantes (dolor en recovery). Usa aliases y reindexa cuando superes la estimación inicial. Los shards no son gratis; cuestan heap y complejidad operativa.

8) ¿Necesito clusters OpenSearch separados para indexado y consulta?

No siempre, pero la separación ayuda cuando tienes backfills pesados, reindexados frecuentes o picos bruscos de tráfico. Como mínimo, controla throughput de indexación con retropresión para que las merges no consuman la latencia de consulta. Si tu negocio depende de la búsqueda, considera arquitecturas que aíslen el radio de explosión.

9) ¿Cómo pruebo relevancia sin romper producción?

Construye un conjunto de juicio offline (queries + resultados esperados). Corre evaluaciones A/B sobre snapshots. Despliega cambios de relevancia detrás de feature flags o versiones de índice. La mayoría de “bugs” de relevancia son cambios sin líneas base.

10) ¿Cuál es el mejor fallback cuando OpenSearch está degradado?

Una búsqueda limitada basada en Postgres para flujos esenciales (por coincidencia exacta, prefijo o FTS acotado) suele ser suficiente. La clave es definir qué significa “modo degradado” y mantenerlo dentro de la capacidad de la base de datos.

Próximos pasos que puedes hacer esta semana

Si ya tienes Postgres y OpenSearch en producción, tu objetivo no es “búsqueda perfecta.” Es búsqueda predecible.
Aquí hay movimientos prácticos que devuelven valor rápidamente:

  1. Añadir métricas de frescura: lag del outbox, throughput del indexador, “tiempo hasta buscable” y tasas de error de indexación.
  2. Auditar tus filtros tenant/auth: aplícalos en un lugar (preferiblemente la hidratación) y pruébalos como controles de seguridad.
  3. Encontrar y eliminar N+1 en hidratación: trae por lotes por IDs y trae solo columnas necesarias.
  4. Bloquear mappings: evita que campos dinámicos accidentales hinchen y causen conflictos.
  5. Practicar flip de aliases: ensaya un reindex, incluso si hoy no lo necesitas. Lo harás.
  6. Escribir un contrato de una página: qué es fresco, qué es correcto y qué pasa durante degradación.

La búsqueda híbrida funciona cuando dejas de intentar que un sistema sea dos cosas. Deja que Postgres sea correcto. Deja que OpenSearch sea
relevante. Luego construye una canalización y una API que acepten la realidad y aún así sirvan bien a los usuarios.

← Anterior
Docker “Too Many Requests” al descargar imágenes: Soluciona el throttling del registro de una vez
Siguiente →
Callouts de documentación que no te traicionan: variables CSS, modo oscuro y disciplina operativa

Deja un comentario