Exprime al máximo el sistema que ya tienes
(blog.danslimmon.com)- El cuello de botella de Postgres de una única aplicación SaaS se agravó, con uso de CPU entre 60% y 80%, y en una ocasión llegó al 100%, provocando una breve caída
- La escalabilidad vertical mediante una instancia de BD más grande ya había llegado a su límite, por lo que era difícil absorber más crecimiento de carga con el mismo enfoque
- El sharding de escrituras y los microservicios pueden aumentar la capacidad y la resiliencia operativa, pero siguen dejando costos de complejidad en backups, monitoreo, migraciones, ORM e incluso topología de red
- En la práctica, durante 3 meses se eliminaron consultas pesadas, se optimizó código Rails, se ajustó Postgres y algunas consultas caras de solo lectura se separaron hacia una BD de réplica
- Como resultado, el uso máximo semanal de CPU de la BD bajó de 90% a 30%, abriendo margen para aprovechar el sistema actual por más tiempo antes de pasar a una arquitectura de próxima generación
Cuello de botella de Postgres y el fin de la escalabilidad vertical
- A medida que aumentó la carga de una única aplicación SaaS, el rendimiento de Postgres se convirtió en el cuello de botella principal
- El uso de CPU se mantenía en el rango de 60% a 80%
- Al menos una vez se disparó hasta el 100%, causando una breve caída
- Antes, cada vez que la BD se saturaba, ganaban tiempo cambiándola por una instancia más grande
- Gracias a ese enfoque, podían enfocarse en otras tareas, como el desarrollo de funcionalidades
- En ese momento ya estaban usando la instancia más grande, así que ya no era posible seguir con la escalabilidad vertical
Arquitecturas de próxima generación: atractivas, pero caras
- Las principales opciones discutidas fueron el sharding de escrituras y los microservicios
- El sharding de escrituras consiste en tener clústeres de BD independientes y escribir los datos en una BD específica según una estrategia de particionamiento
- Potencialmente puede aumentar la capacidad en dos o tres órdenes de magnitud
- Los microservicios dividen el monolito en varios servicios, y cada servicio tiene su propio almacén de datos
- Permiten elegir el almacén de datos según la carga de trabajo de cada servicio
- Ambos enfoques amplían las opciones en términos de tolerancia a fallas y recuperación operativa, pero el objetivo inmediato era devolver el rendimiento de la BD a un estado controlable
La complejidad, una vez introducida, sigue generando costos
- El aumento de complejidad va más allá del costo de implementar la nueva estructura y se convierte luego en un costo de atención
- Si se elige el sharding de BD, cada decisión técnica futura tendrá que lidiar también con esa nueva complejidad
- Backups
- Monitoreo
- Migraciones
- ORM
- Topología de red
- Los microservicios generan una carga similar, y mantener arquitectura adicional puede retrasar o incluso hacer que se abandonen entregas de funcionalidades
Primero buscar margen en el sistema actual
- Incluso cuando parece necesaria una gran transición arquitectónica, muchas veces queda margen adicional en el sistema existente
- Ajustar la carga de trabajo, hacer tuning de rendimiento y sumar sistemas auxiliares puede comprar meses o años de tiempo
- Si estas opciones son viables, vale la pena intentarlas antes de construir desde cero un sistema de próxima generación
Optimizaciones realizadas en la práctica
- En el primer trabajo, dos ingenieros se dedicaron principalmente a problemas de rendimiento de BD durante unos 3 meses
- No hubo una solución milagrosa única
- Usaron telemetría para encontrar consultas pesadas
- Identificaron en qué puntos del codebase Rails se generaban las consultas
- Optimizaron o eliminaron consultas
- Ajustaron varias configuraciones de Postgres
- En el segundo trabajo, otros dos ingenieros modificaron el codebase para que algunas consultas caras de solo lectura se ejecutaran en una BD de réplica
- Separaron de la BD principal las consultas
SELECTmás frecuentes provocadas por el polling del cliente web
- Separaron de la BD principal las consultas
Resultados y principios operativos
- Combinando ambos trabajos, el uso máximo semanal de CPU de la BD bajó de 90% a 30%
- Uso máximo semanal de CPU: {l:90,30}
- Se generó mucho margen de CPU y también aumentó la capacidad de quitar carga de la BD principal
- Al tocar varias partes del codebase y colaborar entre varios desarrolladores, también se acumuló conocimiento distribuido sobre el sistema existente
- La complejidad no siempre es mala, y algún día habrá que pasar a una estructura más compleja antes de llegar a los límites fundamentales de la arquitectura de BD
- Hasta entonces, exprimir primero el sistema actual permite trabajar durante más tiempo con un sistema aburrido y simple en la medida de lo posible, y también resulta ventajoso en costo y practicidad
1 comentarios
Opiniones de Hacker News
Al ver problemas de rendimiento en bases de datos, mi idea más fuerte para proyectos nuevos es diseñar el hot path de la aplicación para que no use joins en absoluto.
El almacenamiento es barato, así que se puede desnormalizar todo y actualizarlo todo dentro de una transacción. Es realmente sorprendente cuánto más rápido se vuelve todo al eliminar los joins. Las consultas analíticas ad hoc se pueden replicar a otra base de datos para análisis.
Tengo sentimientos encontrados sobre DynamoDB de Amazon, pero la idea de que, para usarlo bien, primero hay que planear los patrones de uso y definir el esquema después es algo que vale la pena llevar también a las bases de datos relacionales. Hoy en día, incluso diría que, salvo para fines analíticos, los joins son innecesarios. Las principales bases de datos tienen propiedades ACID y el almacenamiento es absurdamente barato, así que basta con desnormalizar.
Para evitar particiones calientes, conviene usar algo más parecido a un UUID en lugar de enteros. No es una solución mágica y tiene desventajas, pero uno puede acostumbrarse a un rendimiento “siempre aceptable” y escalable horizontalmente, en vez del excelente rendimiento de los enteros que algún día se va a romper.
Una idea todavía más polémica sería indexar todas las columnas, pero ese es tema para otro día.
Desde el punto de vista de la deuda técnica, habría sido mucho más caro intentar que todo fuera rápido desde el principio. La velocidad de desarrollo se habría reducido mucho y probablemente habríamos fracasado en varias crisis.
En cambio, pagamos unos cuantos miles de dólares más al mes en costos de máquinas de lo que realmente necesitábamos, y nos ahorramos meses de trabajo de personal en una etapa en la que no podíamos contratar suficientes ingenieros y el costo de oportunidad de desarrollar funcionalidades era alto. Como no podíamos saber de antemano dónde estaría el cuello de botella, hacer que todo fuera rápido desde el principio habría requerido entre 10 y 20 veces más trabajo. Algunos cuellos de botella fueron inesperados.
Los joins pueden ser dañinos a gran escala, pero la mayoría de las startups, al menos al principio, no tienen problemas de escala. La desnormalización puede ser una buena optimización, pero se paga el costo de mantener sincronizadas todas las copias cada vez que hay cambios. Alguien acabará introduciendo un bug que no actualiza un campo desnormalizado que no es la fuente de verdad, y mostrará datos obsoletos a los usuarios. En general, usar joins y optimizar más adelante con un caché read-aside u otras técnicas suele ser más barato en costo total que retorcer el esquema.
En cambio, escribir un mismo dato en 20 lugares en vez de 1 es mucho más lento en términos de rendimiento, y las consultas se vuelven enormemente complejas y propensas a bugs. Terminas actualizando 18 lugares y olvidando 2.
Se suele mencionar el almacenamiento barato como ventaja de la desnormalización, pero aquí el almacenamiento es el menor de los problemas. El verdadero problema es una superficie de bugs mucho mayor y un mal rendimiento de escritura, y ese rendimiento de escritura puede comerse fácilmente también el rendimiento de lectura.
Una aplicación alta básicamente hace una sola cosa, y lo demás existe para apoyar esa cosa. La mayoría de las grandes tecnológicas que se te ocurran caen en esta categoría. En su modelo de datos hay solo unos pocos conceptos impulsores realmente importantes.
Facebook básicamente tiene personas, publicaciones y anuncios. Netflix tiene cuentas y series/películas. En Amazon, el producto gira alrededor de vendedores, compradores y artículos, con quizá algunas piezas adicionales de logística detrás.
Hay muchas aplicaciones altas porque son fáciles. Son mucho más fáciles que las aplicaciones anchas, a las que normalmente se llama “enterprise”. El software enterprise es malo porque es difícil, y ahí está el territorio menos explorado y una enorme oportunidad. Los jugadores establecidos como Oracle lo hacen pésimo ahí, y si entras con una mentalidad de aplicación alta, tú también lo harás.
Consejos como “nunca uses joins” o “diseña en torno a una sola tabla” tienen sentido para aplicaciones altas, pero son consejos terribles para aplicaciones anchas. A menudo se ve a empresas de aplicaciones muy altas fracasar estrepitosamente al intentar hacer cosas fuera de su competencia principal, porque están llenas de personas que tratan estos consejos como sagrados.
Ese consejo es para empresas que ya tuvieron éxito, hacen algo fácil y ya recogieron toda la fruta al alcance de la mano. Incluso una aplicación alta que aún no se ha convertido en víctima de su propio éxito no necesita destrozar su modelo de datos por rendimiento. Solo las empresas que ya tuvieron un éxito gigantesco y están exprimiendo hasta la última gota de rendimiento se preocupan por eso, y son precisamente las que menos necesitan consejos. Los consejos centrados en aplicaciones altas, como “si FAANG lo hace, tú también deberías” o “¿y qué vas a hacer si tienes mil millones de usuarios?”, contaminan la cabeza de quienes quieren hacer cosas más interesantes que mostrar anuncios a miles de millones de personas.
Los joins grandes tienen formas de corregirse en rendimiento, como moverlos más adelante a vistas materializadas o hacer ETL hacia un almacén columnar. Pero si alguien copia una columna
subtotal_centsen los modelos Order, Invoice, Payment, NotificationEmail y UserProfileRecentOrders, y la hace ser referenciada o actualizada en 296 lugares, el camino de vuelta a un estado sano se vuelve largo.Por lo general, los problemas aparecen en tablas con naturaleza de registros históricos. Los datos necesarios para la operación diaria son solo una fracción minúscula de la tabla real, pero trabajar sobre una tabla enorme se vuelve lento sin importar qué índices tenga. El solo hecho de agregar más índices también se convierte en un problema.
Al menos en las bases de datos relacionales tradicionales, no basta con indexar todas las columnas: hay que definir los índices de combinación de columnas correctos que puedan ser utilizados. DynamoDB puede ser distinto.
Exprime al máximo lo que ya tienes y, después de hacerlo durante un tiempo, cambia la forma de ver el problema: si exprimes aquí, aquí y también aquí, de pronto te das cuenta de que queda mucho por aprovechar
Optimizamos un monolito enorme durante unos dos meses: pasó de menos de 2 mil RPS, donde el PM y el equipo creían que ya no quedaba nada por exprimir, a menos de 3200 RPS con un cambio de hardware; luego, con unos días de ajustes, a 4 mil RPS; con un poco más de esfuerzo, a 10 mil RPS; y alrededor de una semana después, a 40 mil RPS
Se empezó a decir “con esto basta, no hace falta ir más lejos”, pero después de cambiar bastantes cosas, saltó a más de 2 millones de RPS en una sola máquina, y un mes después ya procesaba de forma estable más de 40 millones de RPS con baja latencia. Todavía hay margen para empujar un poco más
Hoy no usamos ni el 5% de la capacidad que se le puede sacar. Ese cambio vino simplemente de cambiar la forma de pensar el problema. Migrar de un servidor viejo a uno nuevo solo lo llevó de 1800 RPS a un poco más de 3000 RPS. Agregar hardware no solucionó el problema de fondo, y agregar complejidad solo postergó el problema. Al cambiar la forma de pensar el problema, cambiaron tanto el problema como la respuesta
Para complementar eso de “dividir el monolito en varios servicios interconectados, y que cada servicio tenga su propio almacén de datos escalable según sus condiciones”, en este punto no hace falta separar todos los microservicios posibles. Basta con preguntar: “¿qué separación tendría el mayor impacto?”
En mi caso, separé algunos datos de series temporales de Mongo hacia Cassandra. La estructura de tablas de Cassandra encajaba mucho mejor. Ese dataset tenía un esquema bien definido, y Cassandra podía empaquetar los datos de forma mucho más eficiente. En esa parte no necesitábamos la flexibilidad de los documentos JSON
Como esos datos eran la mayor parte del total, después de eso Mongo quedó en un estado bastante satisfactorio. Solo hizo falta una separación. Técnicamente, antes y después seguía siendo un monolito; simplemente el mismo servicio pasó a escribir en dos bases de datos
Irónicamente, más adelante un arquitecto de los que diseñan desde el escritorio quiso unificar todos los datos en un almacén de documentos JSON, y tuvimos varias conversaciones del estilo “ya recorrimos ese camino y sabemos dónde termina”
El escalado vertical debería ser la primera solución obvia. Es algo que mucha gente pasa por alto y que el artículo también toca en parte, pero el escalado vertical se parece al escalado horizontal sin romper la consistencia de la base de datos
Hay mucho margen por exprimir, y rara vez hace falta recurrir tan fácilmente a antipatrones como ignorar joins o validación de datos solo porque apareció un problema de rendimiento
Si los desarrolladores saben leer los resultados de EXPLAIN/ANALYZE y hacer indexación correcta y optimización de consultas, pueden evitar muchísimas decisiones de sobrediseño
Basta con registrar las consultas, filtrar las que se ejecutan muy a menudo o tardan mucho, cachear las que se ejecutan con frecuencia y optimizar las pesadas. Si se hace de forma sistemática, el sistema se vuelve más saludable
En mi experiencia, lo que ayuda mucho son APM, logs de consultas lentas, réplicas de lectura/escritura de base de datos, particionamiento y sharding
Herramientas como https://explainmysql.com muestran con más claridad qué hay que optimizar en realidad, y ofrecen un marco más accesible para desarrolladores que saben lo suficiente como para configurar una base de datos, pero no tanto como para entender cómo se usa internamente
Me imagino que alguien ya estará construyendo un sistema de IA que, al pasarle el esquema y los logs, ejecute mágicamente el SQL necesario para mejorar todo. No sé si confiaría en eso, pero muchas empresas preferirían usar algo así antes que contratar a un DBA dedicado
No sé si debería especificar siempre hints de índice para cada consulta. A veces parece que, aunque el índice existe, la consulta no lo usa. Creo que usar planes de ejecución de SQL me ayudaría a entender mejor este problema
No puedes arreglar código malo con una arquitectura nueva. Solo postergas el problema por un tiempo
Me viene a la mente una de mis frases favoritas: “Uno va a la guerra con el ejército que tiene, no con el ejército que quiere o que le gustaría tener más adelante”.
Tal vez quieras ignorar el hecho de que la frase viene de Donald Rumsfeld. Aun así, dejó algunas expresiones excelentes, como “unknown unknowns”.
Cuando trabajo en equipo, pienso mucho en esa frase. No todos están perfectamente de acuerdo ni tienen la misma comprensión o los mismos objetivos compartidos. Puede que alguien trabaje de una manera ineficiente o que yo no prefiera. Pero como es mejor tener un equipo que no tenerlo, hay que encontrar la mejor forma de lograr el objetivo con el equipo que se tiene.
También aplica bien a los sistemas.
https://www.youtube.com/watch?v=XH_34tqxAjA
https://www.google.com/search?q=no+battle+plan+survives
Esto aplica más a nosotros mismos que a los sistemas.
En mi experiencia, en apps web construidas sobre un ORM, cuando la carga de la base de datos se vuelve un problema, hay muchísima fruta al alcance de la mano que se puede obtener optimizando consultas.
Más allá de lo básico, como “¿hay un problema N+1?”, los ORM a veces no generan la consulta óptima. No querría construir una app web de producción compleja sin un ORM, pero de vez en cuando hay que poder escaparse del ORM.
Hay que perfilar las consultas que realmente se ejecutan en producción y consumen más recursos. Hay que mirar esas consultas y entender la forma de las tablas objetivo. A veces el ORM usa joins cuando en realidad se necesita una subconsulta, y a veces es al revés. También puede ser necesario preagrupar parte de los resultados o ajustar condiciones WHERE en joins complejos.
He visto casos en los que una consulta generada por el ORM, que se ejecutaba con cierta frecuencia, estaba matando la base de datos, y con unos pocos cambios pequeños pasó de tardar más de 20 segundos a menos de 1 segundo.
expire_on_commit=False.Esa opción tampoco garantiza el uso de RETURNING: lo usa si el driver de la base de datos y la base de datos lo soportan, y si el ORM lo soporta para esa combinación de driver/base de datos. El SQL generado aparece en los logs, pero no hay una API para inspeccionarlo realmente, así que no hay forma de forzar el uso de RETURNING en la suite de pruebas salvo capturar y raspar nuestros propios logs. Por suerte, dentro del framework Pytest eso es bastante fácil.
Me gustan los ORM, pero cosas como esta son frustrantemente complejas en varios niveles. También entiendo que SQLAlchemy es una biblioteca enorme y que no todo puede ser fácil. Aun así, este caso muestra bien los compromisos de usar un ORM.
Sé que si se usa
insert()directamente en Core se obtiene lo que uno quiere. Aquí hablo del caso de hacer.add()de un objeto ORM a unAsyncSession.La mayor parte de la lógica de negocio se expresa mejor en el lenguaje del álgebra relacional y algunas extensiones que con OOP.
“El costo real, y a menudo mucho mayor, del aumento de complejidad es la atención” también equivale a carga cognitiva.
Estoy cansado de trabajar en sistemas de microservicios que igual tienen downtime y donde nadie sabe cómo funciona todo el conjunto. La mayoría en realidad son monolitos distribuidos: los cambios atraviesan varios servicios y hay que desplegarlos en orden. Los datos hay que replicarlos, los trabajos hay que sincronizarlos y el estado hay que compartirlo.
https://www.youtube.com/watch?v=y8OnoxKotPQ
Pero si para arreglar o modificar el sistema alguien tiene que entenderlo completo, es una señal fuerte de que se rompieron reglas como la responsabilidad única o la abstracción correcta mediante APIs. Aun así, en mi experiencia es muy común. Para depurar un pipeline de N microservicios, a menudo hay que ejecutar y compilar localmente los N servicios.
En sentido estricto, eso es un monolito con particiones de red y variabilidad infinita de build/deploy añadidas. Al final, lo veo como un entorno de trabajo extremadamente difícil que supera la capacidad de cualquier programador humano.
Me gusta este artículo. Durante los últimos meses intenté transmitirle el mismo mensaje a mi gerente, pero sin mucho éxito.
Nuestro clúster de Redshift sobrecargado se cayó varias veces, y como ya lo escalamos al máximo con nodos RA3, ahora estamos por iniciar una gran migración a la “infraestructura de próxima generación”. En este caso, esa infraestructura de próxima generación son tres clústeres de Redshift administrados con CDK.
La nueva infraestructura es mucho más compleja que la configuración actual, y no estoy seguro de que vaya a ser la bala de plata que todos esperan.
Es una solución aburrida. Si no estás construyendo un sistema ultracrítico del que dependan vidas o la integridad física de las personas, debería ser la respuesta por defecto. El costo total de propiedad definitivamente baja mucho.
Si no tienes los recursos para operar un gran sistema redundante, he visto demasiadas veces que la propia complejidad agregada por el sistema redundante se convierte en el problema. Es mejor enfocarse en la simplicidad.
Si para sostener la complejidad necesitas sumar mucha más gente, pero el dinero y la evaluación de riesgos indican que no hace falta, lo más simple es mucho mejor. No es que nunca haya visto casos en los que, para seguir avanzando, terminara haciendo falta un proyecto enorme; pero a veces incluso eso resulta menor que el total de haber seguido cargando con la complejidad hasta ese punto. Depende mucho de qué estés construyendo.
Las soluciones que encuentran optimizaciones de rendimiento en un sistema y exprimen el rendimiento restante son realmente divertidas
Me recuerda al libro de Richard L. Sites, Understanding_Software_Dynamics. Este libro enseña cómo medir y corregir problemas de latencia, y cuánto ahorro puede generar la reducción de la latencia a gran escala
Medir y razonar sobre estos problemas es difícil, pero las soluciones a menudo son simples. Por ejemplo, en la página 9 dice: “[a] simple change paid for 10 years of my salary.”
Algún día me gustaría hacer una optimización con ese nivel de impacto
https://research.google/pubs/pub36575/
Pero también hay mucha gente inteligente alrededor, así que si encuentras una gran oportunidad, normalmente hay una razón por la que nadie más se metió. Puede ser una razón técnica o una razón organizacional
Como ejemplo de lo segundo, Google normalmente no recompensa mucho este tipo de trabajo salvo cuando hay presión por recursos. Puede que haya recibido unos 100 dólares de bono entre colegas por alguna optimización, pero para nada fue una comisión del 10%, ni un ascenso, ni el derecho a cobrar sueldo durante 10 años sin ir a trabajar. En general, las empresas prefieren que los ingenieros trabajen en crecimiento de ingresos antes que en reducción de costos. Si esa política es correcta o no está por encima de mi nivel salarial
Parece que, llegado ese punto, ya no podía subir más la configuración con unos cuantos clics en la consola de administración, y por eso tuvo que ponerse a pensar de verdad para resolver el problema de capacidad. Si hubiera optimizado antes esa parte específica del código, quizá nunca habría necesitado una configuración de instancia tan grande