Cómo Braze aprovecha Ruby a escala

Publicado: 2022-08-18

Si es un ingeniero que lee Hacker News, Developer Twitter o cualquier otra fuente de información similar, es casi seguro que se ha encontrado con miles de artículos con títulos como "La velocidad de Rust vs C", "Qué hace que Node. js más rápido que Java?” o “¿Por qué debería usar Golang y cómo empezar?”. Estos artículos generalmente argumentan que existe un idioma específico que es la opción obvia para la escalabilidad o la velocidad, y que lo único que debe hacer es adoptarlo.

Mientras estaba en la universidad y mi primer año o dos como ingeniero, leía estos artículos e inmediatamente iniciaba un proyecto favorito para aprender el nuevo lenguaje o marco del día. Después de todo, se garantizó que funcionaría "a escala global" y "más rápido que cualquier cosa que hayas visto", y ¿quién puede resistirse a eso? Eventualmente me di cuenta de que en realidad no necesitaba ninguna de estas cosas tan específicas para la mayoría de mis proyectos. Y a medida que avanzaba mi carrera, me di cuenta de que ninguna elección de lenguaje o marco me daría estas cosas de forma gratuita.

En cambio, descubrí que la arquitectura es la palanca más importante cuando se busca escalar sistemas, no lenguajes o marcos.

Aquí en Braze, operamos a una inmensa escala global. Y sí, usamos Ruby y Rails como dos de nuestras principales herramientas para hacerlo. Sin embargo, no existe un valor de configuración “global_scale = true” que lo haga posible: es el resultado de una arquitectura bien pensada que se extiende desde lo más profundo de las aplicaciones hasta las topologías de implementación. Los ingenieros de Braze examinan constantemente los cuellos de botella de escalado y descubren cómo hacer que nuestro sistema sea más rápido, y la respuesta generalmente no es "alejarse de Ruby": es casi seguro que será un cambio en la arquitectura.

Así que echemos un vistazo a cómo Braze aprovecha la arquitectura reflexiva para resolver realmente la velocidad y una escala global masiva, ¡y dónde encajan Ruby y Rails (y dónde no)!

El poder de la mejor arquitectura de su clase

Una solicitud web simple

Debido a la escala a la que operamos, sabemos que los dispositivos asociados con las bases de usuarios de nuestros clientes realizarán miles de millones de solicitudes web todos los días que deberán ser atendidas por algún servidor web Braze. E incluso en los sitios web más simples, tendrá un flujo relativamente complejo asociado con una solicitud de un cliente al servidor y viceversa:

  1. Comienza con la resolución de DNS del cliente (generalmente su ISP) que determina a qué dirección IP ir, según el dominio en la URL de su sitio web.

  2. Una vez que el cliente tiene una dirección IP, enviará la solicitud a su enrutador de puerta de enlace, que la enviará al enrutador del "siguiente salto" (lo que puede suceder varias veces), hasta que la solicitud llegue a la dirección IP de destino.

  3. A partir de ahí, el sistema operativo en el servidor que recibe la solicitud manejará los detalles de la red y notificará al proceso de espera del servidor web que se recibió una solicitud entrante en el socket/puerto en el que estaba escuchando.

  4. El servidor web escribirá la respuesta (el recurso solicitado, tal vez un index.html) en ese socket, que viajará hacia atrás a través de los enrutadores hasta el cliente.

Cosas bastante complicadas para un sitio web simple, ¿no? Afortunadamente, muchas de estas cosas están a cargo de nosotros (más sobre eso en un segundo). ¡Pero nuestro sistema todavía tiene almacenes de datos, trabajos en segundo plano, problemas de concurrencia y más con los que tiene que lidiar! Vamos a sumergirnos en cómo se ve eso.

Los primeros sistemas que admiten escala

Los DNS y los servidores de nombres normalmente no requieren mucha atención en la mayoría de los casos. Su servidor de nombres de dominio de nivel superior probablemente tendrá algunas entradas para asignar "yourwebsite.com" a los servidores de nombres de su dominio, y si está utilizando un servicio como Amazon Route 53 o Azure DNS, ellos manejarán el nombre. servidores para su dominio (por ejemplo, administrar A, CNAME u otro tipo de registros). Por lo general, no tiene que pensar en escalar esta parte, ya que los sistemas que está utilizando lo manejarán automáticamente.

Sin embargo, la parte de enrutamiento del flujo puede volverse interesante. Hay algunos algoritmos de enrutamiento diferentes, como Open Shortest Path First o Routing Information Protocol, todos ellos diseñados para encontrar la ruta más rápida/corta del cliente al servidor. Debido a que Internet es efectivamente un gráfico conectado gigante (o, alternativamente, una red de flujo), puede haber múltiples rutas que se pueden aprovechar, cada una con un costo correspondiente mayor o menor. Sería prohibitivo hacer el trabajo para encontrar la ruta más rápida absoluta, por lo que la mayoría de los algoritmos usan heurísticas razonables para obtener una ruta aceptable. Las computadoras y las redes no siempre son confiables, por lo que confiamos en Fastly para mejorar la capacidad de nuestros clientes para enrutar a nuestros servidores más rápidamente.

Fastly funciona proporcionando puntos de presencia (POP) en todo el mundo con conexiones muy rápidas y confiables entre ellos. Piense en ellos como la autopista interestatal de Internet. Los registros A y CNAME de nuestros dominios apuntan a Fastly, lo que hace que las solicitudes de nuestros clientes vayan directamente a la autopista. A partir de ahí, Fastly puede enrutarlos al lugar correcto.

La puerta de entrada a la soldadura fuerte

Bien, entonces la solicitud de nuestro cliente se ha ido por la autopista Fastly y está justo en la puerta principal de la plataforma Braze, ¿qué sucede después?

En un caso simple, esa puerta principal sería un único servidor que aceptaría solicitudes. Como puede imaginar, eso no se escalaría muy bien, por lo que en realidad apuntamos a Fastly a un conjunto de balanceadores de carga. Hay todo tipo de estrategias que pueden usar los balanceadores de carga, pero imagine que, en este escenario, Fastly distribuye las solicitudes a un grupo de balanceadores de carga de manera uniforme. Estos balanceadores de carga pondrán en cola las solicitudes y luego las distribuirán a los servidores web, que también podemos imaginar que se están tratando las solicitudes de los clientes en forma rotativa. (En la práctica, puede haber ventajas para ciertos tipos de afinidad, pero ese es un tema para otro momento).

Esto nos permite aumentar la cantidad de balanceadores de carga y la cantidad de servidores web según el rendimiento de las solicitudes que recibimos y el rendimiento de las solicitudes que podemos manejar. ¡Hasta ahora, hemos construido una arquitectura que puede manejar una avalancha gigante de solicitudes sin sudar! Incluso puede manejar patrones de tráfico en ráfagas a través de la elasticidad de las colas de solicitudes de los balanceadores de carga, ¡lo cual es asombroso!

Los Servidores Web

Finalmente, llegamos a la parte emocionante (Ruby): el servidor web. Usamos Ruby on Rails, pero eso es solo un marco web: el servidor web real es Unicorn. Unicorn funciona iniciando una serie de procesos de trabajo en una máquina, donde cada proceso de trabajo escucha en un socket del sistema operativo para el trabajo. Se encarga de la gestión de procesos por nosotros y difiere el equilibrio de carga de las solicitudes al propio sistema operativo. Solo necesitamos nuestro código Ruby para procesar las solicitudes lo más rápido posible; todo lo demás está efectivamente optimizado fuera de Ruby para nosotros.

Debido a que la mayoría de las solicitudes realizadas por nuestro SDK dentro de las aplicaciones de nuestros clientes o a través de nuestra API REST son asíncronas (es decir, no necesitamos esperar a que se complete la operación para devolver una respuesta específica a los clientes), la mayoría de nuestros Los servidores de API son extraordinariamente simples: validan la estructura de la solicitud, cualquier restricción de clave de API, luego lanzan la solicitud a una cola de Redis y devuelven una respuesta 200 al cliente si todo sale bien.

Este ciclo de solicitud/respuesta tarda aproximadamente 10 milisegundos en procesar el código de Ruby, y una parte de eso se gasta esperando en Memcached y Redis. Incluso si tuviéramos que reescribir todo esto en otro idioma, en realidad no es posible sacar mucho más rendimiento de esto. Y, en última instancia, es la arquitectura de todo lo que ha leído hasta ahora lo que nos permite escalar este proceso de ingesta de datos para satisfacer las necesidades cada vez mayores de nuestros clientes.

Las colas de trabajo

Este es un tema que hemos explorado en el pasado, por lo que no profundizaré en este aspecto. Para obtener más información sobre nuestro sistema de colas de trabajos, consulte mi publicación sobre Cómo lograr la resiliencia con las colas. En un nivel alto, lo que hacemos es aprovechar numerosas instancias de Redis que actúan como colas de trabajos, lo que amortigua aún más el trabajo que debe realizarse. Al igual que nuestros servidores web, estas instancias se dividen en zonas de disponibilidad, para brindar una mayor disponibilidad en el caso de un problema en una zona de disponibilidad particular, y vienen en pares principal/secundario que usan Redis Sentinel para redundancia. También podemos escalarlos tanto horizontal como verticalmente para optimizar tanto la capacidad como el rendimiento.

Los trabajadores

Esta es sin duda la parte más interesante: ¿cómo logramos que los trabajadores escalen?

En primer lugar, nuestros trabajadores y colas están segmentados por una serie de dimensiones: Clientes, tipos de trabajo, almacenes de datos necesarios, etc. Esto nos permite tener una alta disponibilidad; por ejemplo, si un almacén de datos en particular tiene dificultades, otras funciones seguirán funcionando perfectamente bien. También nos permite autoescalar los tipos de trabajadores de forma independiente, dependiendo de cualquiera de esas dimensiones. Terminamos siendo capaces de administrar la capacidad de los trabajadores de una manera escalable horizontalmente, es decir, si tenemos más de cierto tipo de trabajo, podemos escalar más trabajadores.

Este es el lugar donde podría comenzar a ver la importancia de la elección del lenguaje o del marco. En última instancia, un trabajador más eficiente podrá hacer más trabajo, más rápidamente. Los lenguajes compilados como C o Rust tienden a ser mucho más rápidos en tareas computacionales que los lenguajes interpretados como Ruby, y eso puede conducir a trabajadores más eficientes para algunas cargas de trabajo. Sin embargo, paso una gran cantidad de tiempo mirando rastros, y el procesamiento de CPU sin procesar es una cantidad sorprendentemente pequeña en el panorama general en Braze. La mayor parte de nuestro tiempo de procesamiento se dedica a esperar respuestas de los almacenes de datos o de solicitudes externas, no procesando números; no necesitamos un código C muy optimizado para eso.

Los almacenes de datos

Hasta ahora, todo lo que hemos cubierto es bastante escalable. Así que tomemos un minuto y hablemos sobre dónde pasan la mayor parte de su tiempo nuestros trabajadores: almacenes de datos.

Cualquiera que haya escalado servidores web o trabajadores asincrónicos que usan una base de datos SQL probablemente se haya topado con un problema de escala específico: Transacciones. Es posible que tenga un punto final que se encargue de completar un Pedido, lo que crea dos FulfillmentRequests y un PaymentReceipt. Si todo esto no sucede en una transacción, puede terminar con datos inconsistentes. Ejecutar numerosas transacciones en una sola base de datos simultáneamente puede resultar en mucho tiempo invertido en bloqueos, o incluso en puntos muertos. En Braze, abordamos ese problema de escalado de frente con los propios modelos de datos, a través de la independencia del objeto y la consistencia final. Con estos principios, podemos sacar mucho rendimiento de nuestros almacenes de datos.

Objetos de datos independientes

Aprovechamos mucho MongoDB en Braze, por muy buenas razones: a saber, nos permite escalar de forma sustancialmente horizontal fragmentos de MongoDB y obtener aumentos casi lineales en el almacenamiento y el rendimiento. Esto funciona muy bien para nuestros perfiles de usuario debido a su independencia entre sí: no hay instrucciones JOIN ni relaciones de restricción que mantener entre los perfiles de usuario. A medida que crece cada uno de nuestros clientes o agregamos nuevos clientes (o ambos), simplemente podemos agregar nuevas bases de datos y nuevos fragmentos a las bases de datos existentes para aumentar nuestra capacidad. Evitamos explícitamente funciones como transacciones de varios documentos para mantener este nivel de escalabilidad.

Además de MongoDB, a menudo utilizamos Redis como un almacén de datos temporal para cosas como el almacenamiento en búfer de información analítica. Debido a que la fuente de la verdad para muchos de esos análisis existe en MongoDB como documentos independientes durante un período de tiempo, mantenemos un grupo escalable horizontalmente de instancias de Redis para que actúen como búferes; bajo este enfoque, la identificación del documento con hash se usa en un esquema de fragmentación basado en claves, distribuyendo uniformemente la carga debido a la independencia. Los trabajos periódicos vacían esos búferes de un almacén de datos de escala horizontal a otro almacén de datos de escala horizontal. ¡Escala lograda!

Además, utilizamos Redis Sentinel para estas instancias al igual que lo hacemos para las colas de trabajo mencionadas anteriormente. También implementamos numerosos "tipos" de estos clústeres de Redis para diferentes propósitos, lo que nos brinda un flujo de fallas controlado (es decir, si un tipo particular de clúster de Redis tiene problemas, no vemos que las funciones no relacionadas comiencen a fallar al mismo tiempo).

Consistencia eventual

Braze también aprovecha la consistencia eventual como principio para la mayoría de las operaciones de lectura. Esto nos permite aprovechar la lectura de los miembros primarios y secundarios de los conjuntos de réplicas de MongoDB en la mayoría de los casos, lo que hace que nuestra arquitectura sea más eficiente. Este principio en nuestro modelo de datos nos permite utilizar mucho el almacenamiento en caché en toda nuestra pila.

Usamos un enfoque de varias capas con Memcached: básicamente, cuando solicitamos un documento de la base de datos, primero verificamos un proceso de Memcached local de la máquina con un tiempo de vida (TTL) muy bajo, luego verificamos una instancia remota de Memcached (con un TTL más alto), antes de preguntar directamente a la base de datos. Esto nos ayuda a reducir drásticamente las lecturas de la base de datos para documentos comunes, como la configuración del cliente o los detalles de la campaña. "Eventual" puede sonar aterrador, pero, en realidad, son solo unos segundos, y adoptar este enfoque reduce una enorme cantidad de tráfico de la fuente de la verdad. Si alguna vez ha tomado una clase de arquitectura informática, es posible que reconozca cuán similar es este enfoque a cómo funciona un sistema de caché L1, L2 y L3 de CPU.

Con estos trucos, podemos sacar mucho rendimiento de posiblemente la parte más lenta de nuestra arquitectura, y luego escalarla horizontalmente según corresponda cuando aumenten nuestras necesidades de rendimiento o capacidad.

Donde encajan Ruby y Rails

Aquí está la cosa: resulta que, cuando dedicas mucho esfuerzo a construir una arquitectura holística en la que cada capa se escala bien horizontalmente, la velocidad del lenguaje o el tiempo de ejecución es mucho menos importante de lo que piensas. Eso significa que las opciones de lenguajes, marcos y tiempos de ejecución se realizan con un conjunto completamente diferente de requisitos y restricciones.

Ruby y Rails tenían un historial comprobado de ayudar a los equipos a iterar rápidamente cuando se inició Braze en 2011, y todavía los usan GitHub, Shopify y otras marcas líderes porque continúan haciéndolo posible. Siguen siendo desarrollados activamente por las comunidades de Ruby y Rails, respectivamente, y ambos todavía tienen un gran conjunto de bibliotecas de código abierto disponibles para una variedad de necesidades. El par es una excelente opción para una iteración rápida, ya que tienen una gran flexibilidad y mantienen una cantidad significativa de simplicidad para los casos de uso común. Descubrimos que eso es abrumadoramente cierto todos los días que lo usamos.

Ahora, esto no quiere decir que Ruby on Rails sea una solución perfecta que funcionará bien para todos. Pero en Braze, descubrimos que funciona muy bien para potenciar una gran parte de nuestra canalización de ingesta de datos, canalización de envío de mensajes y nuestro panel orientado al cliente, todos los cuales requieren una iteración rápida y son fundamentales para el éxito de Braze. plataforma en su conjunto.

Cuando no usamos Ruby

¡Pero espera! No todo lo que hacemos en Braze está en Ruby. Hay algunos lugares a lo largo de los años en los que hemos hecho el llamado para dirigir las cosas hacia otros lenguajes y tecnologías por una variedad de razones. Echemos un vistazo a tres de ellos, solo para proporcionar una idea adicional sobre cuándo nos apoyamos y no en Ruby.

1. Servicios del remitente

Resulta que Ruby no es bueno para manejar un alto grado de solicitudes de red simultáneas en un solo proceso. Eso es un problema porque cuando Braze envía mensajes en nombre de nuestros clientes, algunos proveedores de servicios finales pueden requerir una solicitud por usuario. Cuando tenemos una pila de 100 mensajes listos para enviar, no queremos esperar a que termine cada uno de ellos para pasar al siguiente. Preferiríamos hacer todo ese trabajo en paralelo.

Ingrese a nuestros "Servicios de remitente", es decir, microservicios sin estado escritos en Golang. Nuestro código de Ruby en el ejemplo anterior puede enviar los 100 mensajes a uno de estos servicios, que ejecutará todas las solicitudes en paralelo, esperará a que finalicen y luego devolverá una respuesta masiva a Ruby. Estos servicios son sustancialmente más eficientes que lo que podríamos hacer con Ruby cuando se trata de redes simultáneas.

2. Conectores de corriente

Nuestra función de exportación de datos de gran volumen de Braze Currents permite a los clientes de Braze transmitir datos continuamente a uno o más de nuestros muchos socios de datos. La plataforma funciona con Apache Kafka y la transmisión se realiza a través de Kafka Connectors. Técnicamente, puede escribirlos en Ruby, pero la forma oficialmente admitida es con Java. Y debido al alto grado de soporte de Java, escribir estos conectores es mucho más fácil de hacer en Java que en Ruby.

3. Aprendizaje automático

Si alguna vez ha trabajado en el aprendizaje automático, sabe que el lenguaje elegido es Python. Los numerosos paquetes y herramientas para las cargas de trabajo de aprendizaje automático en Python eclipsan el soporte equivalente de Ruby: cosas como los cuadernos de notas TensorFlow y Jupyter son fundamentales para nuestro equipo, y esos tipos de herramientas simplemente no existen o no están bien establecidos en el mundo de Ruby. En consecuencia, nos hemos apoyado en Python cuando se trata de desarrollar elementos de nuestro producto que aprovechan el aprendizaje automático.

Cuando el idioma importa

Obviamente, tenemos algunos excelentes ejemplos anteriores en los que Ruby no era la opción ideal. Hay muchas razones por las que podría elegir un idioma diferente; aquí hay algunas que creemos que son particularmente útiles para considerar.

Construir cosas nuevas sin cambiar los costos

Si va a construir un sistema completamente nuevo, con un nuevo modelo de dominio y sin una integración estrechamente acoplada con la funcionalidad existente, es posible que tenga la oportunidad de usar un lenguaje diferente si así lo desea. Especialmente en los casos en los que su organización está evaluando diferentes oportunidades, un proyecto nuevo más pequeño y aislado podría ser un gran experimento del mundo real para probar un nuevo lenguaje o marco.

Ecosistema de lenguaje específico para tareas y ergonomía

Algunas tareas son mucho más fáciles con un lenguaje o marco específico: nos gustan especialmente Rails y Grape para el desarrollo de la funcionalidad del tablero, pero el código de aprendizaje automático sería una pesadilla absoluta para escribir en Ruby, ya que las herramientas de código abierto simplemente no existen. Es posible que desee utilizar un marco de trabajo o una biblioteca específicos para implementar algún tipo de funcionalidad o integración y, a veces, su elección de idioma se verá afectada por eso, ya que es casi seguro que dará como resultado una experiencia de desarrollo más fácil o más rápida.

Velocidad de ejecución

Ocasionalmente, debe optimizar la velocidad de ejecución sin procesar, y el lenguaje utilizado influirá en gran medida en eso. Hay una buena razón por la que muchas plataformas comerciales de alta frecuencia y sistemas de conducción autónomos están escritos en C++; ¡El código compilado de forma nativa puede ser increíblemente rápido! Nuestros servicios de remitente explotan las primitivas de paralelismo/concurrencia de Golang que simplemente no están disponibles en Ruby por esa misma razón.

Familiaridad del desarrollador

Por otro lado, puede estar construyendo algo aislado, o tener una biblioteca en mente que desea usar, pero su elección de idioma es completamente desconocida para el resto de su equipo. La introducción de un nuevo proyecto en Scala con una gran inclinación hacia la programación funcional podría presentar una barrera de familiaridad para los otros desarrolladores de su equipo, lo que en última instancia daría como resultado el aislamiento del conocimiento o una disminución de la velocidad de la red. Consideramos que esto es particularmente importante en Braze, ya que ponemos un gran énfasis en la iteración rápida, por lo que tendemos a fomentar el uso de herramientas, bibliotecas, marcos y lenguajes que ya se usan ampliamente en la organización.

Pensamientos finales

Si pudiera retroceder en el tiempo y decirme algo sobre la ingeniería de software en sistemas gigantes, sería esto: para la mayoría de las cargas de trabajo, sus opciones generales de arquitectura definirán sus límites de escala y velocidad más de lo que lo hará una elección de idioma. Esa idea se demuestra todos los días aquí en Braze.

Ruby y Rails son herramientas increíbles que, cuando forman parte de un sistema diseñado correctamente, escalan increíblemente bien. Rails también es un marco muy maduro y respalda nuestra cultura en Braze de iterar y producir valor real para el cliente rápidamente. Esto hace que Ruby y Rails sean herramientas ideales para nosotros, herramientas que planeamos seguir usando en los años venideros.

¿Interesado en trabajar en Braze? Estamos contratando para una variedad de funciones en nuestros equipos de ingeniería, gestión de productos y experiencia del usuario. Consulte nuestra página de carreras para obtener más información sobre nuestros puestos vacantes y nuestra cultura.