Saltar al contenido

Fundamentos de diseño de sistemas para desarrolladores

Los conceptos clave del diseño de sistemas escalables: balanceo de carga, caché, escalado de bases de datos, colas y las disyuntivas de arquitectura.

4 min de lectura
Diagrama de microservicios que pasan a través de un balanceador de carga a un grupo de servidores, que se distribuyen a un CDN y una base de datos para escalado de lectura y a una cola de mensajes.

Por qué todo desarrollador necesita conocimientos de diseño de sistemas

No hace falta ser arquitecto de software para beneficiarte de pensar en términos de diseño de sistemas. Entender cómo encajan las piezas te convierte en un mejor desarrollador en todos los niveles: cambia cómo escribes código, cómo depuras problemas en producción y cómo evalúas las disyuntivas en las decisiones del día a día.

Concepto 1: escalado horizontal frente a vertical

Escalado vertical — hacer la máquina más grande (más CPU, más RAM). Es simple, pero tiene un techo y es un punto único de fallo.

Escalado horizontal — añadir más máquinas. Requiere que la aplicación sea sin estado (stateless).

Vertical:        Horizontal:
    [Big Server]     [Server] [Server] [Server]
         |               |       |       |
      [DB]           [Load Balancer]
                           |
                         [DB]

La idea clave: las aplicaciones sin estado (donde no se guardan datos de usuario en la memoria de la aplicación) pueden escalar horizontalmente. Si tu servidor guarda los datos de sesión en memoria, no puedes enrutar al mismo usuario hacia servidores distintos.

Solución: externalizar el estado.

tstypescript
// ❌ In-memory session — breaks horizontal scaling
const sessions = new Map<string, Session>();
app.use((req, res, next) => {
  req.session = sessions.get(req.cookies.sessionId);
  next();
});
 
// ✅ Externalized session — any server can serve any user
import { createClient } from "redis";
const redis = createClient({ url: process.env.REDIS_URL });
 
app.use(
  session({
    store: new RedisStore({ client: redis }),
    secret: process.env.SESSION_SECRET,
  }),
);

Concepto 2: capas de caché

El caché es la optimización de rendimiento con mayor impacto disponible a nivel de sistema. Hay cuatro lugares donde se puede cachear:

Browser Cache → CDN → Application Cache (Redis) → Database Query Cache
     ↑              ↑              ↑                        ↑
  Static assets  Static + API   Computed results      Query results
  ms access      ~10ms           ~1ms                  ~5ms

La parte más difícil no es implementar un caché, sino invalidarlo.

tstypescript
// Cache-aside pattern — the most common approach
async function getProduct(id: string): Promise<Product> {
  const cacheKey = `product:${id}`;
 
  // 1. Try cache
  const cached = await redis.get(cacheKey);
  if (cached) return JSON.parse(cached);
 
  // 2. Cache miss — fetch from database
  const product = await db.product.findUnique({ where: { id } });
  if (!product) throw new NotFoundError();
 
  // 3. Populate cache with TTL
  await redis.setEx(cacheKey, 3600, JSON.stringify(product)); // 1 hour TTL
 
  return product;
}
 
// Invalidate on mutation
async function updateProduct(id: string, data: Partial<Product>) {
  const product = await db.product.update({ where: { id }, data });
  await redis.del(`product:${id}`); // Invalidate cache entry
  return product;
}

Estampida de caché: cuando el caché expira, todas las solicitudes fallan al mismo tiempo y golpean la base de datos de golpe. Se mitiga con expiración temprana probabilística o con bloqueos distribuidos.

Concepto 3: colas de mensajes

Las colas desacoplan a los productores de los consumidores, lo que permite:

  • Procesamiento asíncrono — devolver una respuesta de inmediato y procesar en segundo plano
  • Nivelación de carga — absorber picos de tráfico sin saturar los servicios posteriores
  • Lógica de reintentos — los trabajos fallidos pueden reintentarse automáticamente
tstypescript
// Without a queue — user waits for email to send
app.post("/api/register", async (req, res) => {
  const user = await createUser(req.body);
  await sendWelcomeEmail(user.email); // Slow — blocks response
  res.json({ user });
});
 
// With a queue — response is immediate, email is async
app.post("/api/register", async (req, res) => {
  const user = await createUser(req.body);
  await queue.add("send-welcome-email", { userId: user.id }); // Fast — non-blocking
  res.json({ user });
});
 
// Worker processes jobs independently
queue.process("send-welcome-email", async (job) => {
  const user = await db.user.findUnique({ where: { id: job.data.userId } });
  await emailService.sendWelcome(user);
});

Concepto 4: escalado de bases de datos

Cuando una única base de datos no puede con la carga:

Réplicas de lectura — replican las escrituras hacia réplicas de solo lectura. Las consultas con mucha lectura se enrutan hacia las réplicas.

tstypescript
// Primary for writes, replica for reads
const writeDb = new PrismaClient({ datasources: { db: { url: PRIMARY_URL } } });
const readDb = new PrismaClient({ datasources: { db: { url: REPLICA_URL } } });
 
async function getUserDashboard(userId: string) {
  // Read from replica — ok if slightly stale
  return readDb.user.findUnique({
    where: { id: userId },
    include: { recentOrders: { take: 10 } },
  });
}
 
async function updateUserProfile(userId: string, data: Partial<User>) {
  // Write to primary — must be consistent
  return writeDb.user.update({ where: { id: userId }, data });
}

Particionado (sharding) — divide los datos entre varias bases de datos según una clave de partición (por ejemplo, rangos de ID de usuario). Añade una complejidad operativa considerable, así que conviene recurrir primero a las réplicas de lectura.

Concepto 5: el teorema CAP en la práctica

En un sistema distribuido, solo puedes garantizar dos de las tres propiedades:

  • Consistencia — cada lectura devuelve la escritura más reciente
  • Disponibilidad — cada solicitud recibe una respuesta
  • Tolerancia a particiones — el sistema sigue funcionando pese a fallos de red

Los fallos de red ocurren. Durante una partición, tienes que elegir entre consistencia y disponibilidad.

La mayoría de las aplicaciones web deberían optar por disponibilidad + consistencia eventual:

tstypescript
// Eventual consistency — show cached data, sync in background
async function getInventoryCount(productId: string) {
  // Return cached count — may be slightly stale
  const cached = await redis.get(`inventory:${productId}`);
  if (cached) return parseInt(cached);
 
  // Fallback to database
  const product = await db.product.findUnique({
    where: { id: productId },
    select: { inventoryCount: true },
  });
  await redis.setEx(
    `inventory:${productId}`,
    30,
    String(product.inventoryCount),
  );
  return product.inventoryCount;
}

Las transacciones bancarias y los historiales médicos necesitan consistencia estricta. El conteo de inventario de productos puede tolerar un retraso de 30 segundos.

El modelo mental

El diseño de sistemas consiste en entender dónde están los cuellos de botella y qué disyuntivas son aceptables para tu caso de uso.

Preguntas que hacerte para cualquier sistema:

  1. ¿Cuál es la proporción entre lecturas y escrituras?
  2. ¿Cuál es la latencia aceptable?
  3. ¿Qué ocurre si falla algún componente?
  4. ¿Cómo es el patrón de acceso a los datos?
  5. ¿Cuáles son los requisitos de consistencia?

Empieza simple. Añade complejidad solo cuando tengas evidencia de que la solución simple se ha convertido en un cuello de botella. La optimización prematura en el diseño de sistemas — sobrediseñar para una escala que todavía no tienes — es tan peligrosa como escribir microoptimizaciones prematuras en el código.

Wilfredo Rujel

Wilfredo Rujel

Ingeniero de Software Full Stack

Compartir esta publicaciónX