Diseño de sistemas backend según cuellos de botella: desde acortadores de URL hasta comercio electrónico
Un enfoque centrado en los requisitos para diseñar backends de Node.js: cuándo agregar balanceadores de carga, Redis, réplicas, colas y límites de tasa, y cuánto le cuesta cada uno.
Fuentes: System Design for Backend Engineers: A Practical Guide With Real Examples
Muchos ingenieros abordan el diseño de sistemas creando un diagrama: un balanceador de carga, Redis, Kafka, una pasarela API y una docena de microservicios. El resultado parece impresionante, pero no explica nada. Ya sea en una entrevista o en un producto real, el diseño consiste en elegir los componentes adecuados para un problema específico y poder justificar cada uno de ellos. Esta guía explica ese razonamiento a través de dos sistemas concretos, un acortador de URLs y un backend de comercio electrónico pequeño, para que puedas desarrollar una arquitectura paso a paso, justificando cada decisión y identificando los compromisos que implica cada uno de esos pasos.
Comienza con los requisitos, no con la tecnología
El error más común al principio es empezar con las herramientas. Alguien describe un problema y los primeros pensamientos son “¿Redis o no? ¿Kafka? ¿Microservicios?” Ese orden está al revés. Hasta que no sepas qué debe hacer el sistema y a qué escala, no tienes base para elegir nada.
Tomemos un acortador de URLs. Antes de dibujar siquiera un boceto, necesitas respuestas a preguntas como estas:
- ¿Cuántos usuarios tendrá y cuántas URLs crearán?
- ¿Qué tasa de solicitudes deberá manejar en su punto máximo?
- ¿Cuánto tiempo permanecen válidos los enlaces y pueden expirar?
- ¿Necesitan los usuarios análisis de clics?
- ¿Qué cantidad de tiempo de inactividad es aceptable?
- ¿Con qué rapidez debe ocurrir el redirección?
Cada respuesta orienta el diseño. Un servicio que recibe tanta cantidad de tráfico:
1,000 requests/day
casi no tiene nada en común con uno que debe soportar esta cantidad:
10 million requests/secon
El primero se adapta cómodamente a un servidor pequeño. El segundo obliga a tomar decisiones sobre caché, replicación y geografía desde el primer día.
Comience con la arquitectura más simple que funcione
Para un acortador de URLs pequeño, la primera versión puede ser casi trivialmente sencilla:
Client
↓
Node.js API
↓
PostgreSQL
La API genera un código corto y devuelve un enlace como este:
https://example.com/abc123
Luego almacena una única asociación en PostgreSQL:
short_code → original_url
Cuando un navegador solicita una ruta como la que se muestra a continuación, la API busca la URL original y responde con una redirección:
/abc123
Para una carga de trabajo moderada, se trata de un sistema completo y correcto. Nada en él requiere los elementos habituales:
Kafka
Redis
Kubernetes
Microservices
Añadirlos solo porque están disponibles genera costos operativos adicionales, más modos de fallo y más cosas que aprender para cada nuevo miembro del equipo. Lanza algo que funcione, métralo y luego escala aquellas partes que resulten ser cuellos de botella.
Escala horizontal cuando un servidor no es suficiente
Supongamos que el acortador de enlaces se vuelve popular y su único proceso API queda sobrecargado:
1 API server
Podrías seguir comprando máquinas más grandes, pero la escalabilidad vertical tiene un límite y una sola máquina representa un único punto de fallo. La solución más común es ejecutar varias instancias idénticas detrás de un balanceador de carga:
Load Balancer
↓
┌───────────┼───────────┐
↓ ↓ ↓
Node API Node API Node API
↓ ↓ ↓
└───────────┼───────────┘
↓
PostgreSQL
Las solicitudes ahora se distribuyen entre múltiples instancias de la aplicación. Esto es escalabilidad horizontal: en lugar de depender de
1 huge server
dependes de
multiple smaller servers
La escala horizontal solo funciona si las instancias son intercambiables, y eso plantea preguntas que antes no existían:
- ¿Dónde se almacena el estado de la sesión, si la próxima solicitud de un usuario podría dirigirse a una instancia diferente?
- ¿Es realmente sin estado cada instancia?
- ¿A dónde van los archivos subidos, ya que el disco local ya no se comparte?
- ¿Cómo accede cada instancia a la infraestructura compartida como la base de datos?
Escalar un componente tiende a generar requisitos en otro lugar; aquí, el estado debe trasladarse a almacenes compartidos.
Agregue un caché cuando realmente tenga un problema de caché
Los enlaces cortos populares se solicitan una y otra vez:
/abc123
/abc123
/abc123
/abc123
/abc123
No hay razón para que cada una de esas solicitudes consulte PostgreSQL. Un caché frente a la base de datos proporciona las claves frecuentemente utilizadas desde la memoria, recurre a la base de datos en caso de fallo y almacena el resultado para la próxima vez:
Client
↓
Load Balancer
↓
Node.js API
↓
Redis
│
├── Cache Hit → Return URL
│
└── Cache Miss
↓
PostgreSQL
↓
Redis
Este flujo de caché aparte puede eliminar la mayor parte de la carga de lectura de la base de datos en trabajos con cargas desiguales como las redirecciones de enlaces. También plantea una nueva pregunta: si el destino de un enlace corto se modifica o se elimina, ¿cómo se entera la copia en caché? Ahora es necesario contar con una estrategia de invalidación, ya sea eliminando la clave en cada escritura, estableciendo un tiempo de vida o ambos. Para conocer más sobre estas opciones, consulte nuestra guía sobre patrones y problemas de caché en Redis.
Cada mejora en esta guía conlleva un costo como este. La capacidad de identificar ese costo es lo que diferencia a un diseño de un diagrama.
Arregle la base de datos antes de agregar más
En algún momento PostgreSQL podría parecer el cuello de botella. Antes de añadir nueva infraestructura, averigüe por qué es lento. Las causas comunes incluyen:
- Faltan índices en las columnas utilizadas para búsquedas
- Consultas o uniones ineficientes
- Patrones de consulta N+1 en la aplicación
- Se devuelven muchas más filas o columnas de las necesarias
- Paginación deficiente, como offsets grandes
- Un pool de conexiones mal configurado o agotado
Muy a menudo la solución es una sola instrucción:
CREATE INDEX ...
Eso es más económico y seguro que un componente nuevo. La regla a recordar: arregle el cuello de botella que haya medido antes de añadir arquitectura alrededor de él.
Replicas de lectura para cargas de trabajo con mucho tráfico de lectura
Muchas aplicaciones leen mucho más de lo que escriben, por ejemplo:
90% reads
10% writes
En esa situación, la base de datos principal puede quedar abrumada por el tráfico de lectura. Una solución es agregar replicas de lectura: la principal acepta todas las escrituras, mientras que las replicas se encargan de parte de las lecturas.
Node.js API
↓
┌────────┴────────┐
↓ ↓
Primary DB Read Replica
↓ ↑
Writes Reads
El costo del retraso en la replicación
En la mayoría de las configuraciones, las replicas se actualizan de forma asíncrona, por lo que una escritura llega a la principal antes de llegar a una replica:
Write → Primary
↓
Replication
↓
Read Replica
Si una solicitud escribe algo y luego lo lee inmediatamente desde una replica, es posible que vea datos obsoletos durante un breve período. Por lo tanto, la aplicación debe saber qué lecturas necesitan el estado más reciente. Un enfoque común es enviar tráfico de tipo “lee tus propias escrituras”, como cargar un perfil justo después de guardarlo, a la principal, y dirigir todo lo demás a las replicas.
Traslade las tareas lentas a trabajos en segundo plano
Muchas operaciones no necesitan finalizarse antes de que el usuario reciba una respuesta:
- Envío de correos electrónicos
- Generación de facturas
- Entrega de notificaciones
- Elaboración de informes
- Llamadas a servicios externos lentos
En lugar de hacer esperar al usuario, la API registra lo que debe realizarse, agrega la tarea a una cola y responde de inmediato:
Client
↓
Node.js API
↓
Create Record
↓
Queue Job
↓
Response
Un proceso trabajador separado toma la tarea y realiza el trabajo:
Queue
↓
Worker
↓
Process Job
En la práctica, una sola cola puede alimentar a trabajadores que manejan varios tipos de tareas:
API
↓
Redis / Queue
↓
Worker
├── Email
├── Invoice
└── Notification
Esto mantiene baja la latencia de las solicitudes y proporciona un lugar controlado para los intentos repetidos y los límites de rendimiento. También conlleva su propio conjunto de problemas a considerar en el diseño:
- Una tarea puede ejecutarse más de una vez, por lo que los procesadores deben tolerar duplicados.
La escalabilidad nunca es gratuita. Si estás desarrollando esto en Node.js, nuestra guía sobre sistemas de trabajos en segundo plano con BullMQ y Redis aborda estos problemas mediante código.
Diseñar para el fallo antes de que ocurra
El trabajo interesante de diseño comienza cuando dejas de preguntarte solo qué sucede cuando todo funciona y empiezas a preguntarte qué ocurre cuando algo falla.
Funcionar de manera adecuada cuando una dependencia deja de funcionar
Supongamos que Redis deja de estar disponible. ¿Debería detenerse toda la API? Si Redis se utiliza únicamente como caché, probablemente no. La API puede omitir el caché y leer directamente de PostgreSQL, lo cual es más lento pero aún así correcto:
Request
↓
Redis
↓
FAIL
↓
PostgreSQL
↓
Response
Si Redis también respalda la cola de tareas o el limitador de frecuencia, una interrupción es mucho más grave, ya que esas funciones no tienen una solución de respaldo sencilla. El diseño debe dejar claro el papel de cada componente, ya que ese papel determina cómo se comportará el sistema cuando dicho componente falle.
Establecer un tiempo de espera en cada llamada externa
Imagínese que la API llama a un servicio de pago externo sin tener configurado ningún tiempo de espera:
API
↓
Payment Service
↓
... waiting ...
↓
... waiting ...
↓
... waiting ...
Mientras espera, el servidor mantiene ocupados sockets, memoria y espacios para solicitudes relacionados con una dependencia que podría no responder nunca. Bajo carga, esas solicitudes atascadas pueden agotar todo el proceso. Un sistema bien diseñado establece un plazo límite para cada llamada:
API
↓
Payment Service
↓
5 second timeout
↓
Failure
Una vez que pasa el plazo, la aplicación debe decidir cómo proceder, y la elección correcta depende de la operación:
- Volver a intentar la llamada
- Devolver un error al cliente
- Poner la operación en cola para más tarde
- Usar una solución alternativa
- Marcar la operación como pendiente y reconciliarla posteriormente
Las reintentos solo son seguros cuando las operaciones son idempotentes
Después de que una llamada de pago se agota el tiempo, parece obvio volver a intentarla. Pero un tiempo de espera no indica si el pago falló. Es posible que el cobro haya tenido éxito y solo se haya perdido la respuesta. Un reintentar a ciegas podría facturar al cliente dos veces.
La solución es la idempotencia: adjuntar un identificador estable a la operación para que el lado receptor pueda reconocer una repetición.
payment_id = order_123
El proveedor de pagos utiliza esa clave para detectar que la solicitud ya fue procesada y devuelve el resultado original en lugar de cobrar nuevamente:
Request
↓
Idempotency Key
↓
Payment Provider
↓
Already processed?
├── Yes → Return previous result
└── No → Process payment
Añadir intentos de reintentar la operación requiere una sola línea de código. Hacer que sean seguros necesita la colaboración de todo el sistema. Nuestro artículo sobre claves de idempotencia en endpoints POST de Node.js muestra cómo implementar esto por uno mismo en el lado receptor.
Proteger el sistema con limitación de frecuencia
Ahora supongamos que un único cliente comienza a enviar tanta cantidad de tráfico:
100,000 requests/minute
Aceptar todo ese tráfico permitiría que un único usuario dañara el servicio para todos los demás. Un limitador de frecuencia se coloca frente a la API y rechaza las solicitudes excesivas con el código HTTP 429:
Client
↓
Rate Limiter
↓
Allowed?
├── Yes → API
└── No → 429
Con varias instancias de API, los contadores deben compartirse, y por eso Redis es un almacén de respaldo popular para el limitado de tasas distribuido: cada instancia incrementa las mismas claves.
Los límites deben reflejar lo que hace cada endpoint. Un endpoint de inicio de sesión es un objetivo para ataques de fuerza bruta, por lo que un límite estricto como este es razonable:
Login:
10 requests/minute
Por el contrario, una lista pública de productos puede tolerar mucho más:
GET /products:
1000 requests/minute
Establezca límites basados en el costo de la operación y su modelo de amenazas, en lugar de aplicar un número global.
El diseño del sistema consiste principalmente en compromisos
Rara vez existe una arquitectura perfecta única. La mayoría de las decisiones son elecciones entre opciones que son buenas para cosas diferentes.
SQL versus NoSQL
Una base de datos relacional ofrece un sólido modelado relacional, transacciones y consultas expresivas. Un almacén NoSQL puede ofrecer diferentes características de escalabilidad, esquemas flexibles y ventajas para cargas de trabajo específicas. La estructura de sus datos y sus patrones de acceso determinan cuál opción es la más adecuada.
Caché versus base de datos
Un caché es rápido pero temporal, y añade complejidad en cuanto a su invalidación. La base de datos es duradera y sigue siendo la fuente de verdad, aunque puede ser más lenta para los datos que se leen constantemente.
Monolito versus microservicios
Un monolito es más simple, fácil de desplegar y de depurar en las etapas iniciales. Los microservicios permiten un despliegue y escalabilidad independientes, así como límites más claros entre los equipos, pero a costa de una mayor complejidad en los sistemas distribuidos.
En general, ninguna opción es correcta por sí sola. Son los requisitos los que deciden.
Preferir un monolito modular a los microservicios iniciales
Esta opción merece su propia sección, porque es donde el sobrediseño resulta más costoso. Un nuevo producto no necesita comenzar como un conjunto de servicios como estos:
Auth Service
User Service
Product Service
Order Service
Payment Service
Notification Service
Una opción mejor por defecto es una única aplicación desplegable:
Node.js Application
organizada internamente en módulos con límites claros:
├── Auth
├── Users
├── Products
├── Orders
├── Payments
└── Notifications
Se obtiene una única base de código, un único despliegue y una separación explícita entre los dominios. Si más adelante un módulo necesita escalar o desplegarse de forma independiente, los límites claros facilitan su extracción.
Los microservicios sí resuelven problemas reales de organización y escalabilidad. Sin embargo, también generan fallos en la red entre servicios, necesidad de rastreo distribuido, sistema de descubrimiento de servicios, pipelines de despliegue más complejos, desafíos de consistencia de datos entre los límites de los servicios, además de una mayor cantidad de tareas de monitoreo e infraestructura. Acepte esos costos solo cuando necesite los beneficios.
Resuelva cada cuello de botella al nivel más pequeño que funcione
Considere un sistema simple de tres capas con una base de datos lenta:
Client
↓
API
↓
Database
Existen diferentes opciones de respuesta, desde la más económica hasta la más disruptiva. Si una consulta específica es lenta, óptimícela:
Slow query
↓
Optimize query
Si las búsquedas escanean toda la tabla, agregue un índice:
Missing index
↓
Add index
Si los mismos datos se recuperan repetidamente, agregue una caché:
Repeated reads
↓
Add cache
Si las lecturas dominan el uso general, agregue réplicas:
Read-heavy workload
↓
Read replicas
Solo a una escala realmente grande se recurre a cambios estructurales:
Massive scale
↓
Partitioning / sharding / architectural changes
El orden es clave. Cada paso se da únicamente cuando el anterior ya no es suficiente, y cada uno añade más complejidad que el anterior.
Un proceso reproducible para las entrevistas de diseño
Ese mismo razonamiento se aplica a las entrevistas de diseño de sistemas. Una secuencia sólida es:
- Clarificar los requisitos funcionales y no funcionales.
- Estimar el tráfico y el almacenamiento.
- Proponer la arquitectura más simple que los satisfaga.
- Identificar dónde fallará primero.
- Escalar específicamente esos cuellos de botella.
- Analizar los escenarios de fallo.
- Discutir las compensaciones que se están haciendo.
- Justificar cada componente que se haya añadido.
Ese enfoque es mucho más convincente que dibujar veinte cuadros de inmediato. Un diagrama pulido sin razonamiento detrás de él no constituye un buen diseño.
Reuniendo todo: un backend pequeño para comercio electrónico
Al aplicar estas ideas a una tienda en línea se obtiene una arquitectura inicial razonable como esta:
Client
↓
Load Balancer
↓
┌────────┴────────┐
↓ ↓
Node API Node API
│ │
└────────┬────────┘
↓
┌────────┼────────┐
↓ ↓ ↓
Redis PostgreSQL Queue
│ ↓
│ Worker
│ ↓
│ Email / Invoice
│
↓
External APIs
Payment / Shipp
Lo que la hace defendible es que cada componente responde a una pregunta:
- Redis se encarga del caché, los datos de corta duración, los contadores de límites de velocidad e infraestructura de colas.
- PostgreSQL almacena datos empresariales duraderos y proporciona transacciones relacionales para pedidos y pagos.
- La cola absorbe tareas lentas y asíncronas.
- Múltiples instancias de API ofrecen escalabilidad horizontal y redundancia.
- El balanceador de carga distribuye el tráfico entre esas instancias.
Tenga en cuenta que Redis desempeña varias funciones aquí. Eso es eficiente, pero también significa que una interrupción en Redis afecta al caché, a la limitación de velocidad y al procesamiento de tareas al mismo tiempo, lo cual es exactamente el tipo de dependencia que la análisis de fallos anterior debería revelar.
Seis preguntas que hacer antes de finalizar un diseño
- ¿Qué problema resuelve esto? Si no puede expresarlo claramente, la arquitectura aún no está lista.
- ¿Qué escala se requiere? Usuarios, tasa de solicitudes, volumen de datos, objetivos de almacenamiento y latencia.
La complejidad adecuada supera a la complejidad impresionante
La mayor trampa al aprender el diseño de sistemas es creer que una arquitectura más complicada debe ser mejor. No es así; solo es más compleja. Un buen diseño busca lograr una complejidad adecuada.
Si una aplicación Node.js y una base de datos PostgreSQL cumplen con los requisitos, esa es una arquitectura perfectamente válida. Cuando el tráfico aumente, agregue instancias. Cuando las lecturas se conviertan en un cuello de botella, óptimícelas. Cuando el caché sea útil, agregue Redis. Cuando el trabajo pueda realizarse más tarde, añada una cola. Cuando un módulo realmente necesite escalabilidad o despliegue independiente, considere extraerlo. La arquitectura es algo que evoluciona junto con el producto.
Puntos clave
- Primero vienen los requisitos y las estimaciones aproximadas de capacidad; las elecciones tecnológicas se derivan de ellos.
- Comience con el sistema funcional más simple y escale solo aquello que las mediciones indiquen como cuello de botella.
- Cada adición tiene un costo: estado compartido para la escalabilidad horizontal, invalidación para los cachés, retraso en las réplicas y duplicados en las colas.
- Planifique explícitamente los fallos con soluciones alternativas, tiempos de espera, intentos idempotentes y límites de tasa.
Lecturas relacionadas
- Diseño de API en Node.js en capas: de controladores complejos a arquitectura limpia — Aprenda cómo refactorizar una API de Node.js en capas de controlador, servicio y acceso a datos para solucionar la lógica empresarial enredada, los errores inconsistentes y las dificultades de escalado.