- Un buen diseño de sistemas es aquel que no parece complejo y en el que no surgen mayores problemas durante mucho tiempo
- Manejar el estado (state) es la parte más difícil del diseño de sistemas, y es importante reducir al máximo la cantidad de componentes que almacenan estado
- La base de datos suele ser el lugar donde se guarda el estado, por lo que se necesita un enfoque centrado en el diseño de esquemas e indexación, además de la eliminación de cuellos de botella
- Caching, procesamiento de eventos y trabajos en segundo plano deben introducirse con cuidado para mejorar el rendimiento y la mantenibilidad, evitando abusar de ellos
- Más que un diseño complejo, la clave para construir sistemas sostenibles y estables está en usar adecuadamente componentes y metodologías simples que ya han sido suficientemente probados
Definición del diseño de sistemas y enfoque general
- Si el diseño de software es ensamblar código, el diseño de sistemas es el proceso de combinar distintos servicios
- Los principales componentes del diseño de sistemas son servidores de aplicaciones, bases de datos, cachés, colas, buses de eventos y proxies
- Un buen diseño provoca reacciones como: "no hay ningún problema especial", "terminó siendo más fácil de lo pensado" o "no hace falta preocuparse por esta parte"
- En cambio, un diseño complejo y llamativo puede ocultar problemas fundamentales o reflejar sobreingeniería
- En vez de introducir un sistema complejo desde el inicio, conviene partir de una estructura simple y mínima que funcione, y evolucionarla gradualmente
Distinción entre estado (state) y sin estado (stateless)
- La parte más complicada del diseño de software es justamente la gestión del estado
- Los servicios que no almacenan información y devuelven resultados de inmediato, como el renderizado de PDF de GitHub, son stateless
- En cambio, los servicios que escriben en una base de datos sí gestionan estado
- Es mejor reducir al máximo los componentes con almacenamiento de estado dentro del sistema. Eso disminuye la complejidad y la probabilidad de fallas
- Se recomienda una arquitectura donde solo un servicio gestione el estado, mientras que los demás se concentren en roles stateless, como llamadas API o emisión de eventos
Diseño de bases de datos y cuellos de botella
Diseño de esquema e índices
- Para almacenar datos, se necesita un diseño de esquema fácil de leer para las personas
- Un esquema demasiado flexible, por ejemplo guardar todo en una columna JSON, puede cargar innecesariamente al código de la aplicación y al rendimiento
- Hay que definir índices adecuados según las columnas sobre las que se harán consultas frecuentes. Poner índices en todo solo genera overhead innecesario
Cómo resolver cuellos de botella
- El acceso a la base de datos suele convertirse en un cuello de botella pesado
- Siempre que sea posible, es mejor procesar datos complejos dentro de la base de datos, por ejemplo con joins (JOIN), en lugar de hacerlo en la aplicación, por razones de rendimiento
- Si se usa un ORM, hay que evitar el error de disparar consultas dentro de un loop
- Según el caso, dividir una consulta para ajustar la carga de la base de datos o la complejidad del query también puede ser una opción
- Una estrategia eficaz es distribuir las consultas de lectura a réplicas (read replicas) para reducir la carga del nodo principal de escritura
- Cuando se concentra una gran cantidad de consultas, las transacciones y operaciones de escritura pueden sobrecargar la base de datos con facilidad, por lo que conviene considerar query throttling (limitación de consultas)
Separar trabajos lentos y rápidos
- Las tareas con las que interactúa el usuario necesitan responder en unos cientos de milisegundos
- Para trabajos que tardan mucho, por ejemplo conversiones grandes de PDF, resulta efectivo entregar de inmediato en el frontend solo lo mínimo necesario y dejar el resto en segundo plano
- Los trabajos en segundo plano suelen funcionar con una cola, por ejemplo Redis, junto con un job runner
- Para tareas programadas a mucho tiempo vista, en lugar de Redis suele ser más práctico gestionarlas con una tabla aparte en la base de datos y ejecutarlas mediante un scheduler
Caching
- El caching ayuda a reducir costos y mejorar el rendimiento cuando se repiten operaciones iguales o costosas
- Normalmente, los ingenieros junior que acaban de aprender sobre caché quieren cachearlo todo, mientras que los ingenieros con más experiencia son más cautelosos al introducir caché
- La caché introduce un nuevo estado, por lo que existen riesgos de sincronización, errores y datos stale
- Lo recomendable es intentar primero mejoras de rendimiento, como añadir índices a las consultas, y solo después aplicar caching
- Para cachés de gran volumen, también puede utilizarse una estrategia de almacenamiento periódico en document storage como S3 o Azure Blob Storage, en lugar de Redis o Memcached
Procesamiento de eventos
- La mayoría de las empresas cuentan con un event hub, como Kafka, y varios servicios distribuyen el procesamiento con base en eventos
- Más que abusar de los eventos, un diseño simple de API de solicitud–respuesta es más útil para logging y resolución de problemas
- El procesamiento basado en eventos encaja cuando el emisor no necesita preocuparse por cómo actúa el receptor, o en escenarios de alto volumen y tolerancia a la latencia
Formas de entrega de datos: push y pull
- Hay dos formas de entrega de datos: pull (respuesta después de una solicitud) y push (entrega automática cuando hay cambios)
- El enfoque pull es simple, pero puede provocar solicitudes repetidas y sobrecarga
- El enfoque push entrega los cambios al cliente apenas ocurren en el servidor, por lo que es más eficiente y favorable para mantener datos actualizados
- Para manejar grandes volúmenes de clientes, hace falta ampliar la infraestructura según el enfoque elegido, por ejemplo colas de eventos o varios servidores de caché
Enfoque en las hot paths
- Las hot paths son las rutas más importantes del sistema y por donde fluye la mayor cantidad de datos
- Las hot paths dejan poco margen de maniobra y, si el diseño falla, pueden provocar problemas graves en todo el servicio, así que requieren un diseño especialmente cuidadoso
- Más que dispersar recursos en funciones menores con muchas opciones, resulta más efectivo concentrar el diseño y las pruebas en las hot paths
Logging, métricas y tracing
- Para diagnosticar la causa de una falla, hay que registrar de forma activa logs detallados de las rutas anómalas (unhappy path)
- Es necesario recopilar métricas básicas de observabilidad, como recursos del sistema (CPU/memoria), tamaño de las colas y tiempos de solicitudes o trabajos
- En vez de mirar solo promedios, también hay que observar métricas de distribución como latencias p95 y p99. Un pequeño porcentaje de solicitudes lentas puede ser justamente el problema de los usuarios clave
Kill switch, reintentos y recuperación ante fallas
- Es importante usar estratégicamente un kill switch (bloqueo temporal del sistema) y los reintentos
- Reintentar sin criterio solo carga a otros servicios; para que sea efectivo, primero hay que controlar las solicitudes con mecanismos como un circuit breaker
- Introducir una Idempotency Key permite evitar trabajo duplicado al reprocesar una misma solicitud
- En algunos escenarios de falla, hay que elegir entre fail open o fail closed. Por ejemplo, en rate limiting conviene más fail open para reducir el impacto sobre el usuario. En cambio, en autenticación, fail closed es indispensable
Cierre
- Aunque aquí se omitieron algunos temas como separación de servicios, contenedores, adopción de VM y tracing, usar componentes bien probados en el lugar adecuado sigue siendo la forma más estable de construir sistemas a largo plazo
- Los diseños técnicamente especiales son en realidad muy raros, y un diseño tan simple que hasta parece aburrido es el que más se usa en la práctica
- En esencia, un buen diseño de sistemas es un proceso de combinar con seguridad metodologías suficientemente probadas sin llamar la atención
Aún no hay comentarios.