Deep-dive técnico · Arquitectura EPC

    Arquitectura de latencia ultrabaja para catálogos EPC de millones de filas

    Guía técnica para CTOs, arquitectos y tech leads que necesitan responder en <200 ms una consulta VIN→OEM contra un catálogo de 60M+ filas, fan-out a 8 OEMs distintos y SSPL en vivo. Esquemas, índices, caché y patrones que funcionan en producción.

    Lectura de 14 minutos · Junio 2026 · Por el equipo técnico de Efficiency IT Services

    TL;DR técnico

    • Catálogo principal: el motor relacional particionado por OEM con índices compuestos (wmi, vds_prefix, model_year_range).
    • Latencia objetivo: P50 <150 ms, P95 <400 ms para decode + parts en una sola request.
    • Fan-out a EPCs OEM con timeout estricto de 800 ms y degradación elegante a caché.
    • Caché L2 Redis con clave compuesta (vin, category, sspl_applied) y TTL 7 días — hit rate típico 78-85%.
    • Worker pool en Python con asyncio + connection pool a la base (pooler en transaction mode) para sostener 10k req/min sin saturar.

    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. 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. 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. 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. 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. 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)

    EstrategiaP50P95P99Hit rate
    Sin caché320 ms780 ms1.4 s0%
    LRU en memoria del proceso180 ms650 ms1.2 s35%
    Redis sin singleflight95 ms420 ms850 ms68%
    Redis + singleflight + jitter TTL48 ms180 ms380 ms83%
    Redis + L3 (CDN edge)22 ms95 ms240 ms91%

    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%.

    ¿Querés ver esta arquitectura aplicada a tu volumen real?

    Agendá una revisión técnica de 60 min con nuestro equipo. Revisamos tu caso, tu volumen estimado y tus restricciones de infra. Sin venta dura.

    Preguntas Frecuentes

    Todo lo que necesitas saber antes de comenzar.