Caso 1 · ClipKart.com

Top productos comprados por ventana

Diseñar una API que devuelve los 10 productos más comprados en la última 1h, 4h o 24h, con baja latencia y bajo costo.

Recursos

Producto, Orden, EventoCompra, RankingPorVentana.

Operaciones

Lectura del top, ingestión de eventos, agregación, expiración.

Escala

Miles de compras/seg, millones de productos, lecturas mucho mayores a escrituras.

Riesgo

Inconsistencia entre ventanas, hot keys, cache stampede.

Requisitos

Lo primero que pides al entrevistador: aclarar qué es funcional y qué es de calidad.

Funcionales

  • Crear orden con N productos, monto y región.
  • Listar top productos por ventana 1h / 4h / 24h.
  • Cancelar orden y reflejar la baja en el ranking.
  • Detalle de producto con stock y precio.
  • Multi-región con catálogos parcialmente independientes.

No funcionales

  • p99 lectura del top < 100 ms.
  • Eventual consistency en ranking (segundos OK).
  • Disponibilidad 99.9%; órdenes nunca se pierden.
  • 10× picos de Black Friday sin degradar.
  • Reads ≫ Writes (~1000:1).

Cómo aclarar requisitos en la entrevista

Casos de uso típicos. Editorial home con "más vendidos", sidebar de "lo más popular ahora", banners en Black Friday / Cyber Monday, dashboard de ops para inventario y supply chain, alertas de stock-out por velocidad de venta, sección "trending" en categoría de buscador.

Preguntas que debes hacer:

• ¿El top es global o por categoría/región/idioma? Esto cambia el cardinality del sorted set.
• ¿Cuántas ventanas de tiempo? 1h/24h vs 7d/30d cambia almacenamiento y precisión.
• ¿Filtrar productos out-of-stock antes o después del ranking?
• ¿Cancelaciones cuentan negativamente o se ignoran?
• ¿Hay boost editorial (productos pinned por marketing)?
• ¿Top global o personalizado por user/cohort?
• ¿Cuál es la métrica de éxito: CTR del top, conversión a venta o GMV?

Frase clave: "Antes de diseñar quiero saber si el top tiene que ser estrictamente fresco (real-time hard), eventually consistent (segundos), o batch (cada minuto). Eso cambia toda la arquitectura."

Estimaciones de capacidad

Back-of-the-envelope con resoluciones año/día/segundo. Útil para defender almacenamiento, caching y partition keys.

10M
DAU
~300M/mes · ~3.6B sesiones/año.
~120 sesiones/s avg.
1M
Órdenes/día
~365M/año · ~30M/mes.
~12/s avg · ~150/s peak (Black Friday).
1K QPS
Lectura del top
~3.6M/h · ~86M/día.
10K QPS en flash sale.
~730 GB
Storage órdenes/año
2 KB × 1M = 2 GB/día.
~60 GB/mes; +metadata catálogo.
~24 MB
Redis sorted sets
3 ventanas × ~1M productos.
Cache redirect: ~10 GB top calientes.
60/s
Eventos al stream
~5M/día · ~1.8B/año.
5 items/orden × 12 órdenes/s avg.
5–30 s
Edge cache TTL
Reduce origen ~95%.
~50K req/s al edge en peak.
99.9%
SLO disponibilidad
~8.7 h / año · ~43 min / mes.
~1.4 min / día permitidos.

Endpoints

Lectura cacheada del top y escritura de eventos en path crítico mínimo.

MétodoPathDescripciónRespuesta
GET/api/products/top?window=1h&limit=10Devuelve top productos de la última hora.200 lista ordenada
GET/api/products/top?window=4h&limit=10Top 4 horas, lectura de cache.200
GET/api/products/top?window=24h&limit=10Top 24 horas, ventana más estable.200
POST/api/ordersCrea orden y emite evento de compra al stream.201 con orderId
GET/api/products/{id}Detalle de producto para hidratar el top.200

Cómo defiendo estas APIs en la entrevista

Recursos primero, luego operaciones. Identifico dos sustantivos: products (con sub-recurso top) y orders. Los expongo en plural y kebab-case, IDs en path. La acción "obtener top" es GET idempotente — cacheable en CDN. El checkout es POST /orders, que crea recurso → respuesta 201 con Location header.

Idempotencia y status codes. El POST /orders exige Idempotency-Key en header — si el cliente reintenta tras timeout, no duplico la orden. Si la key ya existe con mismo body devuelvo la respuesta cacheada; con body distinto devuelvo 409 Conflict. 429 con Retry-After si cae rate limit; 503 en circuit breaker abierto.

Versionado y pagination. Versiono en URL path (/v1) — explícito, fácil de debuggear, lo que usa Stripe y Google Cloud. Para listados de órdenes uso cursor opaco (no offset) porque escala mejor con miles de órdenes y no se rompe con inserts concurrentes.

Frase clave: "Las APIs siguen patrón resource-oriented. GET /products/top nunca toca la DB transaccional; va directo a Redis. POST /orders es ACID en Postgres + emit a Kafka — el cliente cierra el request en milisegundos sin esperar a que el ranking se actualice."

Ejemplo de respuesta

El cliente recibe IDs y datos mínimos; los detalles se hidratan con cache aparte.

GET /api/products/top?window=1h&limit=10

{
  "window": "1h",
  "generatedAt": "2026-05-01T12:30:00Z",
  "items": [
    { "productId": "p_8821", "rank": 1, "score": 1842 },
    { "productId": "p_5510", "rank": 2, "score": 1217 },
    { "productId": "p_9034", "rank": 3, "score":  998 }
  ]
}

Arquitectura

Path de escritura desacoplado. Las lecturas no tocan la base transaccional.

flowchart LR Client(["Cliente Web/Móvil"]) --> Edge(["CDN / Edge Cache
TTL 5–30s"]) Edge -->|GET top| GW{{"🌐 API GATEWAY
Auth + Rate Limit"}} GW -->|read| Cache GW -->|POST /orders| Order["Order Service"] Order --> DB Order -->|emit event| Stream Stream --> Agg["Aggregator
1-min buckets
sliding window"] Agg --> Cache Agg --> Cold Cache -. cache miss .-> Agg subgraph Cache["💾 Redis · Sorted Sets"] direction TB rs1["top:1h:region"] rs2["top:4h:region"] rs3["top:24h:region"] rs4["bucket:1m:ts"] end subgraph DB["💾 Orders DB · Postgres"] direction TB d1["orders"] d2["order_items"] d3["products"] end subgraph Stream["📨 Kafka"] direction TB k1["order_events"] end subgraph Cold["🗄️ Cold Storage · BigQuery / S3"] direction TB c1["orders_archive"] c2["events_archive"] end classDef gateway fill:#fbbf24,stroke:#f59e0b,stroke-width:3px,color:#0f172a,font-weight:bold classDef edge fill:#1e1b4b,stroke:#a78bfa,stroke-width:2px,color:#e0e7ff classDef service fill:#0c4a6e,stroke:#38bdf8,stroke-width:2px,color:#e0f2fe classDef client fill:#1f2937,stroke:#94a3b8,stroke-width:2px,color:#e5e7eb class GW gateway class Edge edge class Order,Agg service class Client client

Cómo lo explico en la entrevista

Read path. Cuando el cliente abre la home y pide el top, el browser pega al edge. Si el edge tiene la respuesta en cache (TTL 5–30 s), responde directo. Si no, va al API Gateway que valida auth y rate limit, y de ahí al Redis sorted set top:1h:{region}. Hago ZREVRANGE 0 9 WITHSCORES en O(log N), hidrato con metadata de producto y devuelvo. Nunca toco la base transaccional para servir el top.

Write path. Al hacer checkout, el Order Service escribe la orden en Postgres (fuente de verdad transaccional, ACID) y emite un evento al Kafka order_events. El Aggregator consume el stream, agrupa en buckets de 1 minuto, y al cerrar cada bucket suma sobre los últimos 60/240/1440 buckets para mantener las tres ventanas en Redis con un ZINCRBY. La misma corriente se sinkea a BigQuery para análisis offline.

Frase clave: separo la ruta crítica de checkout del cálculo del ranking; el ranking se construye asíncrono, así un pico de tráfico en el top no afecta la creación de órdenes y viceversa.

Por qué cada componente

CDN / Edge CacheAbsorbe 95%+ del tráfico de lectura sin tocar el origen. TTL corto + stale-while-revalidate.
API GatewayAuth, rate limit por user/IP, observabilidad central. Un solo lugar para políticas.
Redis Sorted SetTop-K en O(log N) con ZREVRANGE. Las DBs relacionales no escalan a este patrón.
Order Service + PostgresACID en checkout no es negociable. Postgres por madurez, joins y transacciones fuertes.
Kafka order_eventsDesacopla el path crítico del cómputo del ranking. Permite reprocesar y agregar consumers sin tocar productores.
Aggregator (sliding window)Buckets de 1 min se combinan para 1h/4h/24h. Stateless, escala horizontal por partición.
Cold Storage (BigQuery)Históricos para BI sin presionar la base operacional. Permite cohortes y trends largos.
Idempotency-Key en POSTEvita doble cobro / doble orden si el cliente reintenta tras timeout.

Cuellos de botella

  • Hot products. Un viral concentra escrituras en un solo shard de Redis.
  • Cache stampede. Si el edge expira al mismo tiempo en muchas regiones, miles de pods tocan al aggregator.
  • Aggregator lag. Si el stream se llena, el ranking deja de reflejar la realidad reciente.
  • Cancelaciones tardías. Una orden que se canceló pero ya impactó el ranking deja "fantasmas" en el top.
  • Catálogo grande. 1M+ productos × 3 ventanas crece el sorted set; consumo de memoria.

Mejoras / cómo escala

  • Particionar sorted sets por hash(productId) y agregar a top-K final por reduce.
  • Stale-while-revalidate en edge: sirve viejo mientras el agregador refresca en background.
  • Backpressure + DLQ en el stream para no perder eventos, alertas si lag > 30 s.
  • Eventos de compensación para cancelaciones (negative count), aplicados al mismo bucket.
  • HyperLogLog / Count-Min Sketch en ventanas de 24h para conteo aproximado y memoria fija.
  • Active-active multi-region con sorted set por región y merge para vista global.

Por qué estos servicios cloud (vs alternativas)

Apache Kafkastream events

Por qué: high-throughput append-only log con particionado por productId, replay desde offset, multi-consumer fan-out (aggregator + analytics + audit) sobre el mismo stream.

vs Kinesis (AWS): equivalente pero AWS-only, retention max 365d, throughput por shard limitado. vs Pub/Sub (GCP): más simple pero menos control de retention y partition keys. vs SQS / RabbitMQ: queue point-to-point — no permite replay ni múltiples consumers leyendo independientemente.

Redis (ElastiCache / Memorystore)sorted sets + cache

Por qué: sorted sets nativos con ZRANGE/ZINCRBY en O(log N), TTL granular, scripts Lua para operaciones atómicas multi-comando. Es el data structure server.

vs Memcached: sin estructuras ordenadas — habría que reconstruir top-K en cada request. vs DynamoDB: latencia P99 ~10 ms vs <1 ms; no tiene primitivas de sorted set. vs Postgres: escanear la tabla cada query mata el SLO.

PostgreSQL (RDS / Cloud SQL)orders DB

Por qué: ACID estricto en checkout, foreign keys, partial indexes, JSONB para campos semi-estructurados, SERIALIZABLE isolation cuando hace falta. Mature y bien soportado en cloud-managed.

vs MySQL: equivalente pero PG tiene mejor soporte para JSONB, partial indexes y CTEs. vs DynamoDB: sin joins ni transacciones multi-row complejas — mal fit para checkout. vs CockroachDB / Spanner: overkill para single-region; agrega latencia.

BigQuery / Snowflake / Redshiftcold storage analytics

Por qué: data warehouse columnar serverless, SQL nativo, escala a PB sin provisioning. Reportes históricos sin presionar la DB operacional.

vs cargar en Postgres: imposible a escala — el OLAP destruiría el OLTP. vs Hadoop / Spark sobre S3: más operativo, requiere ETL. vs ClickHouse: aceptable y más barato pero requiere ops propio.

CloudFront / Cloud CDN / Fastlyedge cache

Por qué: POPs globales, TTL granular con stale-while-revalidate, cache key por (window, region), headers de control fáciles de configurar.

vs servir desde el origin: 95% del tráfico de lectura saturaría el sistema. vs Varnish self-hosted: requiere ops y no escala globalmente sin trabajo.

Diseño de base de datos

Postgres para órdenes, Redis para rankings, Kafka para eventos.

orders
order_idUUID PK
user_idUUID FK
regionVARCHAR(8)
statusENUM
total_centsBIGINT
currencyCHAR(3)
created_atTIMESTAMPTZ
idx (user_id, created_at DESC)
idx (status, created_at)
order_items
order_idUUID PK,FK
line_noSMALLINT PK
product_idUUID FK
qtyINT
price_centsBIGINT
idx (product_id) para análisis por producto
products
product_idUUID PK
nameTEXT
category_idUUID FK
price_centsBIGINT
inventoryINT
regionVARCHAR(8)
idx (category_id), idx (region)
Redis · sorted sets
top:1h:{region}ZSET
top:4h:{region}ZSET
top:24h:{region}ZSET
bucket:1m:{ts}HASH
score = count, member = product_id
ZRANGEBYSCORE / ZREVRANGE para top-K en O(log N)

Cómo defiendo este diseño de base de datos

La regla de oro: la herramienta correcta para cada job. Postgres para órdenes porque necesito ACID (no puedo perder un checkout); Redis sorted sets para el top porque top-K en O(log N) sin escanear; Kafka para eventos porque desacopla; BigQuery para análisis porque columnar serverless. Tres data stores, cada uno haciendo lo que mejor sabe.

Índices justificados por queries. En orders indexo (user_id, created_at DESC) para "mis órdenes recientes" y (status, created_at) para queries de ops/dashboard. En order_items indexo product_id para análisis por SKU. No agrego índices "por si acaso" — cada índice cuesta en write throughput.

Particionado y consistency. orders particionada por created_at (range partitioning mensual) — los archivos viejos son fríos, los recientes calientes. Redis sorted set por region (top:1h:LATAM, top:1h:NA) para no mezclar regiones. Postgres es strong consistency; Redis es eventually consistent (segundos OK para ranking).

Frase clave: "El diseño separa el path transaccional (Postgres ACID) del path analítico (sorted sets pre-agregados). Si me pides top global combinaría sorted sets por región con un merge en O(K log K)."

Decisiones técnicas

TemaDecisiónPor qué
Almacenamiento del rankingRedis Sorted Set por ventana (top:1h, top:4h, top:24h).Escribir y leer top-K en O(log N), TTL natural por ventana.
IngestiónEventos asíncronos vía Kafka.Desacoplar el checkout del cómputo del ranking.
AgregaciónSliding window con buckets de 1 min.Combinar 60 buckets para 1h, 240 para 4h, 1440 para 24h.
Cache de lecturaEdge cache con TTL 5–30s.Las consultas son popularmente repetidas y tolerantes a delay.
IdempotencyClave por orderId.Evitar contar la misma compra dos veces si el productor reintenta.
HidrataciónCliente pide detalles en lote a /api/products/{id}.El ranking se queda ligero y no se invalida si cambia el catálogo.

Cómo defiendo estas decisiones técnicas

Cada decisión sale de un constraint del problema. Lecturas ≫ escrituras (~1000:1) → cache agresivo en edge + Redis pre-agregado. Black Friday → desacoplar checkout de ranking, escalar productores y consumidores independientemente. Hot products → sharding por productId hash y replicación de reads. Cancelaciones → eventos de compensación al mismo bucket, no al sorted set en vivo.

Tradeoffs explícitos. Cache TTL 5–30 s sacrifica frescura por carga al origen — el negocio acepta segundos de delay en el ranking. Idempotency-Key añade ~1 ms por request pero previene doble cobro (worth it). Sliding window con buckets de 1 min sacrifica precisión sub-minuto por memoria fija (O(1) per window).

Métricas que validan cada decisión. Cache hit ratio > 95% (si baja, costo se dispara). p99 lectura del top < 100 ms. Stream lag < 30 s para que el ranking refleje realidad. Idempotency duplicate rate (debe ser ~0). Drop rate de eventos en aggregator (debe ser 0; alerta a 1%).

Cuándo cambio de opinión. Si hay strong consistency requirement en el ranking → quito cache, sirvo directo desde sorted set + lectura síncrona. Si entran categorías muy desbalanceadas → sharding por (category, region) en vez de region sola. Si volumen baja a <100 órdenes/min → simplifico a un cron sobre Postgres y olvido Kafka.

Frase clave: "Cada decisión está atada a una métrica: cache hit ratio para costos, p99 para experiencia, stream lag para frescura, duplicate rate para correctness. Si alguna métrica se degrada, sé exactamente qué decisión revisitar."

Pitfalls

No escanear la tabla de órdenes

Cada lectura del top no debe tocar la base transaccional, sólo cache o sorted set.

Cuidado con cache stampede

Si expira el top a la vez en muchas regiones, miles de pods pegan al aggregator a la vez.

Hot keys

Productos virales pueden saturar un solo shard de Redis. Particiona por productId hash.

Script de respuesta: “Trato la creación de orden como recurso REST con POST /api/orders, pero emito un evento al stream para que un agregador mantenga sorted sets en Redis con buckets de 1 minuto. La API GET /api/products/top?window=... sólo lee de Redis, no de la DB transaccional. Pongo TTL corto en edge para absorber tráfico, idempotency por orderId para no contar dos veces, y mido p99 más tasa de cache hit.”

Tiers de almacenamiento

Dónde vive cada tipo de dato según frecuencia de acceso. Mover data fría a tiers baratos baja el costo 5–20×.

Hot · in-memory
Top sorted sets de las 3 ventanas, productos calientes con metadata ligera.
Redis (ElastiCache / Memorystore)
~$0.05/GB/h · ~$36/GB/mes
Warm · SSD operacional
Órdenes recientes (~12 meses), catálogo de productos, índices de Postgres.
RDS / Cloud SQL gp3 SSD
~$0.10–0.20/GB/mes
Cold · object storage IA
Órdenes 1–7 años (compliance retention), eventos archivados de Kafka.
S3 Standard-IA / GCS Nearline
~$0.0125/GB/mes · retrieval $0.01/GB
Frozen · archive
Órdenes > 7 años (retención legal), históricos para forensia.
S3 Glacier Deep Archive / GCS Archive
~$0.001/GB/mes · retrieval ~12 h

Cómo defenderlo en la entrevista

Lifecycle policy automática. Configuro S3 lifecycle rules: orders en Standard los primeros 90 días → Standard-IA tras 90 días → Glacier Instant Retrieval tras 1 año → Glacier Deep Archive tras 5 años. Esto pasa sin intervención manual y baja el costo total ~10× para data fría.

Trade-off de retrieval. Standard-IA es 60% más barato pero cobra $0.01/GB al leer y tiene latencia ~100ms. Glacier Deep Archive es ~30× más barato pero retrieval tarda 12 h. Para órdenes consultadas raramente (auditoría, resolución de disputas) ese delay es aceptable.

Frase clave: "Data caliente vive en RAM (Redis) porque la consulto cada segundo. Data tibia en SSD (Postgres) porque la consulto diariamente. Data fría en S3-IA porque la consulto mensualmente. Data archive en Glacier porque solo la toco ante incidente o auditoría."

Versión alternativa: con Load Balancer (no API Gateway)

Si te preguntan "¿y si no usas API Gateway?", esta es la versión equivalente y los tradeoffs.

flowchart LR Client(["Cliente Web/Móvil"]) --> Edge(["CDN / Edge Cache
TTL 5–30s"]) Edge --> LB{{"⚖️ L7 LOAD BALANCER
NGINX / ALB / Cloud LB
TLS + path routing"}} LB --> Pod["API Pods
+ Auth Middleware (JWT)
+ Rate Limit (Redis)"] Pod -->|read top| Cache Pod -->|POST /orders| Order["Order Service"] Order --> DB Order -->|emit| Stream Stream --> Agg["Aggregator"] Agg --> Cache subgraph Cache["💾 Redis · Sorted Sets"] direction TB rs1["top:1h:region"] rs2["top:4h:region"] rs3["top:24h:region"] end subgraph DB["💾 Orders DB · Postgres"] direction TB d1["orders"] d2["order_items"] d3["products"] end subgraph Stream["📨 Kafka"] direction TB k1["order_events"] end classDef lb fill:#34d399,stroke:#10b981,stroke-width:3px,color:#0f172a,font-weight:bold classDef edge fill:#1e1b4b,stroke:#a78bfa,stroke-width:2px,color:#e0e7ff classDef service fill:#0c4a6e,stroke:#38bdf8,stroke-width:2px,color:#e0f2fe classDef client fill:#1f2937,stroke:#94a3b8,stroke-width:2px,color:#e5e7eb class LB lb class Edge edge class Pod,Order,Agg service class Client client

API Gateway vs Load Balancer: cómo defender cada uno

El cambio principal: el LB solo hace TLS termination y routing por path/host. Auth y rate limit se vuelven middleware en los API pods (o sidecars con Envoy/Istio). El LB es L7 (NGINX, ALB, Google Cloud LB) si necesito routing por path; L4 (NLB) si solo distribuyo TCP.

Frase clave: "Para este e-commerce me quedo con API Gateway si el catálogo expone APIs públicas a partners (B2B con quotas y plans). Si es solo para el frontend propio y la app móvil, el LB es más barato y rápido — auth y rate limit van en código de la app."

Cuándo elegir API Gateway

  • APIs públicas / B2B con developer keys, quotas, plans monetizados.
  • Múltiples consumidores (web, móvil, partners, internos) con políticas distintas.
  • Per-route config: rate limit distinto en /orders vs /search.
  • Request transformation (versionado de schemas, mapping de campos).
  • Centralizar observability: latency/error rate por endpoint sin instrumentar cada servicio.
  • Caching managed de respuestas comunes a nivel gateway.

Cuándo elegir Load Balancer

  • Latencia crítica — LB ahorra ~5–30 ms por request al saltar el gateway.
  • Costo a alta escala: API Gateway cobra por request; LB por LCU u hora.
  • Tráfico interno entre microservicios: gateway es overkill.
  • Throughput masivo (M+ QPS) — NLB L4 es más rápido y barato.
  • Stack ya tiene service mesh (Istio/Linkerd) que cubre auth/rate limit por sidecar.
  • Auth simple (JWT con verificador local en pod) sin necesidad de OAuth flows complejos.