Por qué la mayoría de los sistemas backend fallan a escala (y los errores que he visto de primera mano)
Siete fallos reales en producción que presencié, desde queries N+1 hasta cache stampedes, y las soluciones que realmente funcionaron. Sin teoría, solo cicatrices de batalla.
- #backend
- #scalability
- #system-design
- #architecture
- #production
Llevo más de seis años construyendo y escalando sistemas backend, y puedo decirte esto con absoluta seguridad: la mayoría de los sistemas no fallan por algún caso borde exótico. Fallan por errores bien conocidos que los equipos siguen repitiendo. No porque los ingenieros sean malos, sino porque estos problemas son invisibles con poco tráfico y solo se vuelven letales a escala.
Estos son siete errores que he visto acabar con sistemas en producción, lo que pasó realmente y cómo los solucionamos. Cada uno de ellos viene de un proyecto real.
1. La query N+1 que nadie notó
Qué pasó: teníamos un endpoint que devolvía una lista de órdenes con sus productos asociados. Con 50 órdenes iba bien: 200 ms de tiempo de respuesta. Con 500 órdenes durante una venta relámpago, tardó 14 segundos y el CPU de la base de datos llegó al 98 %.
El ORM hacía exactamente lo que le pedimos: cargar las órdenes y, para cada orden, cargar sus productos. Una query para las órdenes, 500 queries para los productos. N+1 clásico.
Qué hicimos: la solución inmediata fue el eager loading: una sola query con un JOIN. El tiempo de respuesta bajó a 180 ms sin importar la cantidad de órdenes.
// Antes: desastre N+1
const orders = await orderRepo.find({ where: { userId } });
// Esto dispara 1 query por orden para cargar los productos
// Después: una sola query cargando las relaciones
const orders = await orderRepo.find({
where: { userId },
relations: ['items', 'items.product'],
});
Pero la solución de fondo fue agregar logging de queries en staging con una alerta por umbral. Cualquier endpoint que ejecute más de 10 queries por request queda marcado automáticamente. Ahora detectamos los N+1 antes de que lleguen a producción.
Lección: los ORMs hacen que las queries N+1 sean fáciles de escribir y difíciles de detectar. Necesitas detección automatizada, no solo code reviews.
2. Falta de circuit breakers
Qué pasó: nuestro servicio de órdenes llamaba a una API de tarifas de envío de un tercero. Una tarde, esa API empezó a responder con timeouts de 30 segundos en lugar de sus habituales 200 ms. Cada request al flujo de checkout quedó en cola esperando las tarifas de envío. Se agotó el thread pool. Todo nuestro checkout cayó, no porque NUESTRO sistema fallara, sino porque falló el de otro.
Qué hicimos: implementamos circuit breakers con un patrón similar al Hystrix de Netflix (usamos opossum en Node.js):
import CircuitBreaker from 'opossum';
const shippingBreaker = new CircuitBreaker(fetchShippingRates, {
timeout: 3000,
errorThresholdPercentage: 50,
resetTimeout: 30000,
});
shippingBreaker.fallback(() => ({
rates: getCachedShippingRates(),
isFallback: true,
}));
Cuando la API de envíos falla, el circuito se abre y servimos tarifas en cache con una marca que le indica al frontend que son estimaciones. Los clientes siguen comprando. Conciliamos los costos de envío reales de forma asíncrona.
Lección: toda dependencia externa es un pasivo. Si no tienes una estrategia de fallback para cada una, has acoplado tu uptime al de ellos.
3. Base de datos compartida entre servicios
Qué pasó: dos servicios, órdenes e inventario, leían y escribían en la misma base de datos PostgreSQL. El servicio de inventario ejecutaba cada 5 minutos una query analítica pesada para los reportes de stock. En horas pico, esa query bloqueaba filas que el servicio de órdenes necesitaba actualizar. Las órdenes empezaron a fallar con errores de lock timeout.
Qué hicimos: le dimos a cada servicio su propia base de datos. Los cambios de inventario se publican como eventos vía SNS, y el servicio de órdenes mantiene su propio modelo de lectura del stock disponible. Sí, es eventualmente consistente. Pero “eventualmente consistente con un checkout que funciona” le gana a “fuertemente consistente con un checkout que falla” siempre.
Lección: una base de datos compartida es un destino compartido. Si dos servicios comparten una base de datos, no son dos servicios: son un monolito distribuido.
4. Sin backpressure en las colas
Qué pasó: usábamos SQS para procesar las confirmaciones de órdenes. Cada mensaje disparaba un email, la generación de una factura en PDF y una actualización de inventario. Durante un evento promocional entraron 50.000 órdenes en una hora. La cola de SQS se acumuló hasta 200.000 mensajes (algunas órdenes generaban varios mensajes). Nuestro consumidor intentó procesarlos todos a máxima velocidad, saturó el servicio de email y empezó a recibir errores 429 de rate limit. Los mensajes fallidos fueron a la dead-letter queue. 12.000 clientes no recibieron su email de confirmación.
Qué hicimos: tres cambios:
- Límites de concurrencia en los consumidores: en lugar de procesar lo más rápido posible, fijamos un tope de 20 mensajes concurrentes por instancia de consumidor.
- Rate limiting por dependencia downstream: agregamos un rate limiter de tipo token bucket antes de llamar a la API de email.
- Backoff exponencial con jitter: los mensajes fallidos se reintentan con retrasos crecientes más aleatoriedad para evitar un thundering herd durante la recuperación.
// Consumidor de SQS con concurrencia controlada
const consumer = Consumer.create({
queueUrl: process.env.ORDER_QUEUE_URL,
batchSize: 10,
visibilityTimeout: 60,
handleMessage: async (message) => {
await rateLimiter.acquire('email-service', { maxPerSecond: 50 });
await processOrderConfirmation(JSON.parse(message.Body));
},
});
Lección: una cola sin backpressure es solo una forma de convertir un pico de tráfico en un fallo en cascada con pasos extra.
5. Ignorar los cold starts
Qué pasó: movimos un servicio crítico a AWS Lambda para ahorrar costos. Tiempo de respuesta promedio: 80 ms. Pero después de periodos de inactividad, el primer request tardaba entre 4 y 6 segundos por los cold starts. Era el servicio de autenticación. Los usuarios sufrían demoras aleatorias de 5 segundos al iniciar sesión. Los tickets de soporte se acumularon.
Qué hicimos: evaluamos tres opciones:
- Provisioned concurrency: mantiene las instancias de Lambda calientes. Efectivo pero caro; anuló el ahorro de costos.
- Pings de calentamiento: un evento programado de CloudWatch que invoca la función cada 5 minutos. Barato pero poco confiable bajo carga concurrente: solo mantiene caliente una instancia.
- Volver a ECS Fargate: lo que terminamos haciendo. El servicio de auth necesita una latencia consistente por debajo de 100 ms. El modelo de Lambda no encaja con ese requisito.
Lección: serverless es fantástico, para las cargas de trabajo adecuadas. Los servicios sensibles a la latencia y siempre activos no lo son. Ajusta el modelo de cómputo a los requisitos reales, no a la moda.
6. Cache stampede
Qué pasó: cacheábamos nuestro catálogo de productos en Redis con un TTL de 5 minutos. En el momento exacto en que expiró el cache, 3.000 requests concurrentes llegaron al endpoint. Los 3.000 vieron un cache miss. Los 3.000 consultaron la base de datos al mismo tiempo. Se agotó el connection pool de la base de datos. Errores 500 para todos.
Qué hicimos: implementamos un patrón de cache lock (a veces llamado “probabilistic early expiration”):
async function getProductCatalog(categoryId: string): Promise<Product[]> {
const cacheKey = `catalog:${categoryId}`;
const cached = await redis.get(cacheKey);
if (cached) {
const { data, expiry, delta } = JSON.parse(cached);
const now = Date.now();
// Recómputo anticipado probabilístico
// A medida que nos acercamos a la expiración, es cada vez más probable que se refresque
if (now - delta * Math.log(Math.random()) < expiry) {
return data;
}
}
// Lock distribuido: solo un proceso reconstruye
const lockKey = `lock:${cacheKey}`;
const acquired = await redis.set(lockKey, '1', 'EX', 30, 'NX');
if (!acquired) {
// Otro proceso está reconstruyendo; servimos datos obsoletos si hay
if (cached) return JSON.parse(cached).data;
// No hay datos obsoletos; esperamos un momento y reintentamos
await sleep(100);
return getProductCatalog(categoryId);
}
try {
const products = await productRepo.findByCategory(categoryId);
const ttl = 300; // 5 minutos
await redis.set(cacheKey, JSON.stringify({
data: products,
expiry: Date.now() + ttl * 1000,
delta: ttl * 1000 * 0.1,
}), 'EX', ttl + 60); // Margen extra para servir datos obsoletos
return products;
} finally {
await redis.del(lockKey);
}
}
Solo un proceso reconstruye el cache. Todos los demás reciben datos ligeramente desactualizados. En un catálogo de e-commerce, nadie nota datos de producto con 30 segundos de antigüedad. Todos notan un error 500.
Lección: la expiración del cache es un evento coordinado que puede generar thundering herds. Diséñalo de forma explícita.
7. No diseñar para el fallo
Qué pasó: este es el meta-error que engloba a todos los demás. Construimos la primera versión de la plataforma asumiendo que todo iba a funcionar. Camino feliz en todas partes. Sin reintentos, sin fallbacks, sin degradación elegante. El sistema funcionaba perfecto en desarrollo y en staging. Producción nos enseñó lo contrario durante la primera semana.
Qué hicimos: adoptamos un principio: toda llamada externa debe responder tres preguntas antes de escribirse:
- ¿Qué pasa si esta llamada falla?
- ¿Qué pasa si esta llamada es lenta (10 veces la latencia normal)?
- ¿Qué pasa si esta llamada devuelve datos inesperados?
Lo codificamos en nuestro checklist de code review. Los pull requests que agregan llamadas externas sin responder estas tres preguntas se devuelven.
También empezamos a hacer game days: inyectar fallos de forma intencional en staging con AWS Fault Injection Simulator. Te sorprendería cuántos supuestos se rompen cuando apagas una réplica de la base de datos o le agregas 500 ms de latencia a un servicio interno.
Lección: el fallo no es un caso borde. Es una característica de los sistemas distribuidos. Si no has diseñado para él, no has diseñado el sistema.
El patrón detrás de todos estos fallos
Mira los siete errores. Hay un hilo común: todos son invisibles a baja escala. Las queries N+1 son rápidas cuando N es pequeño. La falta de circuit breakers no importa cuando las dependencias están sanas. Los cache stampedes no ocurren con 10 usuarios concurrentes.
Por eso las pruebas de carga y la ingeniería del caos no son lujos opcionales: son la forma de encontrar estos problemas antes que tus clientes. Ejecutamos pruebas de carga que simulan 3 veces nuestro tráfico pico antes de cada release importante. Hacemos experimentos de caos todos los meses. No es paranoia. Es profesionalismo.
Los sistemas que sobreviven a escala no son los construidos por los ingenieros más brillantes. Son los construidos por ingenieros que asumieron que todo se iba a romper y planificaron en consecuencia. Construye primero para el caso de fallo. El camino feliz se cuida solo.