El problema: latencia en un catálogo de 60M+ filas
Un catálogo EPC realista tiene entre 40 y 80 millones de filas: cada combinación marca × modelo × año × motor × planta × categoría × part_number es una fila. Multiplicado por 8 OEMs y supersesiones SSPL históricas, llegás fácil a 60-80M de filas en la tabla principal antes de hablar de joins.
Una consulta naïve estilo `SELECT * FROM parts WHERE vin_prefix = ? AND category = ?` sobre esa tabla, sin índices correctos y sin particionamiento, toma entre 1.2 y 3.5 segundos en hardware razonable (8 vCPU, 32 GB RAM, SSD NVMe). Eso es 6-15x más lento que el objetivo de <250 ms total para una request HTTP que incluye decode + match + SSPL + serialización JSON.
El objetivo realista que perseguimos en producción es P50 <150 ms y P95 <400 ms incluyendo el round-trip al EPC oficial del OEM cuando hace falta. Cómo se llega ahí es la diferencia entre una API que sirve a 100 clientes B2B y una que se cae con el primer pico de fin de mes.
Esquema de datos: particionar por OEM o morir intentando
El primer error clásico es tener una tabla `parts` única con una columna `oem` discriminadora. Esto no escala porque (1) el optimizador del motor no usa particionamiento implícito y (2) cualquier query toca el índice completo aunque sólo le interese un OEM.
La solución es particionamiento declarativo nativo del motor por LIST sobre la columna `oem`. Una partición por marca (toyota, nissan, honda, suzuki, yamaha, bajaj, jlr, lexus) más una partición default para nuevos OEMs. Cada partición queda en ~5-10M filas, tamaño totalmente manejable.
- CREATE TABLE parts (...) PARTITION BY LIST (oem);
- CREATE TABLE parts_toyota PARTITION OF parts FOR VALUES IN ('toyota', 'lexus');
- Cada partición tiene sus propios índices, su propio VACUUM y se puede mover a tablespaces separados si crece distinto.
- El optimizador hace partition pruning automático: una query con WHERE oem = 'toyota' sólo toca parts_toyota.
- Esto reduce el tamaño del índice efectivo en ~8x y baja la latencia base de un SELECT por VIN de ~800 ms a ~120 ms.
Setup mínimo de base particionada para catálogo EPC
Pasos para arrancar el esquema particionado en una instancia limpia de un motor relacional moderno.
- 1
Crear tabla particionada raíz
CREATE TABLE parts (id bigserial, oem text NOT NULL, wmi char(3), vds char(6), model_year smallint, category text, oem_part_number text, supersedes text[], data jsonb) PARTITION BY LIST (oem);
- 2
Crear particiones por OEM
CREATE TABLE parts_toyota PARTITION OF parts FOR VALUES IN ('toyota','lexus'); CREATE TABLE parts_nissan PARTITION OF parts FOR VALUES IN ('nissan'); CREATE TABLE parts_default PARTITION OF parts DEFAULT;
- 3
Índices compuestos por partición
CREATE INDEX ON parts_toyota (wmi, model_year, category) INCLUDE (oem_part_number, supersedes); Repetir para cada partición.
- 4
Configurar un connection pooler en transaction mode
pool_mode = transaction; max_client_conn = 1000; default_pool_size = 25 (por DB). Esto sostiene 10k+ req/min sobre 25 conexiones reales a la base.
- 5
Activar pg_stat_statements + auto_explain
shared_preload_libraries = 'pg_stat_statements,auto_explain'; auto_explain.log_min_duration = '200ms'. Las queries lentas quedan loggeadas con plan para análisis.
Índices compuestos VIN-aware (no son los obvios)
Después de particionar, el segundo cuello de botella son los índices. El reflejo natural es indexar por `vin` completo, pero eso es contraproducente: un VIN es único por vehículo, no por part number, así que un B-tree sobre `vin` no ayuda al join real que necesitás.
Los índices que funcionan en producción están compuestos por prefijos del VIN. Tres índices cubren el 95% de las queries: (wmi, model_year, category), (wmi, vds_prefix6, category), (oem, model_family, model_year_range, category). El primero captura matches por fabricante + año + categoría; el segundo refina con los 6 caracteres del VDS para variantes específicas; el tercero soporta queries del usuario que sabe el modelo pero no tiene VIN.
- CREATE INDEX idx_parts_wmi_year_cat ON parts_toyota (wmi, model_year, category) INCLUDE (oem_part_number, supersedes);
- CREATE INDEX idx_parts_wmi_vds6_cat ON parts_toyota (wmi, substr(vds, 1, 6), category);
- CREATE INDEX idx_parts_family_year ON parts_toyota (model_family, model_year_range, category);
- Usar INCLUDE para covering indexes: el SELECT puede responderse sin tocar la tabla, reduciendo latencia ~40%.
- Evitar índices sobre vin completo: ocupan 3-5x más espacio sin acelerar las queries reales.
Fan-out a múltiples EPCs OEM sin matar la latencia P95
Cuando un VIN no está cacheado o la categoría requiere datos en vivo (precios, stock OEM), hay que ir al EPC oficial del fabricante. Cada OEM tiene su propia latencia: Toyota TIS responde en 400-700 ms, Nissan FAST en 600-1100 ms, Suzuki en 800-1500 ms. Hacer estas llamadas en serie destroza el P95.
El patrón correcto es fan-out controlado con `asyncio.gather` (Python) o `Promise.all` (Node), con un timeout estricto compartido — típicamente 800 ms — y degradación elegante: si el OEM no responde a tiempo, devolvemos lo que tenemos en caché o un response parcial con flag `partial: true` y un follow-up async.
- Timeout duro 800 ms por OEM, no acumulativo: el cliente espera 800 ms máximo aunque consultes 3 OEMs.
- Pool de conexiones HTTP persistentes a cada OEM (keep-alive) — reduce overhead TLS ~60 ms por request.
- Circuit breaker por OEM: si Nissan FAST tira >30% errores en 60s, deja de consultarlo y responde con caché por 5 min.
- Background refresh: las queries que devolvieron caché viejo (>1h) disparan un worker async para refrescar sin bloquear la response.
- Métrica clave: tasa de timeouts por OEM. Si supera 5%, hay que escalar a soporte del OEM antes de que afecte SLA.
Caché L2: la diferencia entre 200 ms y 50 ms
Para una API que recibe 10k req/min, hit rate de caché >80% es la diferencia entre necesitar 4 réplicas de la base relacional y necesitar 1. La caché L2 (Redis o equivalente) es obligatoria, no opcional.
La clave de caché tiene que ser semánticamente correcta: muchos equipos cachean por `vin` completo y se sorprenden cuando el hit rate es del 12%. La clave que funciona es composite: `parts:{vin}:{category}:{sspl_v}`. El sufijo `sspl_v` es la versión actual de la tabla SSPL — cuando el OEM publica una supersesión nueva, se incrementa y invalida todas las entradas relevantes sin un FLUSHALL.
- TTL por capa: hot cache 5 min (queries idénticas en ventana corta), warm cache 7 días (decodificaciones VIN), cold cache 30 días (catálogo histórico).
- Compresión con LZ4 dentro de Redis: payloads JSON de 8-15 KB bajan a 1.5-3 KB, multiplica capacidad ~5x sin RAM extra.
- Read-through con singleflight: si 200 requests piden el mismo VIN no cacheado al mismo tiempo, solo 1 va al backend y las otras 199 esperan el mismo resultado.
- Métrica obligatoria: hit rate por categoría. Categorías con hit rate <60% suelen tener bugs en la clave de caché.
- Evitar caché en memoria del proceso (LRU local): no escala horizontal y crea inconsistencias entre réplicas.
Latencia por estrategia de caché (medido sobre 10k req)
| Estrategia | P50 | P95 | P99 | Hit rate |
|---|---|---|---|---|
| Sin caché | 320 ms | 780 ms | 1.4 s | 0% |
| LRU en memoria del proceso | 180 ms | 650 ms | 1.2 s | 35% |
| Redis sin singleflight | 95 ms | 420 ms | 850 ms | 68% |
| Redis + singleflight + jitter TTL | 48 ms | 180 ms | 380 ms | 83% |
| Redis + L3 (CDN edge) | 22 ms | 95 ms | 240 ms | 91% |
Pipeline de SSPL en background, no en request path
Aplicar supersesiones SSPL en el path de la request es el error más común y el que más mata latencia. Cada OEM publica deltas SSPL con frecuencias distintas (Toyota mensual, Nissan trimestral, Honda casi en vivo). Resolver una cadena de supersesiones puede involucrar 2-4 lookups recursivos.
La solución es un worker en background (Celery, Sidekiq, Temporal, BullMQ) que consume los feeds SSPL de los OEMs, computa la cadena resuelta (`old_part → ... → current_part`) y materializa el resultado en una tabla `sspl_resolved` con la versión actual. Las queries del API leen esa tabla materializada en una sola lookup O(1), no recorren la cadena en vivo.
Observabilidad: medir percentiles, no promedios
El promedio de latencia es la métrica que más miente. Una API que tiene 95% de requests en 80 ms y 5% en 4 segundos muestra un promedio de 280 ms — "todo bien" — pero esos 5% están haciendo que tus clientes B2B activen el SLA y pidan compensación.
Las métricas que importan: P50, P95 y P99 por endpoint, por OEM y por categoría. Y la métrica más subestimada: P95 de tiempo en cola (cuántos ms una request espera antes de empezar a procesarse). Si P95 de cola supera 50 ms, tu pool de workers está subdimensionado.
- Métricas obligatorias por endpoint: latencia P50/P95/P99, RPS, error rate, hit rate caché.
- Métricas por OEM: latencia promedio del EPC, timeout rate, circuit breaker state, payload size.
- Tracing distribuido (OpenTelemetry, Jaeger): permite ver dónde se va el tiempo en una request lenta puntual.
- Alerta sobre P95, no sobre promedio: tu cliente B2B siente el P95, no el average.
- Dashboards separados por cliente top: los SLAs son contractuales y cada cliente VIP merece su vista.
Anti-patrones que destruyen latencia en producción
Errores que vemos repetirse en revisiones de arquitectura — todos arreglables, todos costosos cuando ya están en producción:
- ORM sin prefetch: cada part_number gatilla un SELECT extra para traer relaciones. 50 parts = 51 queries. Solución: select_related / JOIN explícito.
- Conexiones a la base sin pool: abrir TLS + auth en cada query agrega 30-80 ms. Solución obligatoria: un connection pooler en transaction mode.
- Logging síncrono dentro del request handler: cada `logger.info(...)` puede agregar 5-15 ms si el destino es lento. Solución: log queue async.
- JSON serialization con la lib default: orjson o ujson son 3-5x más rápidos para payloads grandes.
- Validación de schema dentro del endpoint: hacelo en el gateway/edge (Cloudflare, AWS API GW) — libera el backend de queries con VINs claramente inválidos.
- Cache sin TTL escalonado: si todo expira al mismo tiempo, el siguiente segundo todos los workers golpean la DB. Solución: TTL con jitter ±10%.