En el equipo de infraestructura, cuidamos cada proceso para que sea óptimo. Surge el dilema: ¿cuándo deberíamos optimizar un componente? Algunos sugieren hacerlo al construir, mientras que otros preferimos lanzar para obtener métricas y finalmente poder iterar. En este blog te contaremos cómo logramos mejorar en un 20% la latencia y los tiempos de respuesta de todo platzi.com mediante optimizaciones a nivel de milisegundos.
"Lo que no se define, no se puede medir. Lo que no se mide, no se puede mejorar. Lo que no se mejora, se degrada siempre"
William Thomson Kelvin
Aqui va un ejemplo: Si tenemos un API que recibe el 80% de las peticiones de nuestro sistema y es un componente lento (1 a 3 segundos de tiempo de respuesta), esto impactaría en una mayor latencia y una experiencia de usuario pobre para cada cliente que consuma dicha API. Estos componentes criticos se denominan: Cuellos de botella.
Los cuellos de botella suelen ser procesos de alta transaccionalidad que reciben mucho tráfico y de los cuales dependen otros procesos o clientes.
¿Y cuando un cuello de botella se convierte en un problema?. Cuando afecte, o pueda afectar, la experiencia de nuestros usuarios finales, en nuestro caso, a nuestros estudiantes o a nuestro equipo de desarrolladores en Platzi.
Nota: Por razones de confidencialidad, hablaremos de componentes hipotéticos que representan nuestro caso de uso de forma aproximada.
Tenemos un componente desplegado en Cloudflare usando la tecnología de Workers, que para finalidad del texto denominaremos EntryPoint. Los Workers permiten ejecutar nano funciones o algoritmos en el edge, y están mayormente enfocados en servir de pasarela para cada petición, pero también pueden comportarse como microservicio independiente, tal como si habláramos de una lambda en AWS.
Este componente recibe gran parte del tráfico de Platzi.com; ejecuta una serie de validaciones con reglas de negocio (A, B, C, D, E, F) con cada request y, finalmente, dependiendo de esas validaciones, devuelve un resultado. Para no entrar en detalles, algunas de estas tareas están relacionadas con caché de endpoints, autorización, redirecciones, entre otras.
De hecho, si en algún momento han visto algún incidente relacionado con que Platzi se cayó, o que Platzi no responde, seguramente se deba a algo que implementamos o rompimos en ese componente 🫣.
Entonces, si tenemos un componente crítico que funciona y atiende gran parte de nuestro tráfico en Platzi, ¿deberíamos optimizarlo? La respuesta es si, definitivamente es relevante y podria convertirse en un cuello de botella. Considerando lo anterior, los resultados son grises o difusos, ya que los tiempos de respuesta son bajos (del orden de milisegundos), las mejoras solo se verían a escala, es decir, cuando se reciben millones de peticiones, pero aun asi valdra la pena. Ya lo vamos a ver…
Recuerdo la frase de Donald E. Knuth, quien dijo que: Alrededor del 97% de las veces: la optimización prematura es la raíz de todos los males
Structured Programming with go to Statements, Pagina 8, Segunda columna.
Hace más de un año que implementamos este componente y ya tenemos suficiente información para iterar y desplegar, siendo conscientes que estamos en el 3% de las veces en las cuales no estamos proponiendo una optimización prematura 🤔. Claro, esto no lo sabíamos hasta que revisamos las herramientas de observabilidad y trazabilidad de este componente.
Con monitoreo constante nos dimos cuenta que este componente podría mejorar. A pesar de no ser un cuello de botella preocupante, mejorar este componente a nivel de lógica y funcionalidad podría representar un beneficio considerable para la experiencia de nuestros estudiantes; asimismo, también mejoraría el rendimiento de componentes internos que pueden estar conectados a él.
El problema no es problema, ¿o sí? Algunos dirán: dejémoslo quieto, ¿para qué mejorarlo si ya está funcionando? Te cuento un secreto: incluso dentro del equipo nos cuestionamos lo mismo. Pero después de revisar métricas, vimos buenos objetivos de iteración:
Reducir tiempos de cada petición: apostar a mejorar 10 ms en 10 millones de peticiones diarias genera un ahorro de tiempo equivalente a un día y 3 horas de ejecución, esa era la meta mínima, quizás no suena tan ambiciosa, pero sí es de mucho impacto en cada petición. Una suma gigante de números pequeños al final es un número gigante…
Invalidación dinámica de reglas de negocio: las reglas de negocio cambian constantemente. Somos una Start-Up e iteramos según lo pida el mercado y nuestros estudiantes, entonces estas reglas suelen estar en constante movimiento, algunas más que otras. El coste de estas reglas es importante si consideramos el impacto en el tiempo de nuestros estudiantes; cada petición pasa por un proceso de evaluación y esto podía tomar en ocasiones hasta un 40% del tiempo de cada petición.
Optimización de consumo de recursos: en algunos casos estas reglas de negocio generan estáticos en formato HTML, JS, CSS que nuestros estudiantes consumen de forma constante, por ejemplo, al entrar a platzi.com. Estos estáticos suelen tener pocos cambios y deberían estar cacheados, pero al servirlos mediante Workers o Pages, el caché se hace complejo, ya que Cloudflare no cuenta con mecanismos para cachear respuestas de Workers. (Al menos no para el Tiered Caching)
Después de tener claros los objetivos, comenzamos a identificar el cómo. Dado que este componente se ejecuta en los Edge locations de Cloudflare, la principal herramienta que usamos fue Honeycomb, una plataforma de observabilidad para sistemas distribuidos en el Edge. Adicionalmente, partiendo de nuestra experiencia con implementaciones de sistemas distribuidos usando cache, decidimos abordar una solución personalizada que nos permitiera cachear respuestas en el Edge. Pero vamos paso a paso:
Seleccionamos una muestra aleatoria para analizar el detalle de las trazas y los tiempos por tarea, durante cada petición notamos un punto de mejora en común. En algunos casos, las solicitudes a un endpoint podrían demorarse 1.4 segundos (Imagen A), mientras que en otros, la latencia mínima que lográbamos a ese mismo endpoint era de 692 milisegundos (Imagen B).
Imagen A: request con tiempos de respuesta de 1.408 segundos.
Imagen B: request con tiempos de respuesta de 692 segundos.
Esta diferencia de tiempo podría deberse a validaciones de seguridad y dependencias con otros componentes, pero el punto clave aquí es la secuencialidad de las tareas A hasta F. En nuestro análisis, esos tiempos eran consistentes en al menos el 90% de las peticiones. Después de revisar la relevancia y dependencia de esas tareas, nuestra conclusión fue que debíamos eliminar dicha secuencialidad.
Este problema lo abordamos desde dos frentes:
Despues de lanzar a producción, tomamos metricas por más de dos semanas y aca puedes ver una traza aleatoria mostrando el resultado de estas mejoras (Imagen C). En algunos componentes internos, estas mejoras redujeron el promedio de los tiempos de respuesta de 562 ms a 116 ms, es decir, un ahorro de aproximadamente 450 ms en cada petición.
Imagen C: Traza del proceso optimizado (157 ms)
De las imágenes anteriores notamos reducción en los tiempos de ejecución de las tareas, muchas de estas usan como insumo principal las reglas de negocio. Estas reglas se almacenan en una base de datos de llave-valor (Cloudflare KV) y luego se buscan de forma dinámica para definir el comportamiento de cada request.
Estas reglas nacieron siendo de carácter dinámico, de ahí que tuvieran una alta tasa de lectura y escritura, con el paso del tiempo esto cambió y ya no eran usadas del mismo modo. En este caso definimos un nuevo patrón de acceso para estas reglas, ya no es requerido buscarlas de forma dinámica y en su lugar decidimos amarrarlas a una marca de tiempo, similar a lo que se usaría para invalidar cache basado en tiempo.
De esta forma, el proceso de búsqueda de las reglas no se haría de forma dinámica en tiempo de ejecución, sino que se usaría dicha marca como mecanismo para saber si ya se habían leído anteriormente.
En este punto, notamos que agregar caché en las respuestas de los Pages y Workers era absolutamente necesario. Este caché nos podría beneficiar en los tiempos de respuesta, reducción de peticiones que impactan el origen, entre otros beneficios. Dado que Cloudflare aún no tiene soportado cache para estos servicios entonces que mejor forma de resolverlo que creando nuestro propio motor de Cache.
Diseñamos un motor de caché completamente personalizado para Workers y Pages, aplicando la estrategia de caché Read-Through. En la gráfica, vemos el paso a paso:
En el caso de las invalidaciones de caché, decidimos operar de dos formas. La primera es versionar el caché usando el timestamp en la llave del valor almacenado en KV. Esto permite encontrar y generar solo el caché que sea válido para la versión de EntryPoint desplegado. Adicionalmente, para la limpieza de objetos de KV, manejamos la expiración de datos de forma automática mediante TTLs configurados al momento de escritura.
Teniendo en cuenta el impacto y uso extenso de este componente, la optimización a nivel de código, los patrones de acceso y el uso de caché generaron mejoras en la latencia general de toda la plataforma. En la siguiente gráfica, notamos cómo, a partir de aplicar los cambios el 28 de agosto, hubo una considerable reducción en la duración promedio de cada petición que llegaba a EntryPoint.
Antes de esta fecha, para el P90 de los datos, la duración promedio de cada petición era de 325 ms.
Después del 28 de agosto, el P90 de la duración era de 230 ms, mostrando una tendencia de mejora del 29% en términos de ejecución de este componente.
El servicio de Cloudflare Pages permite desplegar landings optimizadas para el Edge; sin embargo, los tiempos de latencia no siempre son los esperados. Esta latencia puede variar por múltiples factores, tales como la ubicación del Edge Location, la velocidad de descarga del cliente, la cantidad de assets del frontend, entre otros.
En nuestro caso, una de las landings de más tráfico tenía tiempos de RTT aceptables para los sitios web actuales. Sin embargo, si queremos ofrecer la mejor experiencia, esta métrica debe reducirse al mínimo.
Esta landing atiende a una gran cantidad de nuestros usuarios. Pasamos de un RTT promedio de 650 ms a 180 ms. Al momento de escribir este blog, aún seguimos trabajando para reducir aún más el RTT promedio de esta y otras landings.
De forma general, la mayoría de nuestros estudiantes, cuando entren a platzi.com, podrá percibir menores tiempos de respuesta.
Para las principales ciudades de Latinoamérica, quizás esto no sea un gran problema, ya que las velocidades de internet son bastante altas y esto podría no ser perceptible en esos contextos. En Platzi, tenemos estudiantes de zonas rurales, alejados de las ciudades principales, con internet de baja velocidad o incluso conexión limitada. De esta forma, garantizamos que ellos también tengan la mejor experiencia de aprendizaje.
Los cambios principales sobre el EntryPoint se agregaron el 28 de agosto, pero el caché sobre Workers se agregó el 23 de septiembre. Como ya hemos mencionado en blogs anteriores, el caché a nivel de backend también puede representar beneficios desde un punto de vista de negocio. Es decir, imaginemos que tenemos microservicios de búsqueda, subtítulos, traducciones, entre otros. Al hacer optimizaciones de este tipo, podríamos generar ahorros en el consumo de APIs de terceros. 🤑.
En el caso de los subtítulos, estas respuestas no son dinámicas ni requieren información privada de usuarios, y pueden beneficiarse de reglas de caché que almacenan las respuestas más frecuentes para futuras peticiones. Para este microservicio, la tendencia de consumo tuvo una caída abrupta: antes era de 1 millón de peticiones semanales, con una cantidad promedio de 125,000 solicitudes diarias.
Después, el consumo semanal bajó a 179,000 solicitudes, con un consumo diario de 21,000 peticiones diarias.
Cuando la respuesta sale de cache, el usuario se ve beneficiado con bajos tiempos de respuesta. En nuestro caso, este microservicio consumía una API de terceros. Al agregar la optimización con caché, el tercero dejará de recibir consumo, lo que representa un ahorro en nuestra facturación mensual.
A lo largo de este blog, te contamos cómo en Platzi hemos logrado la optimización de todos nuestros tiempos de respuesta al optimizar el código de un componente crítico que podría ser considerado como un cuello de botella. Con esto, garantizamos la mejor experiencia para nuestros estudiantes, mientras aprendemos el poder del código hecho con un objetivo claro, con métricas definidas y con buenas prácticas.
En cuanto a la pregunta de ¿cuándo optimizar?, nuestra mejor respuesta hasta ahora es:
En el mundo de las startups, el cambio y la iteración constante son necesarios. Nuestro objetivo va más allá del ahorro y la baja latencia; siempre buscamos garantizar una experiencia de aprendizaje fluida y con la menor fricción para todos nuestros estudiantes.
Recuerda: despliega, mide, rompe, itera y repite. Esa es la forma de crecer.