Escalado
En resumen: El escalado horizontal pone copias del servidor detrás de un balanceador de carga; funciona cuando el servidor no guarda estado propio, mientras las réplicas de la base de datos se retrasan.
Problema
Black Friday y un servidor
En un día normal la tienda recibe unas 50 solicitudes por segundo y un servidor basta. En Black Friday las solicitudes son diez veces más (un ejemplo, no una medición). La CPU está al 100 %, las páginas caducan y los carritos se pierden. El año pasado la empresa compró una máquina más grande. Costó el doble, duró un año, y sigue siendo una sola: cuando se reinicia por una actualización, la tienda se para.
¿Cómo añades más potencia sin esperar a una máquina más grande, y sin parar la tienda cuando cae una máquina?
La idea
Una máquina más grande o más máquinas
Hay dos caminos:
- Escalado vertical: una máquina más grande, con más CPU y memoria. El código no cambia. Pero la máquina tiene un techo, el precio crece más rápido que la potencia, y el servidor sigue siendo uno: si cae, la tienda se para.
- Escalado horizontal: varias copias idénticas del servidor, y delante un balanceador (load balancer) que reparte las solicitudes entre ellas. No hay techo: añades una copia. Una copia cae y las otras toman el relevo.
El escalado horizontal tiene una condición: cada copia debe poder atender cada solicitud. El balanceador no sabe de qué trata una solicitud y las reparte por turnos.
El balanceador como infraestructura real (nginx, un balanceador de la nube, Kubernetes) es un tema del curso de DevOps ZeroToOps. Aquí es un objeto que reparte los servidores por turnos (round robin), para que veas qué le pasa al código.
Míralo
Tres copias con memoria
El servidor guarda los carritos en su memoria, como la tienda guardaba las sesiones en un mapa en la lección sobre sesiones:
class AppServer: def __init__(self, name): self.name = name self.carts = {} # los carritos viven en la memoria de este servidor
def add(self, session, product): cart = self.carts.setdefault(session, []) cart.append(product) return f"{self.name}: {', '.join(cart)}"# el balanceador reparte los servidores por turnos (round robin)class Balancer: def __init__(self, servers): self.servers = list(servers) self.next = 0
def pick(self): server = self.servers[self.next % len(self.servers)] self.next += 1 return server
def remove(self, server): self.servers.remove(server)María añade cuatro productos. El balanceador da las solicitudes a A, B, C y otra vez A:
balancer = Balancer([AppServer("A"), AppServer("B"), AppServer("C")])print(balancer.pick().add("maria", "café"))print(balancer.pick().add("maria", "té"))print(balancer.pick().add("maria", "chocolate"))print(balancer.pick().add("maria", "miel"))A: caféB: téC: chocolateA: café, mielCada copia ve su propio carrito. B no tiene el café, C tampoco tiene el té, y A ve el café porque María vuelve a estar con él. En el cobro el carrito es lo que haya en la copia que atiende la solicitud.
La idea
Un servidor que no guarda nada propio
Un servidor con estado recuerda algo entre dos solicitudes: el carrito, la sesión, un contador. Un servidor sin estado (stateless) no recuerda nada propio. Todo lo que debe sobrevivir hasta la siguiente solicitud vive fuera:
- en un almacén común para las sesiones que ven todas las copias (Redis);
- en la base de datos;
- en el cliente, firmado para que no se pueda cambiar (en la lección sobre JWT).
Entonces las copias pueden sustituirse unas a otras: cada una atiende cada solicitud, y cuando una se detiene, no se pierde nada.
Míralo
El carrito en el almacén común
# el almacén común de sesiones (en una tienda real, Redis)class SessionStore: def __init__(self): self.carts = {}
def add(self, session, product): cart = self.carts.setdefault(session, []) cart.append(product) return list(cart)class AppServer: def __init__(self, name, store): self.name = name self.store = store # el servidor no guarda nada propio
def add(self, session, product): cart = self.store.add(session, product) return f"{self.name}: {', '.join(cart)}"Las mismas solicitudes, y tras la tercera cae el servidor B:
store = SessionStore()b = AppServer("B", store)balancer = Balancer([AppServer("A", store), b, AppServer("C", store)])print(balancer.pick().add("maria", "café"))print(balancer.pick().add("maria", "té"))print(balancer.pick().add("maria", "chocolate"))balancer.remove(b) # el servidor B caeprint(balancer.pick().add("maria", "miel"))A: caféB: café, téC: café, té, chocolateC: café, té, chocolate, mielCada copia ve el carrito entero. Cuando cae B, el balanceador da la solicitud a C, y el carrito está completo, porque nunca estuvo en B. list(cart) y [...cart] devuelven una copia: el servidor no puede tocar la lista del almacén, igual que no podría con un Redis real.
Pruébalo
Predice la salida
Dos servidores con memoria, Lucía añade tres productos. ¿Qué imprime la última línea?
balancer = Balancer([AppServer("A"), AppServer("B")])balancer.pick().add("ivan", "café")balancer.pick().add("ivan", "té")print(balancer.pick().add("ivan", "miel"))El balanceador da A, B, A. El café y la miel están en A, y el té se quedó en B. A ve solo sus dos.
La idea
La base de datos no se multiplica tan fácil
Las copias del servidor escriben en una base de datos. Multiplicar la base de datos es más difícil, porque las escrituras deben estar en un solo lugar, o dos escrituras se pelean.
El paso habitual es una réplica de lectura: una copia de la base de datos que recibe los cambios de la principal y responde solo lecturas. La mayoría de las solicitudes de una tienda son lecturas (el catálogo, las páginas, el historial), así que las réplicas se llevan buena parte de la carga. Cómo se envían los cambios lo viste en la lección sobre replicación.
El precio es el retraso de replicación (replication lag): un cambio llega a la réplica un poco más tarde. Normalmente son fracciones de segundo, pero bajo carga también segundos. Aquí el retraso es de 3 ticks:
@dataclassclass Change: at: int order: int customer: str
# la base principal: cada escritura pasa por ellaclass Primary: def __init__(self): self.log = []
def insert(self, order, customer, now): self.log.append(Change(now, order, customer))
def orders(self, customer): return [c.order for c in self.log if c.customer == customer]
# una réplica de lectura: un cambio le llega lag ticks después de la escrituraclass Replica: def __init__(self, primary, lag): self.primary = primary self.lag = lag
def orders(self, customer, now): ids = [] for c in self.primary.log: arrived = c.at + self.lag <= now if arrived and c.customer == customer: ids.append(c.order) return idsdef show(ids): return f"pedidos {ids}" if ids else "sin pedidos"María paga en el tick 0 y abre enseguida «Mis pedidos», que lee de la réplica:
primary = Primary()replica = Replica(primary, 3)primary.insert(1042, "maria", 0) # María paga en el tick 0for now in range(4): print(f"tick {now}, réplica: {show(replica.orders('maria', now))}")tick 0, réplica: sin pedidostick 1, réplica: sin pedidostick 2, réplica: sin pedidostick 3, réplica: pedidos [1042]Durante tres ticks no ve su pedido. Paga una segunda vez o le escribe a soporte que el dinero desapareció y no hay pedido.
Míralo
Tres copias, un almacén común y la réplica
def my_orders(self, customer, now): if self.just_wrote(customer, now): return self.primary.orders(customer) return self.replica.orders(customer, now)
def just_wrote(self, customer, now): last = self.last_write.get(customer) return last is not None and now - last < self.lagdef add(self, session, product): cart = self.carts.setdefault(session, []) cart.append(product) return f"{self.name}: {', '.join(cart)}"def add(self, session, product): cart = self.store.add(session, product) return f"{self.name}: {', '.join(cart)}"def orders(self, customer, now): ids = [] for c in self.primary.log: arrived = c.at + self.lag <= now if arrived and c.customer == customer: ids.append(c.order) return idsdef my_orders(self, customer, now): if self.just_wrote(customer, now): return self.primary.orders(customer) return self.replica.orders(customer, now)
def just_wrote(self, customer, now): last = self.last_write.get(customer) return last is not None and now - last < self.lagPaso a paso, en palabras
- María añade café. El balanceador envía la solicitud a A, y el carrito queda en la memoria de A.
- La siguiente solicitud (té) va a B. B no sabe nada del café: su carrito solo tiene té.
- La solución: los servidores no guardan nada. El carrito vive en un almacén común, y cada servidor lo lee de ahí.
- Ahora C añade chocolate y ve el café y el té. Da igual qué servidor atienda la solicitud.
- B cae. El balanceador lo quita, y el carrito sobrevive: nunca estuvo en B.
- María paga: el pedido 1042 se escribe en la base principal en el tick 0. La réplica recibe el cambio 3 ticks después.
- En el tick 1 abre «Mis pedidos». La lectura va a la réplica, y el pedido todavía no está allí.
- En el tick 3 el cambio llega a la réplica y el pedido aparece. Hasta entonces María cree que el pago falló.
- La solución: quien acaba de escribir lee de la principal durante 3 ticks. Todos los demás leen de la réplica.
- Los servidores sin estado se multiplican. La base de datos de escritura sigue siendo una, y sus réplicas siempre van un poco atrasadas.
La idea
Quien escribió lee de la principal
La mayoría no nota que el catálogo tiene medio segundo de retraso. Pero la gente espera ver su propio cambio enseguida. Así que: quien acaba de escribir lee de la principal hasta que pasa el retraso. Todos los demás leen de la réplica. Esto se llama read-your-writes.
# quien acaba de escribir lee de la principalclass OrderService: def __init__(self, primary, replica, lag): self.primary = primary self.replica = replica self.lag = lag self.last_write = {}
def place(self, order, customer, now): self.primary.insert(order, customer, now) self.last_write[customer] = now
def my_orders(self, customer, now): if self.just_wrote(customer, now): return self.primary.orders(customer) return self.replica.orders(customer, now)
def just_wrote(self, customer, now): last = self.last_write.get(customer) return last is not None and now - last < self.lagdef read(orders, customer, now): source = "principal" if orders.just_wrote(customer, now) else "réplica" return f"tick {now}, {customer} → {source}: {show(orders.my_orders(customer, now))}"primary = Primary()replica = Replica(primary, 3)orders = OrderService(primary, replica, 3)orders.place(1042, "maria", 0)for now in range(4): print(read(orders, "maria", now))print(read(orders, "ivan", 1))tick 0, maria → principal: pedidos [1042]tick 1, maria → principal: pedidos [1042]tick 2, maria → principal: pedidos [1042]tick 3, maria → réplica: pedidos [1042]tick 1, ivan → réplica: sin pedidosMaría ve su pedido enseguida, desde la principal. Lucía no escribió nada y lee de la réplica: la carga de la principal crece solo con la gente que acaba de escribir.
La idea
Qué escala y cuándo duele
- Escala: los servidores de aplicación, cuando no tienen estado; las lecturas, con réplicas y una caché (en la lección sobre caching).
- No escala fácil: las escrituras en la base de datos principal. Cuando una base de datos no basta para las escrituras, los datos se dividen en partes (sharding) o se separan en servicios con sus propias bases de datos (en la lección sobre el monolito y los servicios). Las dos son decisiones grandes.
- Estado escondido: una caché en la memoria de cada copia (cada una tiene su propio precio viejo), archivos subidos al disco de una copia, contadores en memoria, un trabajo programado que arranca en cada copia y envía tres correos en vez de uno.
- Cuándo duele: las copias necesitan un almacén común, réplicas y monitoreo de cada copia. Si basta un servidor, el escalado horizontal es complejidad sin ganancia. Primero mides dónde está el cuello de botella. A veces la máquina más grande es el primer paso más barato, y a menudo lo es un índice en la base de datos.
Pruébalo
Encuentra el error
La tienda permite solo 5 intentos de inicio de sesión y luego bloquea. La prueba de penetración muestra que con tres copias el atacante prueba 15 contraseñas. ¿Qué línea tiene el error?
Elige la línea con el error.
El contador de intentos está en la memoria de la copia. El balanceador da los intentos a A, B y C por turnos, y cada copia cuenta solo los suyos. El contador es estado, así que debe ser común a todas las copias: en Redis (con INCR y un TTL) o en la base de datos. La comprobación de la contraseña viene de la lección sobre contraseñas.
Más precisamente, para curiosos
Sticky sessions. El balanceador puede enviar al mismo comprador siempre a la misma copia (por una cookie). Eso remienda el carrito, pero las copias siguen sin poder sustituirse unas a otras: si una copia cae, sus carritos se pierden y la carga se reparte de forma desigual.
Otras maneras de leer tus escrituras. El cliente recuerda el número de su última escritura y lee de una réplica que ya la tiene. O todas las lecturas de «mis» datos (perfil, pedidos) van siempre a la principal. O la replicación es síncrona para las escrituras importantes: más lenta, pero sin retraso.
Autoscaling. La nube añade y quita copias según la carga. Eso, el balanceador, las comprobaciones de si una copia está viva y Kubernetes son infraestructura, así que están en el curso de DevOps ZeroToOps.
Las cosas reales: las sesiones en Redis vienen hechas en Spring Session, en ASP.NET Core con AddStackExchangeRedisCache y AddSession, en Django con SESSION_ENGINE, en Express con connect-redis. PostgreSQL, MySQL y SQL Server tienen réplicas de lectura, y los frameworks permiten dirigir las lecturas hacia ellas.
Ejercicio
El contador de intentos compartido
Resuélvelo en tu editor.
Corrige el error de arriba: el contador de intentos es una clase Attempts, común a todas las copias. El servidor le pregunta y devuelve "blocked" después del quinto intento. Corre tres copias detrás del balanceador y siete inicios de sesión incorrectos como maria.
intento 1 (A): contraseña incorrectaintento 2 (B): contraseña incorrectaintento 3 (C): contraseña incorrectaintento 4 (A): contraseña incorrectaintento 5 (B): contraseña incorrectaintento 6 (C): bloqueadointento 7 (A): bloqueadoEl sexto y el séptimo intento están bloqueados, aunque C y A los ven por segunda vez.
Más tareas
- Caducidad. Añade un reloj de ticks y reinicia el contador 10 ticks después del primer intento.
- Archivos subidos. El servidor guarda la foto del producto en su propio disco. ¿Qué ve un comprador cuando el balanceador lo manda a otra copia? ¿Dónde debería vivir la foto?
Test
Ponte a prueba
Lecciones relacionadas
Sección «Lecciones relacionadas»- Se basa en: Sesiones, cookies y CSRF · JWT y sus trampas · Monolito, monolito modular, servicios · Caché
- Continúa en: ADR: registros de decisiones de arquitectura · Trade-offs · Tu primera tarea de arquitectura
- Ejercicios sobre el tema: Arquitectura de software