Ir al contenido

Construyendo para la Escala: Patrones de Arquitectura que Realmente Funcionan

La mayoría de los consejos sobre escalabilidad son genéricos. Aquí presentamos los patrones que han funcionado consistentemente en sistemas reales que manejan millones de solicitudes, y aquellos que suenan bien pero fallan en la práctica.

Durval Pereira
Durval Pereira
5 min

El problema con los consejos de escalabilidad

La mayoría de los artículos sobre escalabilidad repiten el mismo manual: añadir caching, usar una CDN, fragmentar tu base de datos, volverse asíncrono. Este consejo no es incorrecto, simplemente está incompleto. Omite la parte en la que tienes que averiguar cuáles de estas cosas son importantes para tu sistema, cuándo aplicarlas y cómo evitar crear nuevos problemas mientras resuelves los antiguos.

Después de construir y escalar sistemas en diferentes dominios —desde APIs de alto rendimiento hasta pipelines de datos en tiempo real— he descubierto que los patrones de escalabilidad más útiles comparten un rasgo común: reducen el radio de impacto del fallo mientras aumentan la independencia de los componentes.

Empieza por el cuello de botella, no por el diagrama de arquitectura

El error más común al escalar es optimizar lo incorrecto. Antes de rediseñar tu sistema, necesitas saber dónde falla realmente.

// A simple but effective approach: instrument first, optimize second
import { metrics } from '@/lib/telemetry'

export async function handleRequest(req: Request) {
  const timer = metrics.startTimer('request_duration')
  const route = extractRoute(req)

  try {
    const result = await processRequest(req)
    timer.end({ route, status: 'success' })
    return result
  } catch (error) {
    timer.end({ route, status: 'error' })
    throw error
  }
}

Esto no es glamuroso. Pero saber que el 80% de tu latencia proviene de tres consultas específicas a la base de datos vale más que cualquier diagrama de arquitectura.

Patrón 1: La réplica de lectura con enrutamiento inteligente

El patrón de escalabilidad más simple que ofrece resultados desproporcionados. La mayoría de las aplicaciones tienen una carga de lectura pesada —a menudo un 90% o más de lecturas. Enrutar las lecturas a las réplicas es sencillo, pero los detalles de implementación importan.

interface DatabaseRouter {
  write(): DatabaseConnection
  read(consistency?: 'eventual' | 'strong'): DatabaseConnection
}

class SmartRouter implements DatabaseRouter {
  private primary: DatabaseConnection
  private replicas: DatabaseConnection[]
  private recentWrites: Map<string, number> = new Map()

  write(): DatabaseConnection {
    return this.primary
  }

  read(consistency: 'eventual' | 'strong' = 'eventual'): DatabaseConnection {
    if (consistency === 'strong') {
      return this.primary
    }
    return this.selectReplica()
  }

  private selectReplica(): DatabaseConnection {
    const healthy = this.replicas.filter((r) => r.isHealthy())
    if (healthy.length === 0) return this.primary
    return healthy[Math.floor(Math.random() * healthy.length)]
  }
}

El mapa recentWrites es clave. Después de que un usuario escribe datos, se enrutan sus lecturas a la primaria durante un corto período para evitar inconsistencias de lectura-después-de-escritura. Este es el tipo de detalle que los consejos genéricos de escalabilidad pasan por alto.

Patrón 2: Caching por niveles con invalidación explícita

El caching es fácil. La invalidación de la caché es el problema real. El enfoque más robusto que he encontrado utiliza niveles explícitos con una propiedad clara.

Nivel 1 — Nivel de solicitud: Memoización dentro de una única solicitud. Riesgo cero, beneficio masivo para cálculos repetidos.

Nivel 2 — Nivel de aplicación: Caché en memoria (como un mapa TTL) para datos "calientes". Rápido pero requiere un dimensionamiento cuidadoso.

Nivel 3 — Caché distribuida: Redis o Memcached para estado compartido. Añade un salto de red pero escala horizontalmente.

Nivel 4 — Borde de CDN: Para contenido estático y semi-estático. El nivel más efectivo para APIs públicas con muchas lecturas.

El error que cometen la mayoría de los equipos es saltar directamente al Nivel 3 o 4 sin agotar el valor de los Niveles 1 y 2. Una caché en memoria con un TTL de 30 segundos puede eliminar el 95% de las consultas idénticas a la base de datos con cero coste de infraestructura.

Patrón 3: Contrapresión como característica

La mayoría de los sistemas fallan no porque un componente sea lento, sino porque un componente lento se ve abrumado por un productor rápido. La contrapresión —la capacidad de un consumidor para señalar que no puede seguir el ritmo— es esencial a escala.

class BoundedQueue<T> {
  private queue: T[] = []
  private waiters: Array<(value: T) => void> = []

  constructor(private maxSize: number) {}

  async enqueue(item: T): Promise<boolean> {
    if (this.queue.length >= this.maxSize) {
      return false // Signal backpressure
    }

    if (this.waiters.length > 0) {
      const waiter = this.waiters.shift()!
      waiter(item)
    } else {
      this.queue.push(item)
    }
    return true
  }

  async dequeue(): Promise<T> {
    if (this.queue.length > 0) {
      return this.queue.shift()!
    }

    return new Promise((resolve) => {
      this.waiters.push(resolve)
    })
  }
}

Cuando enqueue devuelve false, el productor sabe que debe retroceder. Esto es mucho mejor que las colas ilimitadas que consumen memoria hasta que el proceso falla, o los disyuntores que descartan solicitudes sin que el productor sepa por qué.

Lo que no funciona

Algunos patrones que suenan razonables pero que consistentemente causan problemas:

Microservicios prematuros. Si tu equipo no puede desplegar un monolito de forma fiable, los microservicios no ayudarán; multiplicarán tus problemas operativos. Comienza con un monolito modular bien estructurado.

Bases de datos compartidas entre servicios. Esto crea un acoplamiento invisible que hace imposible la escalabilidad independiente. Si dos servicios comparten una base de datos, no son servicios separados, son un monolito distribuido.

Excesiva dependencia del procesamiento asíncrono. Hacer todo asíncrono puede crear pesadillas de depuración y problemas de experiencia de usuario. Algunas operaciones deben ser síncronas y rápidas.

El meta-patrón

El verdadero patrón de escalabilidad no es una técnica única. Es este: hacer que los componentes sean desplegables de forma independiente, escalables de forma independiente y observables de forma independiente. Cada patrón concreto —réplicas de lectura, caching, colas, sharding— es solo una aplicación específica de este principio.

Cuando evalúes cualquier propuesta de escalabilidad, hazte tres preguntas:

  1. ¿Esto reduce el acoplamiento entre componentes?
  2. ¿Esto hace que el fallo sea más localizado?
  3. ¿Puedo observar y depurar esto de forma independiente?

Si la respuesta es no a cualquiera de estas, el patrón puede resolver tu problema inmediato pero crear uno más difícil más adelante.


Este es el primero de una serie sobre diseño práctico de sistemas. Próximamente: cómo pensar en las decisiones de modelado de datos que escalan.

Etiquetasscalingdistributed-systemssystem-designpatterns