Escalar LunaDb, nuestro sistema interno de carga de datos declarativos

Imagen del colaborador - Equipo de AsanaArvind Vijayakumar
5 de marzo de 2026
17 min de lectura
facebookx-twitterlinkedin
Gemini said Two female professionals collaborating at a desk in a bright, modern office. One woman sits while the other leans in, smiling and using the computer mouse, with a male colleague working in the background.

Aquí en Asana, hemos creado un sistema de carga de datos llamado LunaDb que sirve como la columna vertebral de nuestra aplicación web. A pesar del nombre, no es una base de datos. Más bien, es un sistema similar a GraphQL para obtener datos de forma declarativa; básicamente, una forma de cargar la última versión de los datos y todas las actualizaciones futuras.

Inicialmente, lanzamos LunaDb en 2015 como una reescritura radical de nuestra infraestructura de backend¹. El componente central de este nuevo sistema era el servidor de sincronización, un monolito que realiza todo, desde la sincronización del cliente hasta la carga de datos y el control de acceso. Sin cambios significativos, esta arquitectura inicial se expandió mucho más allá de los niveles esperados inicialmente, hasta llegar a millones de usuarios activos semanales y miles de millones de consultas diarias. 

Si bien el rendimiento se mantuvo sólido, a medida que el tráfico y la complejidad de las funciones aumentaron, se volvió cada vez más difícil operar y mejorar LunaDb debido a las limitaciones del servidor de sincronización.

Resumen de nuestra infraestructura de carga de datos

descripción general de nuestra infraestructura de carga de datos

¿Por qué era difícil de operar?

El cambio de tráfico es costoso

El servidor de sincronización gestionaba directamente las conexiones WebSocket persistentes con los clientes. Cada WebSocket estaba respaldado por una sesión de cliente con estado. Cuando se interrumpía una conexión, todo este estado se descartaba y el cliente volvía a suscribirse a todos los datos que le importaban. Cuando esto sucede con muchas sesiones, rápidamente se vuelve costoso. Así que tuvimos que tener cuidado al cambiar estas conexiones, dado el gran aumento en el trabajo debido a las reconexiones.

cambios de tráfico

cambios de tráfico

Implementar servidores de sincronización significa cambiar el tráfico

Por supuesto, no se puede evitar perpetuamente el desplazamiento del tráfico. Siempre que quieras enviar un nuevo código o aumentar o reducir la escala, deberás retirar los servidores de sincronización, y eso requiere desviar todo el tráfico de las instancias de terminación.

actualizaciones periódicas

actualizaciones periódicas

Los servidores de sincronización no funcionan bien al inicio

Al mismo tiempo, los servidores de sincronización solo serían eficientes después de una cantidad considerable de calentamiento del proceso. La gestión de estos dos problemas ha sido un equilibrio bastante frágil y ha requerido un extenso trabajo de ingeniería en el pasado.

Los servidores de sincronización son complejos y difíciles de supervisar

Finalmente, como los servidores de sincronización ejecutaban muchos bits arbitrarios de código de producto (a través de funciones personalizadas del lado del servidor), eran muy vulnerables a las regresiones de rendimiento basadas en vecinos ruidosos que eran difíciles de atribuir². 

el problema del vecino ruidoso

el problema del vecino ruidoso


Una pregunta razonable es: “¿Por qué los servidores de sincronización son complejos y no funcionan bien al inicio?”

Una causa común de estos problemas es nuestro código de producto del lado del servidor. Los fragmentos principales del código del servidor de sincronización están escritos en Scala.  A pesar de algunas complejidades relacionadas con la gestión del estado de la sesión y los diversos aspectos del marco Luna, este código de marco/plataforma se comporta en su mayor parte como esperamos (hay relativamente pocos problemas operativos y de rendimiento). 

Por otro lado, estos valores calculados por el servidor del producto (los llamamos SCV, pero piensa en resolvers personalizados) están escritos en Typescript. Ambos conjuntos de código se ejecutan juntos en GraalVM, una máquina virtual políglota que permite el uso de múltiples lenguajes a través de su marco Truffle. Como están escritos en Typescript, los SCV se interpretan esencialmente al inicio, lo que previsiblemente da como resultado un rendimiento y un uso de la CPU inaceptables. GraalVM intentará realizar una compilación justo a tiempo en los SCV invocados. ¡Esto es bueno! GraalVM/Truffle pueden optimizar en gran medida su rendimiento, pero hacerlo no es gratis. La compilación de SCV puede ser bastante costosa (en CPU, caché de código, etc.). 

nuestra configuración de VM políglota

nuestra configuración de VM políglota

¿Por qué los dos lenguajes?

Nuestro primer diseño para SCV fue completamente en Scala. Por otro lado, nuestros sistemas de mutación y de trabajos asincrónicos están escritos en Javascript/Typescript. Si bien los SCV basados en Scala funcionaban, la duplicación de la lógica de negocios entre nuestros sistemas de mutación y de trabajos asincrónicos y LunaDb, junto con la falta de familiaridad de los ingenieros de producto con Scala, se convirtió en un gran obstáculo para la velocidad del producto. 

¿Por qué GraalVM?

Almacenamos en caché una gran cantidad de datos en proceso para acelerar el cálculo (y el recálculo) de los resultados de las suscripciones. El uso de GraalVM nos ofrece una forma sencilla de compartir estas memorias caché entre lenguajes sin las preocupaciones de corrección o rendimiento que podrían surgir al dividir las partes de Scala y TypeScript en contenedores separados.


¿Por qué era difícil mejorar?

Dado que el servidor hacía muchas cosas y era relativamente frágil de operar, tendíamos a evitar cambios más grandes. No solo debido a la complejidad del código, sino también a la gran sobrecarga que implica implementar nuevos cambios de manera segura.

¿Cómo resolvemos las cosas?

Sí, me gusta hacer preguntas

Dadas las dificultades para operar y mejorar el servidor de sincronización, tomamos la difícil decisión de cambiar nuestra arquitectura. Principalmente, decidimos reemplazar el servidor de sincronización monolítico por dos tipos de componentes más pequeños:

  • un agente de sesión que gestiona las conexiones de los clientes y la resolución de estados

  • un cargador sincronizable responsable de la carga de datos, es decir, de cumplir con las consultas de los agentes de sesión

agentes de sesión y cargadores sincronizables

agentes de sesión y cargadores sincronizables

¿Por qué ayuda esto?

Inmediatamente, esta nueva arquitectura separa el tráfico cambiante de WebSocket (es decir, la implementación de agentes de sesión) del calentamiento de nuevos procesos (es decir, la implementación de cargadores sincronizables). Como resultado, podemos minimizar las interrupciones al implementar cargadores sincronizables por separado de los agentes de sesión.

Cada uno de estos nuevos componentes es más simple.

  • Los agentes de sesión son mucho más ligeros, no requieren calentamiento de procesos y no ejecutan ningún código de producto. Como resultado, no necesitamos implementarlos con tanta frecuencia y, cuando lo hacemos, es bastante sencillo.

  • Los cargadores sincronizables tienen una interfaz más simple (solicitudes de suscripción sin estado) que es más fácil de adaptar al escalado automático horizontal de pods estándar de Kubernetes. Esto también hace que sea más rápido y sencillo prepararlos: simplemente podemos usar la duplicación de tráfico en las solicitudes del entorno que fluyen entre los intermediarios de sesión y los cargadores sincronizables

La nueva arquitectura nos permite simplificar enormemente el proceso de desarrollo de productos. Desde el principio, los programas de implementación independientes del código del producto del lado del servidor y la llamada al código del producto del lado del cliente han sido un obstáculo para la velocidad y una fuente de trabajo operativo (debido a la incompatibilidad de versiones). Dado que los cargadores sincronizables son ahora el único proceso restante que ejecuta el código del producto y su implementación ya no es disruptiva, podemos volver a implementarlos cada vez que enviamos un nuevo código del producto.

Esta nueva arquitectura nos permite escalar mejor a nuevas funciones mediante la implementación de diferentes grupos de cargadores sincronizables para diferentes tipos de cargas de trabajo (como diferentes funciones distintas como la Bandeja de entrada, las tareas, los objetivos, etc.). El agente de sesión funciona como una puerta de enlace de servicio que puede controlar directamente cómo se envían las consultas de datos a diferentes cargadores sincronizables ascendentes.

¿Cuáles son los desafíos clave del diseño?

¡Genial! Esta nueva arquitectura suena mucho mejor, pero ¿cómo la hicimos realidad? El servidor de sincronización es básicamente un monolito en el sentido de que abarca múltiples funciones, y dividir los monolitos casi siempre es complicado. En nuestro caso, tuvimos que superar algunos obstáculos clave de diseño. 

Dividir PubSub

PubSub, nuestro sistema para implementar la reactividad, se diseñó en torno a un único proceso (el servidor de sincronización) responsable de cargar nuevos datos y enviarlos al cliente. Tuvimos que rediseñar PubSub de una manera que garantizara la corrección en estos dos tipos de procesos ahora independientes (cargadores sincronizables y agentes de sesión). 

Profundicemos brevemente en cómo se implementa en los servidores de sincronización. Nota: Puede ser útil leer nuestra publicación anterior sobre el canal de invalidación, pero proporcionaremos una visión del sistema sin requisitos previos.

En el servidor de sincronización, hacemos un seguimiento de las suscripciones por sesión. Usamos el canal de invalidación para supervisar continuamente las suscripciones en busca de actualizaciones. En cada nuevo mensaje de invalidación, el servidor de sincronización volverá a cargar todas las suscripciones afectadas. 

Los servidores de sincronización almacenan en caché en gran medida los resultados de los objetos/consultas de la base de datos, los resultados del resolvedor personalizado y los resultados de suscripciones anteriores para optimizar las recargas de suscripciones (es decir, mediante un patrón de lectura directa). Los artefactos en caché se invalidan pasivamente mediante el mismo canal de invalidación utilizado para las suscripciones. Siempre que intentemos usar datos almacenados en caché, verificaremos su validez y recurriremos al recálculo si es necesario.

Podemos observar una clara dependencia entre la recarga de suscripciones y la invalidación de los datos almacenados en caché. Al recibir una invalidación, si recargamos una suscripción antes de que se hayan invalidado los datos almacenados en la caché, es posible que calculemos un resultado obsoleto. Cuando tanto la carga de datos como la gestión de suscripciones se realizan en el mismo proceso, es muy sencillo garantizar esta dependencia: simplemente se invalidan las cachés antes de volver a cargar. 

La condición de carrera puede causar datos obsoletos en la actualización

La condición de carrera puede causar datos obsoletos en la actualización

En nuestra nueva arquitectura propuesta, los intermediarios de sesión y los cargadores sincronizables son consumidores independientes del canal de invalidación. Entonces, ¿cómo podemos hacer que las cachés se invaliden antes de que se vuelvan a cargar las suscripciones?

Control de versiones de solicitudes y respuestas

Podríamos haber resuelto esto haciendo que el pipeline de invalidación entregara mensajes al mismo tiempo. O podríamos haber creado un mecanismo para hacer cumplir la garantía de ordenamiento de que ningún mensaje de la canalización de invalidación llegue al agente de sesión antes que al cargador sincronizable. Sin embargo, ambas soluciones tenían compensaciones no ideales: fundamentalmente, aumentaban el acoplamiento de los agentes de sesión y los cargadores sincronizables.

En su lugar, resolvimos este problema al aumentar nuestro protocolo de carga de datos con versiones de solicitud y respuesta basadas en su progreso relativo en los flujos de invalidación. Dado que el flujo representa un orden total de las actualizaciones de las bases de datos, nuestro progreso del flujo se puede utilizar como un contador de versiones global.

versiones de solicitud y respuesta

versiones de solicitud y respuesta

Recargas de invalidación

Los servidores de sincronización cargan muchísimos datos: la mayoría de todas las lecturas para el sitio. Nuestra nueva arquitectura requiere que los intermediarios de sesión y el cargador sincronizable intercambien una gran cantidad de datos a través de la red. Para las suscripciones nuevas, esta sobrecarga de red es relativamente insignificante. Sin embargo, esto es particularmente ineficiente para las recargas de invalidación, ya que a menudo no necesitamos devolver la respuesta completa, solo los datos actualizados³. Ciertos casos son particularmente malos en este sentido. Imagina un caso en el que un usuario ha paginado 10 000 tareas en un proyecto y otro usuario cambia constantemente las descripciones de las tareas en este proyecto: ¡todas las tareas tendrían que enviarse en cada invalidación! Claramente, lo ideal es enviar solo los datos actualizados, pero ¿cómo lo implementamos de manera eficiente?

recargas por invalidación

recargas por invalidación

Huella digital

Para que el cargador sincronizable calcule el delta de los datos actualizados, tiene que saber qué datos ya tiene el solicitante. Pero pasar los datos más recientes con la solicitud sería tan costoso como devolver el resultado completo.  Necesitamos representar los datos de una manera más eficiente en términos de espacio.

Bueno, el hashing es una excelente manera de ahorrar espacio. Cada bit de datos granulares que nos interesa se denomina syncable. Podemos calcular un hash murmur de 128 bits de cada syncable serializado para usarlo como huella digital ⁴. En concreto, esta huella digital es un identificador para esa versión del syncable.

Dondequiera que hagamos un seguimiento de los datos sincronizables, podemos usar sus huellas digitales en su lugar. Ahora, cuando queramos dar seguimiento a una respuesta de suscripción completa, ¡podemos usar un conjunto de huellas digitales sin tener que pasar los datos completos!


Nota al margen: ¿Qué es un elemento sincronizable y cómo se relaciona con las suscripciones?

Los elementos sincronizables son el contenido del resultado de una suscripción. Cuando cargamos una suscripción, los resultados se devuelven como un conjunto de sincronizables. Más específicamente, un elemento sincronizable puede ser un objeto, una consulta o un resultado de SCV.

asignación de sincronizable a suscripción

asignación de sincronizable a suscripción

Claramente, cada suscripción se asocia con varios elementos sincronizables. Sin embargo, los elementos sincronizables se pueden compartir entre múltiples suscripciones (cuando cargan datos superpuestos). Por lo tanto, en realidad hay una asignación de muchos a muchos entre las suscripciones y los elementos sincronizables.


Pasamos el conjunto de estas huellas digitales con cada solicitud del agente de sesión. En el cargador de elementos sincronizables, calculamos la respuesta completa, calculamos su conjunto de huellas digitales, excluimos cualquier dato que se superponga con la solicitud y devolvemos el delta.

¿Cómo lo logramos?

Dado el tamaño y la importancia del cambio, dividimos la implementación en aproximadamente 4 etapas. 

Etapa 1: Refactorización del monolito

  • Dividimos nuestro código de gestión de sesiones y carga de datos altamente acoplado en componentes independientes

Etapa 2 - Cargador sincronizable local

  • Usar el nuevo componente de carga de datos para crear un servidor gRPC local y migrar la carga de datos

etapas 1-2

Etapa 3: cargador sincronizable remoto

  • Crear un nuevo binario de syncable-loader y su implementación

  • Migrar toda la carga de datos del sync-server a nuestra nueva implementación de syncable-loader

Etapa 4 - Binario separado de session-broker

  • Crear un nuevo binario de session-broker y su implementación

  • Migrar todo el tráfico de los servidores de sincronización a los agentes de sesión

etapas 3 y 4

¿Con qué desafíos nos encontramos?

Muchos. ¿Por dónde empezar?

Respuestas grandes

Un problema con el que nos encontramos rápidamente fue el gran tamaño de las respuestas. Dado que la carga de datos en los servidores de sincronización se realizaba dentro del mismo proceso, esto no había resultado ser un gran problema hasta ahora⁵. Sin embargo, una vez que comenzamos a cargar datos a través de un límite local de gRPC, empezamos a encontrar muchos problemas. 

Siempre sospechamos que algunas respuestas podrían ser grandes, pero cuando comenzamos a investigar esto, encontramos resultados realmente sorprendentes. ¡Teníamos miles de cargas por día que regularmente superaban los 100 MiB! Físicamente, no podíamos devolver respuestas tan grandes a través de un método gRPC unario (se empieza a alcanzar el tamaño máximo de trama de http2). ¿Qué hacer?

Consideramos algunas formas de solucionar esto sistemáticamente, pero finalmente llegamos a la conclusión de que teníamos que abordar las causas subyacentes. Podríamos haber implementado la transmisión del lado del servidor de gRPC, pero los elevados costos de serialización incurridos y la elevada contención de sockets serían bastante regresivos para la latencia y el rendimiento. Podríamos simplemente rechazar estas respuestas, pero la tasa de ocurrencia era demasiado alta para que esto fuera aceptable. 

Nos decidimos por un enfoque de tres fases en el que marcamos todas las respuestas grandes, analizamos y eliminamos cada caso problemático y, luego, aplicamos un límite superior estricto para el tamaño de la respuesta.

Marcamos todas las cargas de más de 1 MB y registramos eventos detallados sobre la fuente, el uso y el desglose de los datos. Hubo algunos casos de uso costosos diferentes, pero el más notorio probablemente fueron los blobs de miniaturas de archivos adjuntos. Resulta que se codificaban como cadenas base64 y se incluían en las respuestas serializadas. Eran tolerables en pequeñas cantidades, pero rápidamente se volvían enormes cuando se cargaban en masa, como al cargar una vista en cuadrícula que mostraba miniaturas de los archivos adjuntos para cada tarea.

Pudimos solucionar progresivamente problemas como estos al restringir las miniaturas para respuestas enormes, usar miniaturas más pequeñas y, finalmente, eliminar los datos binarios de la respuesta. Después de simples mitigaciones como estas, la cantidad de respuestas enormes desapareció y, posteriormente, pudimos aplicar limitaciones de tamaño de respuesta a nivel de marco⁶.

Colisiones de temas

Otro problema extraño que encontramos fueron las colisiones de temas de PubSub. Resulta que teníamos usos de marco no conformes que generaban el mismo tema de suscripción independientemente del dominio. Cuando PubSub solo ocurría en un solo tipo de proceso, los efectos eran relativamente benignos. Por lo general, un único tema de Pubsub corresponde a los datos de un único dominio. Sin embargo, con pubsub ahora dividido entre agentes de sesión y cargadores sincronizables, era posible que los dos tipos de procesos no estuvieran de acuerdo sobre el dominio de un tema en particular. Cuando esto sucedía, veíamos una tasa elevada y estable de recargas de invalidación debido a esta “incompatibilidad de dominio”. Afortunadamente, la solución fue bastante sencilla, pero es interesante cómo este error sobrevivió en nuestro marco durante tanto tiempo sin ser detectado. 

Reajuste de las cargas de trabajo

Los agentes de sesión y los cargadores sincronizables tienen cargas de trabajo que son considerablemente diferentes de las de los servidores de sincronización originales. Los agentes de sesión solo son responsables de la gestión de sesiones y los cargadores sincronizables solo son responsables de la carga de datos. 

No estábamos del todo seguros de cómo afectaría esto a sus requisitos de recursos, por lo que comenzamos con solicitudes de recursos (cpu/mem) y configuraciones de horizontal pod autoscaler (HPA) similares para ambos. 

session-brokers

Según lo que observamos, quedó claro que los agentes de sesión eran considerablemente más ligeros. Funcionaban de manera confiable con una CPU muy baja (en todo caso, estaban mucho más limitados por la memoria⁷). Unas pocas réplicas parecían suficientes para atender el tráfico de toda una celda de infraestructura. Sin embargo, cuando realmente redujimos las minReplicas en el HPA, observamos que las recargas de datos y la latencia de recarga se dispararon. ¿Qué estaba pasando?

En resumen, nos habíamos olvidado de considerar todas nuestras configuraciones compartidas en torno al tamaño de la caché y los limitadores. Con solo unas pocas réplicas, cada agente de sesión estaba gestionando muchas más sesiones por pod (alrededor de 3,5 veces) que un servidor de sincronización típico. Dado que cada uno estaba viendo muchos más datos, estaban llenando por completo sus cachés de tema ⇔ suscripción de PubSub y cada expulsión desencadenaba una recarga (por seguridad). Al aumentar adecuadamente este umbral en aproximadamente 6 veces, se solucionaron las elevadas tasas de eliminación de la caché y de recarga. Del mismo modo, descubrimos que nuestros limitadores de recarga jerárquicos estaban mal configurados para las nuevas tasas de tráfico que llegaban. Del mismo modo, ajustar la configuración de estos limitadores condujo a una reducción drástica de la latencia de recarga y del retraso de reactividad de extremo a extremo (es decir, cuánto tiempo tarda una aplicación web en ver su propia escritura) de alrededor de 5 a 10 veces.

syncable-loaders

Por otro lado, los cargadores sincronizables eran considerablemente más pesados de lo esperado. Cada servidor cargaría más suscripciones por segundo (alrededor de 1,5 veces) que un servidor de sincronización con recursos equivalentes. A diferencia de los agentes de sesión, estaban mucho más limitados por la CPU ⁸.

Curiosamente, una parte no insignificante de la CPU se atribuyó a un aumento en las desoptimizaciones de Truffle relacionadas con nuestro código TS SCV. Lo más probable es que esto se debiera a que cada cargador sincronizable accedía a una mayor parte de nuestro código SCV. En cualquier caso, fue necesario un modesto aumento en el tamaño de nuestra caché de código⁹. 

Calentamiento de procesos

El calentamiento de procesos para la carga de datos ha sido históricamente un desafío. Afortunadamente, en nuestra nueva arquitectura, es un poco más sencillo. Nuestra interfaz principal de los cargadores sincronizables son las consultas de datos sin estado, por lo que podemos precalentarlos simplemente reproduciendo o reflejando el tráfico existente entre los agentes de sesión y los cargadores sincronizables.

Por otro lado, todavía nos enfrentamos a muchos de los mismos desafíos que con el calentamiento de los servidores de sincronización. Principalmente, se necesita mucha CPU para precalentar los procesos, y esto causa todo tipo de problemas de vecinos ruidosos (para nosotros, la métrica relevante aquí es el bloqueo parcial, ya que no estamos alcanzando los límites de limitación de k8s) en el inicio. Una buena mejora que hicimos aquí fue usar el redimensionamiento de pods en el lugar para limitar los recursos de un syncable-loader al inicio, pero permitir que se amplíe después del inicio. 

A pesar de esto, el calentamiento de cada pod todavía toma unos minutos. Al observar los perfiles de JFR, creemos que el principal cuello de botella es la compilación insuficiente del código TS SCV durante el calentamiento, y creemos que hay más margen allí. Estamos buscando activamente calentar con mayor precisión las rutas relevantes con métodos más específicos y remodelar nuestras interfaces de TS¹⁰ para lograr una mejor compilación.

Nuestros intermediarios de sesión no son responsables de la carga de datos y, en la práctica, nunca requirieron ningún tipo de calentamiento.

¿Cómo ayudó?

Nuestra nueva arquitectura redujo significativamente nuestra complejidad operativa, aceleró las implementaciones y la velocidad del código, y abrió la puerta a futuras oportunidades de velocidad y escalamiento. 

Nuestra nueva arquitectura simplemente se escala automáticamente a los cambios en el tráfico total de lectura sin requerir una gestión compleja del tráfico. Cada tipo de operación de cambio de tráfico se gestiona de forma ordenada simplemente reiniciando los pods. ¿Necesitas iniciar todas las sesiones? Inicie un ciclo de todos los agentes de sesión. ¿Necesitas borrar nuestras cachés? Inicie un ciclo de todos los cargadores sincronizables. Ambas operaciones son seguras en lo que respecta al tiempo de actividad.

En el pasado, la velocidad de desarrollo de productos se veía obstaculizada principalmente por la implementación de servidores de sincronización. En nuestro nuevo mundo, solo necesitamos implementar cargadores sincronizables para implementar el código del producto. Para ello, hemos trasladado los cargadores sincronizables a su propia celda implementable, que estamos trabajando para implementar con mayor frecuencia (y, finalmente, junto con el código del producto). La implementación de cargadores sincronizables ya es aproximadamente un 40 % más rápida (aproximadamente 20 minutos más rápida) que la de los servidores de sincronización (podemos aumentar la velocidad de manera segura mucho más), y nuestro objetivo es lograr mayores aumentos en el futuro.

Cabe destacar que las mejoras de rendimiento no eran un objetivo de este trabajo, pero vale la pena mencionarlas dada la cantidad de diseño e implementación que implicaron. La latencia de los resultados de las consultas de computación en realidad mejoró en nuestro nuevo sistema (probablemente debido a un mejor equilibrio de carga/ajuste/escalado automático). En consecuencia, la latencia de la suscripción inicial es notablemente mejor. Por otro lado, la latencia de reflejo de mutación de extremo a extremo (es decir, cuánto tiempo tarda un cambio en distribuirse a otras sesiones) es aproximadamente la misma. Los seguimientos muestran que es probable que esto se deba al sesgo en el consumo del flujo de PubSub entre los agentes de sesión y los cargadores sincronizables (los cargadores sincronizables no pueden atender una solicitud hasta que estén actualizados con la versión de la solicitud).

¿Qué queda por hacer?

Nuestro sistema actual ha mejorado mucho, pero sigue limitando la velocidad del producto al requerir consideraciones sobre la compatibilidad hacia atrás y hacia adelante con cada versión. Sin embargo, con todas nuestras mejoras en la velocidad de implementación, ahora es posible implementar todos nuestros cambios en el modelo de datos juntos, lo que elimina cualquier necesidad de considerar la compatibilidad con versiones anteriores o posteriores. Estamos trabajando activamente para crear esto en un futuro próximo.

Una de nuestras principales motivaciones para este trabajo fueron las regresiones de rendimiento que son difíciles de atribuir. En particular, esta es un área en la que no hemos realizado mejoras significativas como resultado de este trabajo. Lo positivo es que ahora este es uno de los principales problemas que abordaremos en el futuro. A diferencia de la mayor parte del trabajo que se analiza aquí, es un problema considerablemente más interdisciplinario que involucra consideraciones sobre el dominio del producto, el modelo de datos, el marco y la infraestructura. 

grupos de trabajadores del cargador sincronizables por función

grupos de trabajadores del cargador sincronizables por función

Nos entusiasma explorar soluciones en la infraestructura de la plataforma (p. ej., grupos de trabajadores por función), los marcos de la plataforma y las herramientas de la plataforma (p. ej., pruebas de caja negra/caja blanca).


Biografía del autor

Arvind Vijayakumar es ingeniero en el equipo de LunaDb, donde trabaja para ayudar a crear y ampliar la plataforma central de carga de datos de Asana, la infraestructura crítica que garantiza que los usuarios siempre vean actualizaciones precisas, reactivas y ultrarrápidas en nuestras aplicaciones web y API.

Reconocimientos al equipo

Este trabajo para escalar LunaDb ha sido un esfuerzo de equipo de larga duración que abarca los últimos años y en el que han participado muchos miembros de LunaDb, tanto actuales como anteriores: Brandon Zhang, Alex Matevish, Sean Wentzel, Eric Walton, Spencer Yu, Sophia Yao, George Ong, Tyler Prete, Koushik Ghosh, Natan Dubitski, Vinodh Chandra Murthy y más


Notas al pie

  1. En particular, lo creamos antes de que Facebook pusiera GraphQL en código abierto

  2. Hay más detalles en la publicación anterior sobre el calentamiento de procesos, pero en resumen, dependemos de permitir que los pods de sincronización se disparen arbitrariamente como una forma de escalar rápidamente a los picos de tráfico local

  3. También realizamos actualizaciones con una granularidad por objeto en lugar de por campo (por razones históricas). Esto aumenta la cantidad de datos involucrados en las recargas de invalidación.

  4. Utilizamos un hash murmur de 128 bits para evitar colisiones.

  5. En particular, hemos estado usando la compresión de WebSocket durante mucho tiempo, lo que probablemente mitiga los efectos percibidos por el cliente.

  6. Cabe destacar que, en un principio, también tuvimos algunos problemas con la sobrecarga del salto de red. Después de investigar un poco, descubrimos que la mayor parte de la sobrecarga se debía en realidad a nuestra malla de servicios (Istio/Envoy) y realizamos algunos ajustes específicos para mejorar el rendimiento en este aspecto.

  7. Un factor importante en la memoria retenida fue reescribir nuestro almacén sincronizable del lado del servidor para que solo retenga hashes de datos. No fue un problema sencillo y es posible que publiquemos una publicación de seguimiento sobre esto. Sin esto, los datos del cliente almacenados hacían que los agentes de sesión usaran mucha más memoria.

  8. Antes de esto, todos los servidores de sincronización se ejecutaban en instancias optimizadas para la memoria. Por otro lado, los cargadores sincronizables se benefician de tipos de nodos con más CPU, como las instancias mixtas.

  9. Un atributo notable de ejecutar TS en GraalVM de esta manera es que utiliza bastante más caché de código de lo que es típico en una aplicación Scala/Java JVM más estándar.

  10. Por lo que sabemos, el alto polimorfismo hace que la compilación de TS sea más costosa. Estamos estudiando la posibilidad de cambiar a interfaces monomórficas y aprovechar las especializaciones polimórficas de informes