62 puntos por xguru 2023-11-09 | 9 comentarios | Compartir por WhatsApp

Elixir como sistema de fanout

  • Cada vez que ocurre algo en Discord, como cuando se envía un mensaje o alguien entra a un canal de voz, hay que actualizar la UI en los clientes de todos los usuarios en línea que están en el mismo servidor (también llamado "guild")
  • Usan un proceso de Elixir por guild como punto central de enrutamiento para todo lo que ocurre en ese servidor, y otro proceso ("sesión") para el cliente de cada usuario conectado
  • El proceso del guild se encarga de rastrear las sesiones de los usuarios que pertenecen a ese guild y de propagarles las tareas
  • Cuando una sesión recibe una actualización, la entrega al cliente a través de la conexión WebSocket
  • Algunas tareas aplican a todas las personas del servidor, mientras que otras requieren verificar permisos, así que hay que conocer no solo la información de roles y canales de ese servidor, sino también los roles del usuario
  • La actividad de un guild es proporcional al número de personas del servidor, y la cantidad de trabajo necesaria para hacer fanout de un mensaje también es proporcional al número de usuarios en línea en ese servidor
    • Es decir, la cantidad de trabajo necesaria para manejar un servidor de Discord crece a la cuarta potencia según el tamaño del servidor
    • Si hay 1,000 personas en línea en un servidor y todas dijeron una vez "me gusta la gelatina", eso significa procesar 1 millón de notificaciones
    • Si fueran 10,000, serían 100 millones de notificaciones; y con 100,000, habría que entregar 10 mil millones de notificaciones
  • Además del problema general de throughput, a medida que el servidor crece algunas tareas empiezan a volverse más lentas
  • Para que el servidor se sienta responsivo, casi todas las tareas deben procesarse rápido: cuando se envía un mensaje, otras personas deben verlo de inmediato; y cuando alguien entra a un canal de voz, debe poder empezar a participar enseguida
  • Si una tarea costosa tarda varios segundos, la experiencia de usuario se degrada
  • Entonces, ¿cómo pudieron soportar el servidor de Midjourney, que tiene más de 10 millones de miembros y más de 1 millón siempre en línea?
    • Primero era importante entender el rendimiento del sistema
    • Después de obtener datos, buscaron oportunidades para mejorar tanto el throughput como la capacidad de respuesta

Entender el rendimiento del sistema

  • Wall time analysis:
    • Stack tracing con Process.info(pid, :current_stacktrace)
    • Midieron el loop de procesamiento de eventos para registrar cuántos mensajes de cada tipo se recibían y el tiempo máximo/mínimo/promedio/total que tomaba procesarlos
    • Ignoraron por completo las tareas que representaban menos del 1% del tiempo total, salvo en casos de explosión extrema
    • Excluyeron las tareas baratas y destacaron las más costosas
  • Process Heap Memory Analysis
    • También era importante entender cómo se usaba la memoria
    • En lugar de revisar cada elemento uno por uno, escribieron una librería helper que muestrea mapas y listas grandes (no structs) para estimar el uso de memoria
    • Esta librería no solo ayudó a entender el rendimiento del GC, sino también a identificar qué campos valía la pena optimizar y cuáles en realidad no eran relevantes
  • Después de identificar en qué consumía tiempo el proceso del guild, pudieron definir estrategias para evitar que el proceso del guild estuviera ocupado al 100%
    • En algunos casos bastaba con reescribir implementaciones ineficientes de forma más eficiente
    • Pero eso solo alcanzaba hasta cierto punto. Hacía falta un cambio más fundamental

Sesiones pasivas: evitar trabajo innecesario

  • Una de las mejores maneras de aliviar un cuello de botella de throughput es reducir el trabajo
  • Una forma de hacerlo es considerar los requisitos de la aplicación cliente
  • En la topología original, todos los usuarios recibían todas las acciones visibles de todos los guilds a los que pertenecían
  • Pero algunos usuarios pertenecen a varios guilds y puede que ni siquiera hagan clic para ver qué está pasando en algunos de ellos
  • ¿Y si no se enviara todo hasta que el usuario haga clic? Ya no haría falta verificar permisos para cada mensaje uno por uno, y además se reduciría mucho la cantidad de datos enviados al cliente
  • A esto lo llamaron conexión "Passive", y la mantuvieron en una lista separada de las conexiones "Active", que sí deben recibir todos los datos
  • Como resultado, en los servidores grandes alrededor del 90% de las conexiones usuario-guild eran pasivas, así que el costo del trabajo de fanout se redujo en 90%
  • Eso les dio algo de aire, pero a medida que la comunidad siguió creciendo, naturalmente no fue suficiente por sí solo
    (una reducción de 10x en la carga de trabajo puede dar una ganancia de alrededor de 3x en el tamaño máximo de comunidad)

Relays: dividir el fanout entre varias máquinas

  • Una técnica estándar para escalar el límite de throughput de un solo núcleo es dividir el trabajo entre varios hilos (o, en términos de Elixir, procesos)
  • A partir de esa idea construyeron un sistema llamado "relay" entre el guild y las sesiones de usuario
  • En vez de procesar todo el trabajo de manejo de sesiones en un solo proceso, lo dividieron entre varios relays, permitiendo que un solo guild use más recursos para servir a comunidades grandes
  • Algunas tareas todavía deben hacerse en el proceso principal del guild, pero esto les permitió manejar comunidades con cientos de miles de miembros
  • Para implementarlo, tuvieron que identificar qué trabajo importante debía ejecutarse en los relays, qué trabajo debía seguir en el guild y qué podía ejecutarse en ambos sistemas
  • Una vez que entendieron lo necesario, comenzaron un trabajo de refactor para extraer lógica compartible entre sistemas
    • Por ejemplo, gran parte de la lógica de cómo hacer fanout se refactorizó a una librería usada tanto por el guild como por los relays
    • Parte de la lógica que no podía compartirse necesitó otras soluciones; el manejo del estado de voz, por ejemplo, se implementó haciendo que el relay básicamente proxyeara todos los mensajes hacia el guild con cambios mínimos
  • Una decisión de diseño interesante al lanzar los relays fue incluir la lista completa de miembros en el estado de cada relay
    • Fue una buena decisión por simplicidad, porque toda la información necesaria de miembros estaba disponible
    • Pero a escala de Midjourney, con cientos de miles o millones de miembros, este diseño empezó a dejar de tener sentido
  • No solo había decenas de copias de la información de decenas de millones de miembros ocupando RAM, sino que crear un nuevo relay requería serializar toda esa información y enviarla al relay nuevo, lo que causaba retrasos de decenas de segundos en el guild
  • Para resolverlo, agregaron lógica para identificar qué miembros necesitaba realmente un relay para funcionar, que resultó ser apenas una fracción muy pequeña del total

Mantener la capacidad de respuesta del servidor

  • Además de mantenerse dentro de los límites de throughput, también tenían que conservar la capacidad de respuesta del servidor
  • Aquí también fue útil revisar los datos de timing
  • Resultó más efectivo enfocarse en las tareas con mayor duración por llamada que en la duración total
  • Procesos worker + ETS
    • Una de las mayores causas de falta de respuesta eran las tareas que debían ejecutarse en el guild y recorrer a todos los miembros
    • Esto ocurre muy rara vez, pero pasa. Por ejemplo, si alguien hace un ping a todos, hay que averiguar quiénes en el servidor pueden ver ese mensaje
    • Pero estas verificaciones pueden tardar varios segundos. ¿Cómo manejar eso?
    • Lo ideal era ejecutar esta lógica mientras el guild seguía procesando otras tareas, pero los procesos de Elixir no comparten memoria de forma eficiente. Así que hacía falta otra solución
    • Una de las herramientas de Erlang/Elixir para guardar datos en memoria compartible entre procesos es ETS
    • Es una base de datos en memoria con soporte para que varios procesos de Elixir accedan de forma segura
    • Es menos eficiente que acceder a datos en el heap del proceso, pero sigue siendo muy rápida. Además, tiene la ventaja de reducir el tamaño del heap del proceso y así disminuir la latencia del garbage collection
    • Decidieron crear una estructura híbrida para conservar la lista de miembros:
      • Guardar la lista de miembros en ETS para que otros procesos también puedan leerla, pero mantener en el heap del proceso los cambios recientes (inserciones, actualizaciones, eliminaciones)
      • Como la mayoría de los miembros casi nunca se actualizan, el conjunto de cambios recientes es una parte muy pequeña del conjunto total
    • Ahora podían crear procesos worker usando los miembros en ETS y pasarles el identificador de la tabla ETS cuando hubiera trabajo costoso que hacer
    • Los procesos worker podían encargarse de la parte costosa mientras el guild seguía con otras tareas. También se menciona una forma sencilla de hacer esto (con un snippet de código en el original)
    • Un ejemplo de uso de este método es cuando deben mover un proceso de guild de una máquina a otra (normalmente por mantenimiento o despliegue)
    • En ese proceso crean un nuevo proceso para manejar el guild en la máquina nueva, luego copian el estado del proceso anterior al nuevo, reconectan todas las sesiones conectadas al nuevo proceso del guild y después procesan el backlog acumulado durante ese trabajo
    • Con procesos worker, pueden transferir la mayoría de los miembros (que pueden ser varios GB de datos) mientras el proceso del guild existente sigue trabajando, reduciendo así los retrasos de varios minutos en cada handoff
  • Manifold offload
    • Otra idea para mejorar la capacidad de respuesta y superar los límites de throughput fue ampliar manifold para que, en vez de hacer fanout desde el proceso del guild, usara procesos separados de "sender" para hacer el fanout hacia los nodos receptores
    • Esto no solo reduce la carga del proceso del guild, sino que también lo protege del backpressure de BEAM si una de las conexiones de red entre el guild y los relays se congestiona temporalmente (BEAM es la máquina virtual donde corre el código de Elixir)
    • En teoría parecía una solución fácil, pero al probar esta función (llamada manifold offload) descubrieron que en realidad empeoraba mucho el rendimiento
    • ¿Cómo podía pasar eso? En teoría había menos carga de trabajo, entonces ¿por qué el proceso estaba más ocupado?
    • Al revisar en detalle, vieron que la mayor parte del trabajo extra estaba relacionada con garbage collection
    • Ahí erlang.trace apareció como salvación
    • Esta función les permitió recolectar datos cada vez que el proceso del guild hacía garbage collection, dándoles visibilidad no solo sobre la frecuencia, sino también sobre qué lo estaba disparando
    • A partir de esa información de tracing, revisaron el código de garbage collection de BEAM y descubrieron que, cuando manifold offload estaba activado, la condición que disparaba los major (full) GC era el virtual binary heap
    • El virtual binary heap es una función diseñada para poder liberar memoria usada por strings que no están almacenados dentro del heap del proceso, incluso cuando el proceso no necesita hacer garbage collection
    • Desafortunadamente, su patrón de uso hacía que se disparara garbage collection una y otra vez para recuperar unos cientos de KB de memoria a cambio de copiar heaps de tamaño de varios GB, una compensación que claramente no valía la pena
    • Por suerte, en BEAM este comportamiento puede ajustarse con la process flag min_bin_vheap_size
    • Al aumentar ese valor a unos cuantos MB, el comportamiento patológico de garbage collection desapareció, y pudieron activar manifold offload con una mejora importante de rendimiento

9 comentarios

 
roxie 2023-11-18

Elixir, dando pelea

 
arfwene 2023-11-10

Las sesiones pasivas técnicamente no tienen gran cosa, pero me parece una buena idea.
Definitivamente podría reducir la carga.

No solo Discord; seguro que en otros lugares también han implementado una función así, y me da curiosidad qué diferencias habrá entre servicios.

 
mhj5730 2023-11-10

Está increíble, wow.

 
abhidhamma 2023-11-09

Parece que el destino final del famoso streaming SSR de Next.js estos días también es el framework Phoenix de Elixir. En muchos sentidos, Elixir parece estar en la primera línea de los lenguajes de programación modernos.

 
papillon 2023-11-09

¡Vamos, Elixir!

 
n1ghtc4t 2023-11-09

Hace algunos años, tomando como referencia el blog técnico de Discord, terminamos adoptando Elixir para un servicio en tiempo real, y guardo muy buenos recuerdos porque pudimos lanzar el servicio con gran satisfacción tanto en velocidad de desarrollo como en seguridad, no solo para mí sino también para los ejecutivos a cargo.

 
kotlinc 2023-11-09

Ojalá Elixir se vuelva más popular.

 
[Este comentario fue ocultado.]
 
damtet 2023-11-10

Parece que hoy en día ya no es tanto cosa de las grandes como Naver, Kakao o Line, y más bien las startups pequeñas y medianas parecen estar dominadas por Spring. Supongo que no hay mucho que hacer, porque la mayoría de esos managers de startups son especialistas en Spring.

Toda ineficiencia se puede resolver con dinero y escala. Total, la empresa de todos modos no entiende muy bien.