System Design: from zero users to a million

empecé a leer el libro «System Design Interview: an insider's guide» de Alex Xu. lo conocí a través de ByteByteGo y me cautivó su manera directa de enseñar.

en esta serie de artículos que comienza con este, pretendo resumir y anotar mis aprendizajes, por si le es de utilidad a alguien; ciertamente lo es para mí.

el libro, como anticipa el título, está enfocado a preguntas sobre el diseño de sistemas. en programación, y estoy seguro que en tantos otros ámbitos de la vida también, muchos problemas se repiten para infinidad de casos particulares, lo que significa que ya hay otras personas que lo resolvieron. en vez de reinventar la rueda, podemos tratar de entender cómo lo hicieron.

partiendo de una estructura básica, Alex nos enseña a abordar un problema que, si la empresa o producto escala, es inveitable. paso a paso, nuestro código va a tener que evolucionar en complejidad por la bendita necesidad de satisfacer a muchos usuarios sin que se rompa todo.

al principio, tenemos algo como esto:

estructura básica de un sistema
estructura básica de un sistema

por si no es evidente, también hago esto para aprender a dibujar. seguimos.

qué está sucediendo acá?

el usuario quiere acceder a un dominio (en formato de texto, como puede ser www.google.com) y un proveedor de dominios convierte esa cadena en una dirección IP.

ahora, el usuario, con dirección IP en mano, sabe a donde buscar esa web y sus contenidos, porque tiene la «dirección digital» de la web, pudiendo así acceder con una petición HTTP al servidor.

el servidor le devuelve el contenido que esa web quiere mostrar, como un HTML o un JSON.

todo este capítulo podría visualizarse como una escalera que tenemos que subir para llegar a construir ese gran castillo virtual que pueda alojar a miles de huéspedes. a cada escalón que subimos, nos acercamos un poco más a esa arquitectura vasta y contenedora, que funciona porque fue diseñada con estrategia, para que, finalmente, más personas la puedan disfrutar.

escalones hacia el millón
escalones hacia el millón

primer escalón: separar la base de datos del servidor

separando las cosas
separando las cosas

separarlos trae la ventaja de que pueden ser escalados independientemente. la escalabilidad, en este contexto, hace referencia a la capacidad de un sistema de adaptarse a la creciente demanda sin sacrificar valor.

nuestra base de datos puede ser relacional o no relacional. Alex recomienda para la mayoría de los casos utilizar las relacionales porque ya superaron la prueba del tiempo (llevan más de 40 años funcionando). las no relacionales las recomienda solo en casos particulares, como por ejemplo si tu aplicación utiliza datos sin estructurar o tiene latencia muy baja.

segundo escalón: escalo para los lados

ahora que se introdujo el concepto de escalar, vale la pena preguntarse para dónde. acá hay dos formas de hacerlo: vertical u horizontalmente.

escalar de forma vertical significa añadirle poder, como más CPU o RAM, a los servidores. esta opción, comenta el autor, es la más simple y funciona bien cuando el tráfico es bajo, pero si muchos usuarios quieren acceder a los recursos de ese único servidor, los problemas no tardan en aparecer. de chicos, cuando jugábamos al Minecraft con mis amigos, siempre había problemas con dónde se levantaba el server, el cual se caía constantemente y a uno no le quedaba otra que culpar a los dioses de las computadoras por sus ineficiencias.

hoy sabemos que, en este sistema de servidor único, las limitaciones vienen por el lado del hardware y son claras: no es posible agregar poder computacional (CPU), ni memoria (RAM) ilimitada a un mismo servidor.

la otra opción es escalar de forma horizontal, y un balanceador de carga es la técnica más elegida para solucionar estos problemas de escalabilidad.

como el nombre indica, un balanceador de carga balancea la carga que recibe un servidor. cómo se hace esto?

load balancing
load balancing

el «load balancer» distribuye el tráfico entre los servidores privados que uno configura. el usuario ahora no se comunican de manera directa con los servidores, sino que pasan por el balanceador el cual se encarga de distribuir el tráfico para que todos sigan jugando al Minecraft tranquilamente.

la IP de estos servidores internos es privada, es decir, no se puede acceder de manera pública a través de internet, sino que este balanceador hace de custodio de sus llaves.

como aclara Alex, si el servidor 1 se cae, todo el tráfico se redirige al servidor 2, evitando que se caiga nuestra aplicación. además, en ese momento agregaremos otro servidor sano para suplir la pérdida.

se suele decir que el sueño del emprendedor digital es recibir un caudal voluminoso de tráfico de la noche a la mañana. pero, si uno está durmiendo, quién gestiona ese tráfico entrante? quién se encarga de adaptar la infraestructura para esta nueva y gloriosa desgracia?

el balanceador de carga permite agregar más servidores de manera dinámica para aguantar el embiste de tantos nuevos usuarios, capitalizando así la demanda y evitando la picardía que hubiera sido perder todo ese flujo.

tercer escalón: replicación de base de datos

habiendo ya dejado los servidores de la web balanceados y listos para cualquier cantidad de tráfico, queda ocuparse de un problema análogo en la base de datos.

la técnica elegida por el autor es la de establecer una base de datos maestra y una esclava. en la maestra se soportan operaciones de escritura, y en la esclava, que es una copia de la maestra, de lectura.

qué significa esto para nuestra app? que todas las operaciones que borren, inserten o actualicen algo en la base de datos deben hacerse contra la maestra; las que solo requieran leer, contra la esclava.

el autor aclara que en una aplicación las operaciones de lectura suelen ser 5 veces más que las de escritura, razón por la cual suele haber muchas más bases de datos esclavas que maestras.

mejores bbdd
mejores bbdd

las principales ventajas de este modelo son 3, según Alex:

  1. mejor rendimiento: se procesan más operaciones en paralelo (las de lectura en las esclavas, las de escritura en la maestra)
  2. fiabilidad: si tus servidores se destruyen por algún motivo, no se pierda la data porque está distribuida en muchos lugares.
  3. disponibilidad: como está distribuida, la app seguirá funcionando simplemente cambiando de servidor de base de datos.

este modelo me pareció genial porque te cubre de miles de imprevistos o «cisnes negros». las bases de datos esclavas pueden salir al rescate de fallos en las maestras o de otras esclavas. con una implementación robusta de backups, se puede orquestar un sistema para que entre unas reemplacen a las otras en casos de fallos, se promuevan de esclavas a maestras, se repliquen si se llegara a dar la destrucción de alguna, etc.

cuarto escalón: tiempo de respuesta

en esta etapa entra el juego el caché. «cachear» algo significa almacenarlo temporalmente en memoria para su acceso rápido, sin necesidad de hacer una nueva petición al servidor.

una aplicación puede ralentizarse si recibe muchas peticiones, por lo que reducir la cantidad de las mismas tiende a mejorar el rendimiento.

al igual que cuando separamos la base de datos del servidor, separar el caché permite también escalarlo independientemente.

en esta flujo, Alex muestra una implementación de una capa de caché en nuestra app.

al recibir una petición desde el servidor web a la base de datos, primero verificamos si esa información que se quiere ver está en el caché; de ser así, se devuelve eso, y si no, se va a buscar a la base de datos. esta estrategia se conoce como «read-through cache», y es solo una de muchas.

el autor advierte que hay que tener varias cosas en cuenta antes de implementar una capa de caché.

una regla de oro es implementar caché cuando los datos se quieren leer frecuentemente pero modificar infrecuentemente. mantener el caché actualizado es más sencillo si los datos de fondo no están cambiando todo el tiempo. además, en el caso de las operaciones de escritura, el caché, al almacenarse en memoria volátil, no es óptimo para guardar datos que deben persistir.

otras práctica sugerida es la implementación de políticas de expiración del caché para mantener la información cacheada más segura y actualizada.

vale la pena mencionar lo que se conoce como «eviction policy» que alude al conjunto de reglas que estandariza cómo proceder ante la necesida de borrar datos de un sistema para introducir nuevos. la política más común se denomina «least recently used (LRU) cache», donde el objetivo principal es descartar los datos menos accedidos y conservar los más consultados.

quinto escalón: entregar rápido el contenido estático

sin este paso, a nuestra aplicación le sucedería lo que experimentamos en muchas webs actuales: que las imágenes, videos, css, o cualquier contenido que tenga algo de carga tarde mucho en aparecer. esto afea la experiencia de usuario y atenta contra su retención.

para abordar este problema, existe lo que se llama «Content Delivery Network (CDN)». este concepto alude a la red geográficamente distribuida de servidores que se encargan de entregar ese contenido estático (hoy en día también funciona para contenido dinámico) desde un servidor que esté cerca del usuario.

en términos generales, explica Alex, cuando un usuario vista una web, el servidor CDN más cercano a ese usuario es el encargado de entregarle el contenido. esta es una de las razones por las cuales, cuando queremos hacer el despliegue de una aplicación, base de datos o lo que fuera, nos preguntan la región, para proporcionarle a nuestros usuarios una mejor experiencia de carga.

static content needs CDN
static content needs CDN

el flujo ilustra cómo viaja la solicitud. el usuario intenta acceder a una imagen (image.png), cuyo dominio está controlado por el proveedor de CDN. Si éste no tiene la imagen, va a buscar la imagen al servidor donde la tengamos almacenada, desde donde se la pasará al CDN para que éste la cachee hasta que expire. hecho esto, cuando el usuario 2 quiera acceder a esa imagen, la misma ya estará en el CDN y no tendrá que esperar lo que sí esperó el usuario 1.

vale la pena profundizar el hecho de que el servidor puede pasarle al CDN un parámetro «header» junto con la imagen que indique el «Time-to-Live (TTL)», el cual establece cuánto tiempo la debe almacenar en su caché.

el autor finaliza esta sección advirtiendo sobre los costos de estos proveedores de CDN. si cacheamos activos que no usamos frequentemente, quizás sea mejor almacenarlos fuera del CDN. también es útil indicar el TTL apropiado para cada tipo de activo, y tener una estrategia por si el CDN falla, llamando al servidor de origen cuando sea necesario.

sexto escalón: arquitectura «stateless»

este paso ya me da la sensación que se da por sentado hoy en día, o por lo menos en mi experiencia laboral nunca se barajó la opción de manejar una arquitectura con estados o «stateful».

una arquitectura con estado refiere a cuando almacenamos información del usuario directamente en lo servidores. así, cuando el usuario 1 se quiere loguear, hay que redirigirlo al servidor 1 donde tiene su información; lo mismo para cada usuario, es decir, un servidor dedicado para un conjunto de usuarios.

stateful
stateful

esto trae problemas de coordinación, ya que hay que llevar siempre al mismo usuario al mismo servidor, y, de caerse uno de estos, es más difícil reestablecerlo, añadir los backups también se vuelve ajetreado.

una solución más moderna a este problema es la arquitectura sin estados o «stateless». en este paradigma, las peticiones http que haga cualquier usuario pueden ser routeadas a cualquier servidor, el cual buscará la información en una base de datos separada (puede ser SQL, NoSql, Redis, etc) y compartida entre los servidores.

separar de los servidores la información del estado de los usuarios permite aumentar fácilmente la capacidad de recibir tráfico, teniendo ahora sólo que añadir más servidores.

stateless
stateless

séptimo escalón: centros de datos

estos datos, tanto los del estado del usuario como cualquier dato que requiera nuestra app, deben estar almacenada en algún lugar. para mejorar tiempos de respuesta y tener backups geográficamente distribuidos, una buena práctica es implementar geoDNS.

geoDNS es un servicio que permite que los dominios se resuelvan a IP´s dependiendo de la ubicación del usuario. de esta manera, puedo redirigir el tráfico de mi aplicación según este criterio estratégico.

separate databases
separate databases

en la imagen vemos el ejemplo de cómo se distribuye la carga entre ciudadanos estadounidenses según su ubicación.

el problema, además del tráfico y la carga de los servidores, es la disponibilidad de la información. si tengo la infraestructura separada por regiones, cómo me aseguro de que la misma información está disponible en todos los centros?

Alex Xu menciona el ejemplo de Netflix, cuyos desarrolladores implementaron una replicación asincrónica de todos los centros de datos. dejo el link a la historia.

pequeñas recomendaciones

al principio, nuestra app no necesita tanta complejidad por la falta de usuarios, pero a medida que va creciendo, el autor recomienda invertir en algunas herramientas que nos van a facilitar la vida:

logging: ver los errores que surjan en cualquier lugar de la app

métricas: centralizar en una herramienta las métricas, tanto computacionales (uso de CPU, memoria, etc), como las de las distintas capas de nuestros servicios (centros de datos, servidores), y las de negocio (usuarios, retención, ingresos).

automatización: CI/CD es fundamental para agilizar el proceso de integrar desarrollos y facilitar la productividad futura.

octavo escalón: escalar la base de datos

similar al caso de los servidores, acá podemos escalar vertical u horizontalmente. verticalmente, claro está, consistiría en añadir más poder a una misma máquina (más CPU, RAM, etc).

las desventajas son análogas a los que mencionamos antes: el límite del hardware es claro, hay un «single point of failure», y el costo es superior a añadir más servidores.

vale aclarar lo que refiere el autor sobre el caso de stackoverflow, que en 2013 tenía 10 millones de usuarios activos y una única base de datos maestra. según Amazon, se puede tener una única base de datos con hasta 24 TB de RAM.

la opción recomendada, otra vez, es hacer el escalado horizontal, también llamado «sharding». en este modelo, se separan las grandes bases de datos en pequeñas partes llamadas «shards».

la lógica interna que se aplica es la de guardar en un lugar el id de los usuarios y aplicar una función de hash para ir a buscar la data de ese usuario al shard que le corresponde.

esta solución, aclara Alex, no es perfecta porque introduce otras complejidades como la necesidad de redistribuir la data manualmente porque el hasheo no la distribuye de manera perfecta y quedan desbalanceados. otro problema que fue popular hace poco se conoce como el «celebrity problem», donde, por culpa de que la información de varios usuarios famosos está en un mismo shard (Messi y Ronaldo, por ejemplo) eso causa una sobreexigencia al servicio y obliga a una redistribución.

al millón de usuarios y más allá

hasta acá llega este primer artículo, donde abordamos los puntos clave según Alex, donde vimos cómo escalar servidores web, bases de datos, entrega de contenido, dominios, redirecciones según geolocalización y cacheo de datos, entre otras cosas.