Diagnóstico primero: dónde está realmente el cuello de botella
Escalar sin diagnóstico es tirar dinero. Antes de añadir instancias o rediseñar la arquitectura, identificar el cuello de botella real. Los cuatro lugares más frecuentes en orden de probabilidad:
- Base de datos (70% de los casos): queries sin índices, N+1 queries, lock contention, connection pool agotado. La base de datos escala peor que la aplicación y es el cuello de botella en la mayoría de los sistemas.
- IO de red y latencia de terceros (15%): llamadas síncronas a APIs externas en el request-response principal, sin caching ni circuit breaker.
- Código de aplicación (10%): loops O(n²), serialización ineficiente, generación de objetos en memoria a escala.
- Infraestructura (5%): CPU throttling en contenedores, límites de red, nodos sobresaturados.
Connection Pooling: el primer problema que nadie configura bien
Una aplicación sin connection pool crea una nueva conexión a la base de datos en cada request. A baja carga, el overhead es manejable. A alta carga, el tiempo de establecer conexiones TCP + TLS + autenticación PostgreSQL puede ser el 30-40% de la latencia total del request. Con PgBouncer como proxy de pooling, la aplicación reutiliza conexiones existentes.
; pgbouncer.ini — configuración para una API de producción
[databases]
production = host=postgres-primary port=5432 dbname=produccion
[pgbouncer]
pool_mode = transaction ; pool por transacción (más eficiente que session)
max_client_conn = 1000 ; conexiones máximas de la aplicación al proxy
default_pool_size = 25 ; conexiones reales al PostgreSQL
min_pool_size = 5
reserve_pool_size = 5
reserve_pool_timeout = 3
server_idle_timeout = 600
log_connections = 0 ; desactivar en producción (genera I/O)
log_disconnections = 0Con pool_mode = transaction, PgBouncer asigna una conexión de PostgreSQL solo durante la duración de la transacción, no durante toda la sesión del cliente. 1,000 conexiones de aplicación pueden usar solo 25 conexiones reales a PostgreSQL si cada transacción es corta. Esto es lo que permite a sistemas con miles de requests concurrentes operar con una base de datos que acepta 100 conexiones máximas.
Caching en capas: la arquitectura correcta
El caching no es una sola herramienta — es una estrategia en capas, cada una con un tradeoff diferente entre costo, complejidad y hit rate.
// Caching en capas: in-process → Redis → base de datos
import { LRUCache } from 'lru-cache';
import Redis from 'ioredis';
const localCache = new LRUCache<string, Product>({
max: 1000, // máximo 1000 items en memoria del proceso
ttl: 30_000, // 30 segundos de TTL en caché local
});
const redis = new Redis(process.env.REDIS_URL!);
async function getProduct(id: string): Promise<Product> {
// Capa 1: caché local en memoria (sub-milisegundo)
const local = localCache.get(id);
if (local) return local;
// Capa 2: Redis (1-2ms)
const cached = await redis.get(`product:${id}`);
if (cached) {
const product = JSON.parse(cached);
localCache.set(id, product); // poblar caché local
return product;
}
// Capa 3: base de datos
const product = await db.product.findUnique({ where: { id } });
if (!product) throw new NotFoundError(`Product ${id} not found`);
// Escribir en Redis con TTL de 5 minutos
await redis.setex(`product:${id}`, 300, JSON.stringify(product));
localCache.set(id, product);
return product;
}CQRS: separar lecturas de escrituras para escalar cada lado
Command Query Responsibility Segregation separa el modelo de datos para escrituras (Commands) del modelo para lecturas (Queries). En muchos sistemas de producción, las lecturas superan a las escrituras en ratio 10:1 o más. Con CQRS, puedes escalar el read model independientemente (réplicas de solo lectura, projections desnormalizadas) sin afectar el write model.
// CQRS: handler de escritura y modelo de lectura separados
// --- Write side: normalizado, consistente ---
async function createOrder(cmd: CreateOrderCommand): Promise<string> {
return db.transaction(async (trx) => {
const order = await Order.create({
userId: cmd.userId,
status: 'pending',
}, { transaction: trx });
await OrderItem.bulkCreate(
cmd.items.map(item => ({ orderId: order.id, ...item })),
{ transaction: trx }
);
await OutboxEvent.create({
event_type: 'OrderCreated',
payload: JSON.stringify({ orderId: order.id, userId: cmd.userId }),
}, { transaction: trx });
return order.id;
});
}
// --- Read side: desnormalizado, optimizado para la UI ---
// Este modelo se construye desde los eventos y puede ser
// una tabla en PostgreSQL, un documento en MongoDB, o un índice en Elasticsearch
interface OrderSummaryReadModel {
orderId: string;
userName: string;
totalAmount: number;
itemCount: number;
statusLabel: string;
createdAtFormatted: string;
}Database sharding y hot spots
El sharding horizontal (distribuir datos entre múltiples instancias de base de datos por sharding key) es la solución de última línea para escala de base de datos. Antes de llegar ahí, hay pasos intermedios:
- Read replicas: escalar las lecturas con réplicas de solo lectura es simple y resuelve la mayoría de los problemas de escala de base de datos sin complejidad de sharding.
- Table partitioning: particionar tablas grandes (logs, eventos, facturas) por rango de fecha. PostgreSQL native partitioning reduce el costo de queries y facilita el archivado de datos históricos.
- Optimizar queries antes de escalar infraestructura: un índice faltante puede generar un full table scan que tarda 5 segundos en una tabla de 10 millones de filas. Con el índice correcto, la misma query tarda 2 milisegundos.
Rate limiting: proteger el backend de sí mismo
El rate limiting no es solo protección contra abuso externo — es protección contra picos de tráfico legítimo que pueden saturar el backend. El algoritmo Token Bucket permite picos de tráfico hasta cierto límite mientras mantiene una tasa promedio sostenible.
// Rate limiting con Redis usando el algoritmo sliding window
async function checkRateLimit(
userId: string,
limitPerMinute: number
): Promise<{ allowed: boolean; remaining: number }> {
const key = `rate_limit:${userId}:${Math.floor(Date.now() / 60000)}`;
const current = await redis.incr(key);
if (current === 1) {
await redis.expire(key, 60); // expirar al final del minuto
}
return {
allowed: current <= limitPerMinute,
remaining: Math.max(0, limitPerMinute - current),
};
}Preguntas frecuentes
¿Cuándo es momento de pasar de una instancia de base de datos a réplicas de lectura?
¿Redis o Memcached para caching empresarial?
¿Qué es N+1 query y cómo lo evito?
¿Cómo escalo horizontalmente una API stateful (con sesiones)?
¿Cuándo implementar CQRS y cuándo es over-engineering?
¿Tu backend empieza a mostrar problemas de escala? Podemos hacer un diagnóstico técnico e identificar los cuellos de botella reales antes de proponer cambios de arquitectura.
Habla con el equipo