Anteriormente, hablamos sobre cómo, a medida que crecía la carga, abandonamos gradualmente el uso de Python en el back-end de servicios críticos en la producción, reemplazándolo con Go. Y hoy, yo, Denis Girko, líder del equipo de desarrollo de Madmin, quiero compartir detalles: cómo y por qué sucedió esto en el ejemplo de uno de los servicios más importantes para nuestro negocio: calcular el precio teniendo en cuenta los descuentos en cupones.

La mecánica de trabajar con cupones probablemente esté representada por cualquiera que haya realizado al menos una vez compras en tiendas en línea. En una página especial o directamente en la cesta, ingresa el número de cupón y los precios se vuelven a calcular de acuerdo con el descuento prometido. El cálculo depende de qué tipo de descuento ofrece el cupón: en porcentaje, en forma de una cantidad fija o usando algunas otras matemáticas (por ejemplo, adicionalmente tomamos en cuenta los puntos del programa de lealtad, las promociones de la tienda, los tipos de productos, etc.). Naturalmente, el pedido ya se emite con nuevos precios.
Las empresas están encantadas con todos estos mecanismos de trabajar con los precios, pero queremos hablar sobre el servicio desde un punto de vista ligeramente diferente.
Como funciona
Para la fijación de precios teniendo en cuenta todas estas dificultades en el backend, ahora tenemos un servicio por separado. Sin embargo, no siempre fue independiente. El servicio apareció uno o dos años después del inicio de la tienda en línea, y para 2016 era parte de un gran monolito de Python que incluía una amplia variedad de componentes para la actividad de marketing (Madmin). Se destacó como un "bloque" independiente más tarde, mientras se movía hacia la arquitectura de microservicios.
Como suele ser el caso con los monolitos, Madmin fue modificado y correspondió parcialmente con una gran cantidad de desarrolladores. Las bibliotecas de terceros se integraron allí, lo que simplificó el desarrollo, pero a menudo no tuvo el mejor efecto en el rendimiento. Sin embargo, en ese momento, no nos importaba realmente la resistencia a las cargas pesadas durante las ventas, ya que el servicio hizo un excelente trabajo. Pero 2016 lo ha cambiado todo.

En los Estados Unidos, el "Viernes Negro" se conoce desde los años 60 del siglo pasado. En Rusia, comenzó a lanzarse en la década de 2010, mientras que la acción tuvo que ser creada desde cero: el mercado no estaba listo para ello. Sin embargo, los esfuerzos de los organizadores no fueron en vano, y con cada año el tráfico de usuarios a nuestro sitio aumentó durante los días de ventas. Y, por lo tanto, nuestra colisión con la carga, excesiva para esa versión del servicio de cálculo de precios, era solo cuestión de tiempo.
Black Friday 2016. Y nos quedamos dormidos
Dado que la idea de venta funcionó en todo su potencial, el "Viernes Negro" difiere de cualquier otro día del año en que a medianoche llega a la tienda una audiencia del sitio web aproximadamente semanal. Este es un período difícil para todos los servicios. Incluso en aquellos de ellos que funcionan sin problemas durante todo el año, a veces surgen problemas.
Ahora nos estamos preparando para cada nuevo "Viernes Negro", imitando la carga esperada, pero en 2016 todavía actuamos de manera diferente. Al probar Madmin antes de un día importante, probamos la resistencia de carga utilizando escenarios de comportamiento del usuario en días regulares. Al final resultó que, esta prueba no refleja la situación real, porque en el "Viernes Negro" viene mucha gente con el mismo cupón. Como resultado, el servicio de cálculo de precios teniendo en cuenta este descuento, incapaz de hacer frente a una carga triple (en comparación con los días normales), bloqueó la capacidad de atender a los clientes durante dos horas durante el pico más caliente de la venta.
El servicio "fue" una hora antes de la medianoche. Todo comenzó con una interrupción en la conexión a la base de datos (MySQL en ese momento), después de lo cual no todas las copias en ejecución del servicio de cálculo de precios pudieron volver a conectarse. Y aquellos que aún estaban conectados no resistieron la carga y dejaron de responder, quedando atrapados en las cerraduras de la base.
Por coincidencia, el joven permaneció de turno entonces, quien en el momento de la caída del servicio estaba en camino desde la oficina de su casa. Solo pudo conectarse con el problema cuando llegó al lugar y llamó a la "artillería pesada", el oficial de servicio de emergencia. Juntos, normalizaron la situación, sin embargo, solo después de dos horas.
A medida que comenzaron los procedimientos, comenzaron a abrirse detalles sobre cuán subóptimo era el servicio. Por ejemplo, resultó que para calcular un cupón, se realizaron 28 consultas a la base de datos (no es sorprendente que todo funcionara con una utilización del 100% de la CPU). Los usuarios mencionados anteriormente con el mismo cupón del Black Friday no simplificaron la situación, especialmente porque teníamos un contador de aplicaciones para todos los cupones, por lo que cada uso aumentó la carga al referirse a este contador.
El 2016 nos dio mucho que pensar, principalmente sobre cómo ajustar nuestro trabajo con cupones y pruebas para que esta situación no vuelva a suceder. Y en números que el viernes se describe mejor en esta imagen:
Los resultados del Black Friday 2016Black Friday 2017. Nos estábamos preparando en serio, pero ...
Habiendo recibido una buena lección, nos preparamos de antemano para el próximo "Viernes Negro", después de haber reconstruido y optimizado seriamente el servicio. Por ejemplo, finalmente creamos dos tipos de cupones: límite e ilimitado: para evitar bloqueos en el acceso simultáneo a la base de datos, eliminamos la entrada a la base de datos del script para aplicar el cupón popular. Al mismo tiempo, 1–2 meses antes del Black Friday, cambiamos de MySQL a PostgreSQL en el servicio, lo que, junto con la optimización del código, redujo el número de llamadas a la base de datos de 28 a 4–5. Estas mejoras permitieron extender el servicio de prueba a los requisitos de SLA - respuesta en 3 segundos, percentil 95 a 600 RPS.
Sin tener idea de cuánto nuestras mejoras aceleraron el trabajo de la versión anterior del servicio en producción, en ese momento se estaban preparando dos versiones de código Python para el Black Friday a la vez: una versión existente altamente optimizada y un código completamente nuevo escrito desde cero. En producción, se lanzó el segundo, que se probó antes de este día y esta noche. Sin embargo, como resultó "en batalla", un poco poco probado.
El día de "emergencia" con la llegada del flujo principal de clientes, la carga del servicio comenzó a crecer exponencialmente. Algunas solicitudes se procesaron hasta dos minutos. Debido al largo procesamiento de algunas solicitudes, la carga sobre otros trabajadores creció.
Nuestra tarea principal era servir un tráfico tan valioso para los negocios. Pero se hizo evidente que "fundir con hierro" no resuelve el problema y, en cualquier momento, el número de trabajadores ocupados alcanzará el 100%. Sin saber a qué nos enfrentamos exactamente, decidimos activar harakiri en uWSGI y simplemente concretar solicitudes largas (que se procesan durante más de 6 segundos) para liberar recursos para los normales. Y realmente ayudó a resistir: los trabajadores comenzaron a ser liberados solo un par de minutos antes de estar completamente exhaustos.
Un poco más tarde, descubrimos la situación ... Resultó que se trataba de pedidos con cestas muy grandes, de 40 a 100 productos, y con un cupón específico que tenía restricciones en el rango. Esta situación fue mal resuelta por el nuevo código. Mostró un trabajo incorrecto con la matriz, que se convirtió en una recursión infinita. Es curioso que luego probamos un caso con cestas grandes, pero no en combinación con un cupón complicado. Como solución, simplemente cambiamos a una versión diferente del código. Es cierto que esto sucedió tres horas antes del final del Black Friday. A partir de este momento, todas las cestas comenzaron a procesarse correctamente. Y aunque completamos el plan de ventas en ese momento, evitamos problemas globales milagrosos debido a la carga cinco veces el día habitual.
Viernes negro 2018
Para 2018, para los servicios altamente cargados que sirven al sitio, gradualmente comenzamos a implementar Go. Dada la historia de los viernes negros anteriores, el servicio de cálculo de descuentos fue uno de los primeros candidatos para el procesamiento.

Por supuesto, podríamos guardar la versión de Python que ya fue "probada en la batalla", y antes del nuevo "Viernes Negro" podríamos apagar bibliotecas pesadas y arrojar código no óptimo. Sin embargo, Golang ya había echado raíces en ese momento y parecía más prometedor.
Cambiamos a un nuevo servicio este verano, así que antes de la próxima venta logramos probarlo bien, incluso en un perfil de carga creciente.
Durante las pruebas, resultó que la debilidad en términos de altas cargas sigue siendo nuestra base. Las transacciones demasiado largas llevaron al hecho de que seleccionamos el conjunto completo de conexiones, y las solicitudes se pusieron en cola. Así que tuvimos que rehacer un poco la lógica de la aplicación, reduciendo el uso de la base de datos al mínimo (refiriéndose a ella solo cuando no hay nada que hacer sin ella) y almacenando en caché los directorios de la base de datos y los datos en cupones que son populares en el Black Friday.
Es cierto que este año nos equivocamos con los pronósticos de carga hacia arriba: nos estábamos preparando para un crecimiento de 6-8 veces en los picos y logramos un buen trabajo de los servicios solo para tal volumen de solicitudes (cachés adicionales, funciones experimentales deshabilitadas por adelantado, simplificamos algunas cosas, implementamos nodos Kubernetes adicionales e incluso servidores de bases de datos para réplicas, que al final no fueron necesarias). De hecho, el aumento en el interés del usuario fue menor, por lo que todo salió como de costumbre. El tiempo de respuesta del servicio no superó los 50 ms en el percentil 95.
Para nosotros, una de las características más importantes es cómo se escala la aplicación cuando no hay suficientes recursos para una copia. Go utiliza recursos de hardware de manera más eficiente, por lo que con la misma carga necesita ejecutar menos copias (en última instancia, atender más solicitudes en los mismos recursos de hardware). Este año, en el pico de la venta, 16 instancias de la aplicación estaban funcionando, que procesaron un promedio de 300 solicitudes por segundo con picos de hasta 400 solicitudes por segundo, que es aproximadamente dos veces mayor que la carga habitual. Tenga en cuenta que el año pasado, un servicio de Python requirió 102 instancias.
Parece que el servicio en Go desde el primer enfoque cerró todas nuestras necesidades. Pero Golang no es una "solución única para todos los problemas". No podría prescindir de algunas características. Por ejemplo, tuvimos que limitar el número de subprocesos que el servicio puede iniciar en el nodo multiprocesador Kubernetes, de modo que al escalarlo no interfiera con las aplicaciones "vecinas" en la producción (por defecto, Go no tiene límite en la cantidad de procesadores que necesitará). Para hacer esto, configuramos GOMAXPROCS en todas las aplicaciones en Go. Estaremos encantados de comentar lo útil que fue esto: en nuestro equipo esta fue solo una de las hipótesis sobre cómo lidiar con la degradación de los "vecinos".
Otra "configuración" es la cantidad de conexiones que se mantienen como Keep-Alive. Los clientes regulares de http y DB en Go por defecto solo tienen dos conexiones, por lo que si hay muchas solicitudes simultáneas y necesita ahorrar en el tráfico de la configuración de la conexión TCP, tiene sentido aumentar este valor configurando MaxIdleConnsPerHost y SetMaxIdleConns, respectivamente.
Sin embargo, incluso con estos "giros" manuales, Golang nos proporcionó un amplio margen de rendimiento para futuras ventas.