Extracción de facturación: una historia de optimización de la API interna de GraphQL
Publicado: 2022-03-11Una de las principales prioridades del equipo de ingeniería de Toptal es la migración hacia una arquitectura basada en servicios. Un elemento crucial de la iniciativa fue Billing Extraction , un proyecto en el que aislamos la funcionalidad de facturación de la plataforma de Toptal para implementarla como un servicio independiente.
En los últimos meses, extrajimos la primera parte de la funcionalidad. Para integrar la facturación con otros servicios, usamos una API asíncrona (basada en Kafka) y una API síncrona (basada en HTTP).
Este artículo es un registro de nuestros esfuerzos para optimizar y estabilizar la API síncrona.
Enfoque incremental
Esta fue la primera etapa de nuestra iniciativa. En nuestro camino hacia la extracción total de la facturación, nos esforzamos por trabajar de manera incremental para ofrecer cambios pequeños y seguros en la producción. (Vea las diapositivas de una excelente charla sobre otro aspecto de este proyecto: extracción incremental de un motor de una aplicación de Rails).
El punto de partida fue la plataforma Toptal, una aplicación monolítica de Ruby on Rails. Comenzamos identificando las costuras entre la facturación y la plataforma Toptal a nivel de datos. El primer enfoque fue reemplazar las relaciones de Active Record (AR) con llamadas a métodos regulares. Luego, necesitábamos implementar una llamada REST al servicio de facturación para obtener los datos devueltos por el método.
Desplegamos un pequeño servicio de facturación accediendo a la misma base de datos que la plataforma. Pudimos consultar la facturación utilizando la API HTTP o con llamadas directas a la base de datos. Este enfoque nos permitió implementar un respaldo seguro; en caso de que la solicitud HTTP fallara por algún motivo (implementación incorrecta, problema de rendimiento, problemas de implementación), usamos una llamada directa y devolvimos el resultado correcto a la persona que llamó.
Para que las transiciones sean seguras y sin inconvenientes, usamos un indicador de función para alternar entre HTTP y llamadas directas. Desafortunadamente, el primer intento implementado con REST resultó ser inaceptablemente lento. Simplemente reemplazar las relaciones AR con solicitudes remotas provocó bloqueos cuando HTTP estaba habilitado. Aunque lo habilitamos solo para un porcentaje relativamente pequeño de llamadas, el problema persistió.
Sabíamos que necesitábamos un enfoque radicalmente diferente.
La API interna de facturación (también conocida como B2B)
Decidimos reemplazar REST con GraphQL (GQL) para obtener más flexibilidad en el lado del cliente. Queríamos tomar decisiones basadas en datos durante esta transición para poder predecir los resultados esta vez.
Para hacer eso, instrumentamos cada solicitud de la plataforma Toptal (monolito) para facturación y registramos información detallada: tiempo de respuesta, parámetros, errores e incluso seguimiento de pila en ellos (para comprender qué partes de la plataforma utilizan la facturación). Esto nos permitió detectar puntos de acceso: lugares en el código que envían muchas solicitudes o que provocan respuestas lentas. Luego, con stacktrace y los parámetros , podríamos reproducir los problemas localmente y tener un breve ciclo de retroalimentación para muchas correcciones.
Para evitar sorpresas desagradables en la producción, agregamos otro nivel de indicadores de funciones. Teníamos una marca por método en la API para pasar de REST a GraphQL. Estábamos habilitando HTTP gradualmente y observando si aparecía "algo malo" en los registros.
En la mayoría de los casos, "algo malo" fue un tiempo de respuesta largo (varios segundos), 429 Too Many Requests o 502 Bad Gateway . Empleamos varios patrones para solucionar estos problemas: carga previa y almacenamiento en caché de datos, limitación de datos obtenidos del servidor, adición de inestabilidad y limitación de velocidad.
Precarga y almacenamiento en caché
El primer problema que notamos fue una avalancha de solicitudes enviadas desde una sola clase/vista, similar al problema N+1 en SQL.
La carga previa de Active Record no funcionó más allá del límite del servicio y, como resultado, teníamos una sola página que enviaba aproximadamente 1000 solicitudes a facturación con cada recarga. ¡Mil solicitudes desde una sola página! La situación en algunos trabajos de segundo plano no era mucho mejor. Preferimos hacer decenas de solicitudes en lugar de miles.
Uno de los trabajos en segundo plano fue obtener datos del trabajo (llamemos a este modelo Product ) y verificar si un producto debe marcarse como inactivo en función de los datos de facturación (para este ejemplo, llamaremos al modelo BillingRecord ). Aunque los productos se obtuvieron en lotes, los datos de facturación se solicitaron cada vez que se necesitaban. Cada producto necesitaba registros de facturación, por lo que el procesamiento de cada producto generaba una solicitud al servicio de facturación para obtenerlos. Eso significó una solicitud por producto y resultó en alrededor de 1000 solicitudes enviadas desde una sola ejecución de trabajo.
Para solucionarlo, agregamos la precarga por lotes de los registros de facturación. Para cada lote de productos obtenidos de la base de datos, solicitamos registros de facturación una vez y luego los asignamos a los productos respectivos:
# fetch all required billing records and assign them to respective products def cache_billing_records(products) # array of billing records billing_records = Billing::QueryService .billing_records_for_products(*products) indexed_records = billing_records.group_by(&:product_gid) products.each do |p| e.cache_billing_records!(indexed_records[p.gid].to_a) } end endCon lotes de 100 y una sola solicitud al servicio de facturación por lote, pasamos de ~1000 solicitudes por trabajo a ~10.
Uniones del lado del cliente
Las solicitudes por lotes y el almacenamiento en caché de los registros de facturación funcionaron bien cuando teníamos una colección de productos y necesitábamos sus registros de facturación. Pero, ¿qué pasa al revés: si obtenemos los registros de facturación y luego tratamos de usar sus respectivos productos, obtenidos de la base de datos de la plataforma?
Como era de esperar, esto causó otro problema N+1, esta vez en el lado de la plataforma. Cuando utilizábamos productos para recopilar N registros de facturación, realizábamos N consultas a la base de datos.
La solución fue obtener todos los productos necesarios a la vez, almacenarlos como un hash indexado por ID y luego asignarlos a sus respectivos registros de facturación. Una implementación simplificada es:
def product_billing_records(products) products_by_gid = products.index_by(&:gid) product_gids = products_by_gid.keys.compact return [] if product_gids.blank? billing_records = fetch_billing_records(product_gids: product_gids) billing_records.each do |billing_record| billing_record.preload_product!( products_by_gid[billing_record.product_gid] ) end endSi cree que es similar a una combinación hash, no está solo.
Filtrado y captación insuficiente del lado del servidor
Luchamos contra los peores picos de solicitudes y problemas N+1 en el lado de la plataforma. Sin embargo, aún teníamos respuestas lentas. Identificamos que se debían a cargar demasiados datos en la plataforma y filtrarlos allí (filtrado del lado del cliente). Cargar datos en la memoria, serializarlos, enviarlos a través de la red y deserializarlos solo para eliminar la mayor parte fue un desperdicio colosal. Fue conveniente durante la implementación porque teníamos terminales genéricos y reutilizables. Durante las operaciones, resultó inutilizable. Necesitábamos algo más específico.
Abordamos el problema agregando argumentos de filtrado a GraphQL. Nuestro enfoque fue similar a una conocida optimización que consiste en mover el filtrado del nivel de la aplicación a la consulta de la base de datos ( find_all vs. where en Rails). En el mundo de las bases de datos, este enfoque es obvio y está disponible como WHERE en la consulta SELECT . En este caso, requería que nosotros mismos implementáramos el manejo de consultas (en Facturación).
Implementamos los filtros y esperamos ver una mejora en el rendimiento. En cambio, vimos errores 502 en la plataforma (y nuestros usuarios también los vieron). No es bueno. ¡No es bueno en absoluto!
¿Por qué sucedió eso? Ese cambio debería haber mejorado el tiempo de respuesta, no romper el servicio. Habíamos introducido un error sutil sin darnos cuenta. Conservamos ambas versiones de la API (GQL y REST) en el lado del cliente. Cambiamos gradualmente con una bandera característica. La primera y desafortunada versión que implementamos introdujo una regresión en la rama REST heredada. Centramos nuestras pruebas en la rama GQL, por lo que no detectamos el problema de rendimiento en REST. Lección aprendida: si faltan parámetros de búsqueda, devuelva una colección vacía, no todo lo que tiene en su base de datos.
Eche un vistazo a los datos de NewRelic para Facturación. Implementamos los cambios con el filtrado del lado del servidor durante una pausa en el tráfico (apagamos el tráfico de facturación después de encontrar problemas con la plataforma). Puede ver que las respuestas son más rápidas y más predecibles después de la implementación.
No fue demasiado difícil agregar filtros a un esquema GQL. Las situaciones en las que GraphQL realmente brilló fueron los casos en los que obtuvimos demasiados campos, no demasiados objetos. Con REST, enviábamos todos los datos que posiblemente se necesitaban. La creación de un punto final genérico nos obligó a empaquetarlo con todos los datos y asociaciones utilizados en la plataforma.
Con GQL, pudimos elegir los campos. En lugar de obtener más de 20 campos que requerían cargar varias tablas de base de datos, seleccionamos solo los tres a cinco campos que se necesitaban. Eso nos permitió eliminar picos repentinos de uso de facturación durante las implementaciones de la plataforma porque algunas de esas consultas fueron utilizadas por trabajos de reindexación de búsqueda elástica ejecutados durante la implementación. Como efecto secundario positivo, hizo que las implementaciones fueran más rápidas y confiables.

La solicitud más rápida es la que no haces
Limitamos la cantidad de objetos obtenidos y la cantidad de datos empaquetados en cada objeto. ¿Qué más podriamos hacer? ¿Quizás no obtener los datos en absoluto?
Notamos otra área con margen de mejora: estábamos usando una fecha de creación del último registro de facturación en la plataforma con frecuencia y cada vez, llamábamos a facturación para obtenerlo. Decidimos que, en lugar de obtenerlo de forma sincrónica cada vez que fuera necesario, podríamos almacenarlo en caché en función de los eventos enviados desde la facturación.
Planificamos con anticipación, preparamos tareas (de cuatro a cinco) y comenzamos a trabajar para que se hiciera lo antes posible, ya que esas solicitudes generaban una carga importante. Teníamos dos semanas de trabajo por delante.
Afortunadamente, no mucho después de que comenzamos, analizamos el problema por segunda vez y nos dimos cuenta de que podíamos usar los datos que ya estaban en la plataforma pero en una forma diferente. En lugar de agregar nuevas tablas para almacenar en caché los datos de Kafka, pasamos un par de días comparando los datos de facturación y la plataforma. También consultamos a expertos en dominios sobre si podíamos usar los datos de la plataforma.
Finalmente, reemplazamos la llamada remota con una consulta DB. Esa fue una gran victoria tanto desde el punto de vista del rendimiento como de la carga de trabajo. También ahorramos más de una semana de tiempo de desarrollo.
Distribuyendo la Carga
Estábamos implementando y desplegando esas optimizaciones una por una, pero todavía hubo casos en los que la facturación respondió con 429 Too Many Requests . Podríamos haber aumentado el límite de solicitudes en Nginx, pero queríamos comprender mejor el problema, ya que era un indicio de que la comunicación no se está comportando como se esperaba. Como recordará, podíamos darnos el lujo de tener esos errores en producción, ya que no eran visibles para los usuarios finales (debido a la alternativa a una llamada directa).
El error ocurrió todos los domingos, cuando la plataforma programa recordatorios para los miembros de la red de talentos con respecto a las hojas de tiempo atrasadas. Para enviar los recordatorios, un trabajo obtiene datos de facturación de productos relevantes, que incluyen miles de registros. Lo primero que hicimos para optimizarlo fue procesar por lotes y precargar los datos de facturación, y obtener solo los campos obligatorios. Ambos son trucos bien conocidos, por lo que no entraremos en detalles aquí.
Desplegamos y esperamos al siguiente domingo. Estábamos seguros de que habíamos solucionado el problema. Sin embargo, el domingo, el error resurgió.
Se llamó al servicio de facturación no solo durante la programación, sino también cuando se envió un recordatorio a un miembro de la red. Los recordatorios se envían en trabajos de fondo separados (usando Sidekiq), por lo que la carga previa estaba fuera de discusión. Inicialmente, asumimos que no sería un problema porque no todos los productos necesitaban un recordatorio y porque los recordatorios se envían todos a la vez. Los recordatorios están programados para las 5 p. m. en la zona horaria del miembro de la red. Sin embargo, nos perdimos un detalle importante: nuestros miembros no se distribuyen uniformemente en las zonas horarias.
Estábamos programando recordatorios para miles de miembros de la red, aproximadamente el 25 % de los cuales viven en una zona horaria. Alrededor del 15% vive en la segunda zona horaria más poblada. Cuando el reloj marcó las 5 p. m. en esas zonas horarias, tuvimos que enviar cientos de recordatorios a la vez. Eso significó una explosión de cientos de solicitudes al servicio de facturación, que era más de lo que el servicio podía manejar.
No era posible precargar los datos de facturación porque los recordatorios se programan en trabajos independientes. No pudimos obtener menos campos de la facturación, ya que habíamos optimizado ese número. Mover a los miembros de la red a zonas horarias menos pobladas también estaba fuera de discusión. entonces, ¿qué hicimos? Movimos los recordatorios, solo un poco.
Agregamos fluctuación a la hora en que se programaron los recordatorios para evitar una situación en la que todos los recordatorios se enviarían exactamente al mismo tiempo. En lugar de programar a las 5 p. m. en punto, los programamos dentro de un rango de dos minutos, entre las 5:59 p. m. y las 6:01 p. m.
Desplegamos el servicio y esperamos hasta el domingo siguiente, seguros de que finalmente habíamos solucionado el problema. Desafortunadamente, el domingo, el error apareció nuevamente.
Estábamos desconcertados. Según nuestros cálculos, las solicitudes deberían haberse repartido en un período de dos minutos, lo que significaba que tendríamos, como máximo, dos solicitudes por segundo. Eso no era algo que el servicio no pudiera manejar. Analizamos los registros y los tiempos de las solicitudes de facturación y nos dimos cuenta de que nuestra implementación de jitter no funcionaba, por lo que las solicitudes seguían apareciendo en un grupo reducido.
¿Qué causó ese comportamiento? Era la forma en que Sidekiq implementa la programación. Sondea redis cada 10 a 15 segundos y, por eso, no puede ofrecer una resolución de un segundo. Para lograr una distribución uniforme de las solicitudes, usamos Sidekiq::Limiter , una clase proporcionada por Sidekiq Enterprise. Empleamos el limitador de ventana que permitió ocho solicitudes para una ventana móvil de un segundo. Elegimos ese valor porque teníamos un límite de Nginx de 10 solicitudes por segundo en la facturación. Mantuvimos el código de jitter porque proporcionó una dispersión de solicitud de grano grueso: distribuyó trabajos de Sidekiq durante un período de dos minutos. Luego, se usó Sidekiq Limiter para garantizar que cada grupo de trabajos se procesara sin romper el umbral definido.
Una vez más, lo desplegamos y esperamos al domingo. Estábamos seguros de que finalmente habíamos solucionado el problema, y lo hicimos. El error desapareció.
Optimización API: Nihil Novi Sub Sole
Creo que no le sorprendieron las soluciones que empleamos. El procesamiento por lotes, el filtrado del lado del servidor, el envío solo de campos obligatorios y la limitación de velocidad no son técnicas novedosas. Los ingenieros de software experimentados sin duda los han utilizado en diferentes contextos.
¿Precarga para evitar N+1? Lo tenemos en todos los ORM. Hash se une? Incluso MySQL los tiene ahora. ¿Subestimar? SELECT * frente SELECT field es un truco conocido. ¿Repartiendo la carga? Tampoco es un concepto nuevo.
Entonces, ¿por qué escribí este artículo? ¿Por qué no lo hicimos bien desde el principio ? Como siempre, el contexto es clave. Muchas de esas técnicas parecían familiares solo después de que las implementamos o solo cuando notamos un problema de producción que debía resolverse, no cuando miramos el código.
Había varias explicaciones posibles para eso. La mayor parte del tiempo, intentábamos hacer lo más simple que pudiera funcionar para evitar el exceso de ingeniería. Comenzamos con una solución REST aburrida y solo luego pasamos a GQL. Implementamos cambios detrás de un indicador de función, monitoreamos cómo se comportaba todo con una fracción del tráfico y aplicamos mejoras basadas en datos del mundo real.
Uno de nuestros descubrimientos fue que la degradación del rendimiento es fácil de pasar por alto cuando se refactoriza (y la extracción puede tratarse como una refactorización significativa). Agregar un límite estricto significó que cortamos los lazos que se agregaron para optimizar el código. Sin embargo, no fue evidente hasta que medimos el rendimiento. Por último, en algunos casos, no pudimos reproducir el tráfico de producción en el entorno de desarrollo.
Nos esforzamos por tener una pequeña superficie de una API HTTP universal del servicio de facturación. Como resultado, obtuvimos un montón de puntos finales/consultas universales que transportaban datos necesarios en diferentes casos de uso. Y eso significaba que, en muchos casos de uso, la mayoría de los datos eran inútiles. Es un pequeño intercambio entre DRY y YAGNI: con DRY, solo tenemos un punto final/consulta que devuelve registros de facturación, mientras que con YAGNI, terminamos con datos no utilizados en el punto final que solo perjudican el rendimiento.
También notamos otra compensación cuando discutimos el jitter con el equipo de facturación. Desde el punto de vista del cliente (plataforma), cada solicitud debe recibir una respuesta cuando la plataforma lo necesite. Los problemas de rendimiento y la sobrecarga del servidor deben ocultarse detrás de la abstracción del servicio de facturación. Desde el punto de vista del servicio de facturación, debemos encontrar formas de que los clientes conozcan las características de rendimiento del servidor para soportar la carga.
Nuevamente, nada aquí es novedoso o innovador. Se trata de identificar patrones conocidos en diferentes contextos y comprender las compensaciones introducidas por los cambios. Lo hemos aprendido de la manera difícil y esperamos haberte ahorrado repetir nuestros errores. En lugar de repetir nuestros errores, sin duda cometerá sus propios errores y aprenderá de ellos.
Un agradecimiento especial a mis colegas y compañeros de equipo que participaron en nuestros esfuerzos:
- Makar Ermokhin
- gabriele renzi
- samuel vega caballero
- luca guidi
