Mas leido
Building Stories
Modo Rua: Redefiniendo el desarrollo de aplicaciones mediante iteración centrada en el usuario Ago 23
Building Stories
NuStories: Adaptación de productos para clientes fanáticos en varios países Oct 30
Culture & Values
Cómo los valores y la cultura de Nu dan forma a los productos que creamos Ago 7
Carreras
Reunimos a grandes mentes de diversos orígenes que permiten la discusión y el debate y mejoran la resolución de problemas.
Conoce más sobre nuestras carreras



La cuenta digital de Nubank se crea utilizando la misma fantástica pila que impulsa nuestros otros productos: Clojure, Datomic y Kafka, todos implementados en la nube de AWS. En la cuenta de creación de equipos de Nubank, también somos grandes admiradores de la contratación de eventos.
El abastecimiento de eventos es una expresión elegante para una idea que es simple en esencia: en lugar de almacenar el estado actual del sistema en una base de datos, almacene cada paso de la historia hasta el momento. Luego, si es necesario, siempre podemos volver a mirar esa tienda para responder cualquier pregunta que tengamos o incluso reducir la lista completa para obtener una vista ″con estado″ actualizada.
Resuelve una gran cantidad de problemas que surgen con los sistemas distribuidos. Utilizamos muchos microservicios en Nubank y mantenerlos coherentes entre sí es un problema complejo. Descubrimos que el abastecimiento de eventos nos permite desglosar las cosas de una manera que mantiene esta complejidad en un nivel cómodo.
Esta es una historia sobre cuándo nuestro enfoque basado en eventos comenzó a convertirse en OOM y cómo lo resolvimos sin comprometer la coherencia.
Contando dinero
Una característica principal de una cuenta bancaria es mantener un puntaje actualizado de cuánto dinero tiene allí. Por supuesto, también queremos permitirle ingresar y retirar más dinero.
Meter y sacar dinero de Nubank implica algunas integraciones complicadas que son interesantes en sí mismas, pero me gustaría hablar sobre la parte de ″mantener un puntaje actualizado″. En este caso, la puntuación es el saldo de la cuenta.
Una implementación ingenua de un servicio de saldo se vería así:
Suena simple, ¿verdad?
Sin embargo, ¿cómo construiría un estado de cuenta bancario sobre esto? ¿Cómo depuraría si algo sale mal? ¿Cómo se aseguraría de nunca agregar accidentalmente una cantidad dos veces ni eliminarla dos veces?
No entraré en detalles aquí, pero un enfoque de abastecimiento de eventos resuelve todos estos problemas (y también algunos otros). Entonces, aquí hay una forma de crear un servicio de saldo basado en eventos:
Es una definición demasiado simplificada pero bastante cercana. Así suelo explicar nuestro sistema en las conversaciones. Cuando abrimos preguntas, hay una predecible:
“¿Repasa toda la lista cada vez que alguien le pide su saldo? ¿No es demasiado lento?
Mi respuesta solía ser: “Hasta ahora todo va bien, ¡gracias por tu preocupación!” hasta hace unos meses.
Consulte nuestras oportunidades laborales
Camino a OOM
Resulta que la velocidad no era un problema, pero los requisitos de memoria terminaron siéndolo. Para entender por qué, necesitamos hablar de Datomic.
Datomic no es una base de datos SQL tradicional, pero probablemente tampoco coincida con lo que piensa cuando lee la palabra de moda ″NoSQL″. Ofrece transacciones ACID totalmente consistentes y tiene un montón de superpoderes listos para usar. Obtenemos pistas de auditoría sencillas y nunca perdemos ningún dato. Es realmente impresionante, incluso después de trabajar con él durante algunos años.
Por muy interesantes que sean, las complejidades del modelo de datos de Datomic no son muy importantes para este problema. Lo relevante es una decisión arquitectónica específica: Datomic separa los trabajos de escribir, almacenar y consultar datos. En un servidor SQL tradicional, una sola máquina realiza los tres trabajos. Con Datomic, podemos (y normalmente lo hacemos) tener tres máquinas o clústeres diferentes, uno para cada trabajo.
El almacenamiento de datos parece una parte esencial de una base de datos. En cierto sentido lo es — pero Datomic no resuelve este problema directamente. En cambio, nos permite descargar esto a un servicio independiente, como DynamoDB.
El trabajo de escribir datos se deja a un proceso específico, llamado transactor. Todas las escrituras pasan por este embudo, son serializadas, procesadas, validadas. Si todo va bien, se acepta nuestra transacción y el resultado se conserva en nuestra capa de almacenamiento.
Debido a que todos los datos están en la capa de almacenamiento, se puede acceder a ellos desde cualquier lugar. Significa que podemos tener tantos procesos de consulta como queramos, todos buscando, filtrando y devolviendo de forma independiente los datos de interés. Estos procesos de solo lectura se denominan pares.
En Nubank, nuestros pares son nuestras propias instancias de microservicios. El mismo contenedor, la misma JVM que ejecuta el código de nuestra aplicación, también ejecuta consultas, carga cosas desde DynamoDB en un caché enorme en la memoria y carga datos de forma preventiva incluso antes de que se lo solicitemos. Significa que las consultas pueden ser ridículamente rápidas porque la mayoría de los datos que necesitará están en la memoria, sin necesidad de viajes de red.
También puede consumir mucha memoria. Hemos visto una instancia en la que nuestro servicio de saldos asigna varios gigabytes en unos segundos.
Entonces, lo que pasó es que empezamos a ver accidentes. Las máquinas se quedan sin memoria y se detienen durante minutos durante pausas de recolección de basura. Al principio, nuestra respuesta fue aumentar la memoria disponible para estas instancias. Al principio esto funcionó.
Cuando llegamos a las instancias de 100 GB que pendían de un hilo, supimos que no podíamos seguir así por más tiempo.
Reunimos un grupo de trabajo. Los perfiladores fueron las armas elegidas y se realizaron muchas pruebas de carga. Se redujeron megabytes, se optimizaron las consultas. Hubo paz por un tiempo. Sin embargo, no duró.
El verdadero problema se basaba en dos hechos básicos sobre nuestro sistema:
Ninguna instancia podía ser tan grande como para contener toda la base de datos en la memoria caché, pero aun así todas las instancias debían contener datos de todos los clientes activos en cada momento. Todas las instancias dedicaban mucho tiempo y memoria a rehacer el trabajo que otra máquina ya había realizado. Todo el tiempo. Para todo el mundo.
Si pudiéramos lograr que estas máquinas compartieran parte del trabajo, o mejor aún, evitaran por completo rehacer trabajos innecesarios, entonces tendríamos alguna esperanza. Significaba agregar una capa de caché. Pero, ¿qué dicen sobre ellos, de nuevo?
Una de las cosas difíciles
Debo admitir que tengo un poco de miedo a los cachés. Quiero decir, sé que existen, sé que es posible hacerlos funcionar. También sé que es posible escribir un programa multiproceso seguro en ensamblador — pero prefiero no hacerlo si puedo evitarlo.
He visto implementaciones de caché en las que varias personas revisaban el código, lo analizaban, concluían que era correcto y aún así encontrábamos estados no válidos en producción.
Una cita/broma famosa sobre el almacenamiento en caché se atribuye a Phil Karlton:
″Sólo hay dos cosas difíciles en Ciencias de la Computación: invalidar la caché y nombrar cosas″.
Resulta que esta cita es absolutamente correcta: la parte difícil no es el almacenamiento en caché; la parte difícil es la invalidación de la caché. ¿Qué pasaría si pudiéramos crear un caché que nunca tuviera que ser invalidado?
Nosotros, los programadores de Clojure, nos jactamos de nuestras estructuras de datos inmutables listas para usar. Nunca perdemos nada hasta que ya no lo necesitamos, y en ese momento, podemos simplemente… soltar la referencia, olvidarla, confiar en el CG y nunca mirar atrás.
¿Podríamos crear un caché que funcione más o menos así? ¿Podríamos solucionar lo difícil, en lugar de resolverlo?
Almacén inmutable
Piense en esto: un caché donde cualquier referencia que tenga apunta a nada (un error de caché) o a algo inmutable, por lo tanto válido para algún momento. Cuando el mundo cambia, cambiamos la referencia, no ese valor. La nueva referencia también debería apuntar a nada o apuntar a un valor nuevo, inmutable y válido.
Es difícil rastrear de dónde provienen las ideas, pero mirando hacia atrás, creo que dos fuentes fueron las más influyentes.
Conceptos, Técnicas y Modelos de Programación Informática es un libro alucinante de Peter Van Roy y Seif Haridi. Una de las primeras construcciones que aprende en ese libro es la variable lógica. Las variables lógicas solo se pueden asignar una vez — están liberadas o vinculadas a un valor específico por el resto del tiempo (hasta que finaliza el programa). Las variables lógicas me recuerdan a losfuturos, promesas y funciones memorizadas.
Ese libro también muestra el uso de variables lógicas para construir hermosos sistemas concurrentes con un comportamiento totalmente determinista. Resulta que si sus variables son inmutables una vez vinculadas y el proceso que las vincula es determinista, ¡la concurrencia es bastante segura!
La otra gran inspiración fue la propia Datomic. Datomic almacena datos (en cualquier capa de almacenamiento que queramos usar) en forma de un gran árbol de nodos inmutables. Cada nodo inmutable se identifica mediante un UUID. Siempre que deba realizar un cambio en ese árbol, simplemente puede crear nuevas versiones de los nodos que deben modificarse, hasta la raíz del árbol — y, finalmente, realizar un intercambio atómico único en la parte del almacenamiento que contiene la referencia a la raíz actual del árbol.
Ahora imagine que un Datomic peer debe ejecutar una consulta. Comienza en la raíz del árbol, realiza cierta lógica de indexación y decide que los datos que necesita se encontrarán en el nodo con UUID EAE4D22F-CE79–47C4-AE5A-759A46B4D166. Ahora supongamos que encuentra ese nodo ya cargado en su caché en memoria. ¿Cómo puede saber si el nodo sigue siendo válido?
¡Por supuesto que es válido! ¡Es un valor inmutable! Mientras comencemos con la referencia correcta, no podemos evitar llegar a valores válidos. ¡Datomic, bajo el capó, tiene precisamente el tipo de caché que estábamos buscando!
Otra documentación sugerente de Datomic es esta:
“Datomic siempre verá un puntero de registro correcto, que se colocó mediante transferencia condicional. Si algunos de los nodos del árbol aún no son visibles debajo de ese puntero, Datomic es consistente pero parcialmente no está disponible, y estará completamente disponible cuando finalmente suceda”.
Parece mucho a tener una variable lógica independiente, que estará vinculada de forma asíncrona por algún otro hilo. Siempre y cuando ese otro hilo haga su trabajo de manera correcta y determinista, ¡estaremos a salvo!
Entonces, esto es lo que necesitamos:
Cualquier almacén clave-valor funcionará bien como almacén inmutable. Decidimos utilizar DynamoDB, algo a lo que estamos acostumbrados en Nubank.
Ya sabemos cómo calcular los resultados que queremos de la base de datos — lo hacemos cada vez que alguien realiza una solicitud HTTP. Entonces, una estrategia simple que podemos usar es un caché de lectura: buscar datos en la caché; si falta, calculelo desde cero, luego escríbalo en la caché y regrese.
Todo lo que nos falta ahora es esa referencia mágica, que deberíamos poder usar como clave para nuestra tienda inmutable. ¿Cómo conseguimos eso?
Números de versión
Datomic ha sido un buen modelo hasta ahora, veamos si podemos robarle algunas ideas más. ¿Cuál es la referencia válida para el almacenamiento de Datomic?
La respuesta está en esa ubicación de almacenamiento particular que solo se actualiza utilizando una opción condicional fuertemente consistente: la clave del nodo raíz del árbol. Este UUID apunta a la raíz de la última versión de los datos. Si sigue esa referencia, todo lo que encontrará está actualizado. Cualquier otra cosa que pueda encontrar en la capa de almacenamiento es basura vieja.
Esta idea de identificadores de versión aparece en muchos lugares como una forma de identificar cosas de forma única: repositorios git, administradores de paquetes, cadenas de bloques. Pueden ser hashes criptográficos o pueden ser nombres asignados por alguna autoridad central. No importa, siempre y cuando nombren valores inmutables.
Datomic tiene una especie de número de versión que puede ser utilizado por el código de la aplicación. Se llama base-t, una referencia a un punto en el tiempo en la base de datos. Se puede compartir una base-t entre pares para asegurarse de que estén viendo la misma versión de los datos. La base-t se puede utilizar para mirar hacia el pasado (es decir, consultar la base de datos tal como estaba en ese momento) o esperar el futuro (es decir, bloquear un hilo hasta que el par local haya recibido todos los datos hasta ese momento).
¿Qué pasa si usamos este número base-t como referencia?
Bueno, cada transacción hace funcionar el reloj de Datomic, cada transacción aumenta este número de versión. Entonces sucedería lo siguiente:
Entonces, si usamos la base-t como parte de nuestra clave de caché, perderemos nuestras cachés con demasiada frecuencia, sin una buena razón. Todas estas consultas que realizamos analizan los datos de una cuenta a la vez. Perder la caché de la cuenta A debido a un depósito en la cuenta B es simplemente una tontería.
¿Podemos hacerlo mejor?
Contador a nivel de cuenta
¡Sí!
Todo lo que tenemos que hacer es agregar un atributo a nuestra entidad de cuenta en Datomic. Este atributo comienza en cero. Cada vez que hacemos algo relacionado con esa cuenta, aumentamos el atributo en 1 en la misma transacción. Nos da un número de versión de los datos de la cuenta, que podemos usar como clave de caché.
Debido a que este atributo termina contando cada evento que ocurre en una cuenta, le dimos un nombre muy original: contador de eventos.
La estructura general de nuestra caché de lectura se ve así:
Si obtenemos un nuevo depósito o retiro para la cuenta 2042, muy bien, aumentaremos el contador de eventos y esto provocará un error de caché en la siguiente solicitud. ¡Es inevitable, ya que el equilibrio actual, de hecho, ha cambiado! Lo importante es que esto no sucederá por razones tontas, como sucedió cuando intentamos usar la base-t.
La caché y la base de datos.
Generalmente pensamos en un caché como algo que va delante del servicio real, protegiéndolo de las solicitudes — eso es lo que hacen las CDN, por ejemplo. Este tipo de caché también nos permite seguir ofreciendo contenido incluso si el servicio real no funciona por completo.
Sin embargo, esto sólo funciona bien para contenido estático. Si necesitamos dinamismo, interacción y acciones que vayan más allá de cualquier estado que podamos tener en el lado del cliente, necesitamos ejecutar algún código de aplicación del lado del servidor de todos modos.
Si ese es el tipo de aplicación que estamos ejecutando, y si la coherencia es un requisito importante, tiene sentido obtener cierta cooperación del servidor y su base de datos, incluso cuando intentamos servir desde la caché.
Observe la diferencia: en una CDN, primero consultamos la caché y luego volvemos al servidor real si falla. En el esquema que describimos anteriormente, lo primero que hacemos es consultar la base de datos para obtener el contador de eventos. Solo entonces podremos buscar un caché y luego recurrir a la base de datos y recuperar el resto de los datos.
Podemos almacenar en caché casi todos los datos, pero la base de datos aún proporciona esa pequeña base sólida que mantiene todo consistente.
Tiene sentido para nuestro caso de uso porque la “disponibilidad a toda costa” no beneficia a nuestros clientes. ¿De qué sirve ver un saldo vencido en su cuenta bancaria, si no pueden hacer nada con él porque los servidores no funcionan?
En lugar de reemplazar el servicio, nuestro objetivo principal con la caché era permitir que se ejecutara usando menos recursos. Lo logramos: cada instancia de nuestro servicio requirió 90GB de RAM, pero ahora solo necesitan 55GB. Y aún mejor: ahora podemos escalar el servicio horizontalmente si la carga aumenta porque el trabajo se distribuye efectivamente entre instancias. La latencia también disminuyó para la mayoría de las solicitudes, lo cual fue bueno, aunque no era el objetivo principal.
Con este enfoque, logramos reducir drásticamente la cantidad de trabajo repetido que realizábamos en nuestros servidores. También nos permitió manejar una carga mayor con máquinas más pequeñas, todo sin comprometer la consistencia.
¡Ahora sólo tenemos que encontrar una manera de evitar nombrar cosas!
(Más artículos de Gustavo Bicalho)
(Foto de Samuel Scrimshaw en Unsplash)
Descubre las oportunidades