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í:

  • El modelo de base de datos consta de una entidad de cuenta
  • Esta entidad tiene un atributo de saldo
  • Agregar dinero significa actualizar el atributo de saldo para aumentarlo
  • Quitar dinero significa actualizar el atributo de saldo para disminuirlo

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:

  • El modelo de base de datos tiene una entidad de cuenta.
  • También cuenta con una entidad de depósito y una entidad de retiro, ambas pertenecientes a una cuenta. Cada una de estas entidades tiene dos atributos: monto y fecha.
  • Agregar dinero significa crear un nuevo depósito.
  • Retirar dinero significa crear un nuevo retiro.
  • El saldo de la cuenta en una fecha determinada es la suma de todos los depósitos hasta esa fecha, menos la suma de todos los retiros hasta esa fecha.

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.

Cache
La caché siempre verde. 🌲 
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:

  • Nuestro enfoque basado en eventos requirió que cargáramos una gran cantidad de datos desde el almacenamiento en la memoria caché de los pares, donde podíamos ejecutar las consultas necesarias. 
  • Cualquier instancia de nuestro servicio podrá ser llamada para atender cualquier solicitud. Significa que si un cliente realiza cuatro solicitudes de saldo mientras navega por su aplicación (lo cual es típico), podría forzar a cuatro instancias diferentes a cargar los mismos datos.

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:

  • Un almacén inmutable que asigna referencias a algún valor (o a nada)
  • Una forma de derivar el valor actual del estado de la base de datos y enviarlo a la tienda.
  • Una forma de obtener una referencia válida al último valor

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:

  • Consultamos a Datomic y encontramos que la base-t actual es 13. Usaremos esto como parte de nuestra clave de caché hasta que cambie la base-t actual.
  • Recibimos una solicitud del saldo de la cuenta número 2042.
  • Nuestra clave de caché tendrá el formato número-de-cuenta/base-t, por lo que en este caso será 2042/13.
  • Lo buscamos en la tienda y no encontramos nada — falta de caché.
  • Consultamos la base de datos y calculamos el resultado.
  • Guardamos el resultado en la tienda en la clave “2042/13” y lo devolvemos.
  • A continuación, recibimos un depósito para una cuenta diferente y no relacionada. Lo guardamos en la base de datos. Esta transacción aumenta la base-t a 14.
  • Si ahora recibimos una nueva solicitud para obtener el saldo de la cuenta 2042, usaremos una clave de caché diferente: “2042/14”. ¡La caché falla otra vez!

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í:

  • Recibimos una solicitud del saldo de la cuenta 2042.
  • Consultamos a Datomic por el valor actual del contador de eventos para la cuenta 2042.
  • Datomic nos dice que el contador de eventos es actualmente 17. Nuestra clave de caché será “2042/17”
  • Buscamos 2042/17 en el almacén inmutable y no encontramos nada. Error de caché.
  • Consultamos a Datomic para obtener los datos que necesitamos y calculamos el saldo.
  • Guardamos el saldo en la tienda inmutable, en la clave “2042/17” y regresamos.
  • Horas más tarde, recibimos una nueva solicitud del saldo de la cuenta 2042.
  • El contador de eventos actual sigue siendo 17, por lo que la clave de caché sigue siendo 2042/17.
  • ¡Acierto en caché!

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