Inicio / Artículos

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.

Editor: REACTAPP.TOP13 min de lectura3028 palabras

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.
  • Los intentos agresivos de reintentar una dependencia que falla pueden convertirse en una tormenta de reintentos.
  • Los trabajos fallidos necesitan un lugar al que ir y a alguien que los revise.
  • Algunos flujos de trabajo requieren garantías de ordenamiento.
  • Los procesadores deben ser idempotentes.
  • Es necesario monitorear la profundidad de la cola y las tasas de fallo.
  • 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:

    1. Clarificar los requisitos funcionales y no funcionales.
    2. Estimar el tráfico y el almacenamiento.
    3. Proponer la arquitectura más simple que los satisfaga.
    4. Identificar dónde fallará primero.
    5. Escalar específicamente esos cuellos de botella.
    6. Analizar los escenarios de fallo.
    7. Discutir las compensaciones que se están haciendo.
    8. 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.
  • Trabajadores procesan tareas en segundo plano como correos electrónicos y facturas de forma independiente de las solicitudes API.
  • API externas para pagos y envíos se llaman con tiempos de espera y claves de idempotencia.
  • 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

    1. ¿Qué problema resuelve esto? Si no puede expresarlo claramente, la arquitectura aún no está lista.
    2. ¿Qué escala se requiere? Usuarios, tasa de solicitudes, volumen de datos, objetivos de almacenamiento y latencia.
  • ¿Qué sucede cuando falla cada componente? La base de datos, Redis, una instancia de API, la red, un proveedor externo.
  • ¿Qué debe ser rápido? Las lecturas, escrituras, búsquedas o la comunicación en tiempo real.
  • ¿Qué puede ser asíncrono? Correos electrónicos, informes, notificaciones, análisis.
  • ¿Qué compromiso estoy aceptando? Toda decisión importante debe tener una razón.
  • 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.
  • Un monolito modular es la opción por defecto más adecuada; los microservicios son una respuesta a necesidades específicas, no un punto de partida.
  • El objetivo es el sistema más simple que cumpla de manera fiable con los requisitos actuales y pueda evolucionar a medida que estos cambien.
  • Lecturas relacionadas