Potenciando las lecturas con Command Query Responsibility Segregation (CQRS)
Si recuerdan los posteos anteriores, hablamos del modelo de actores y de cómo persistimos la información de forma incremental siguiendo el patrón de event sourcing.
Veamos un ejemplo concreto para recordar algunos conceptos. Imaginemos el modelo de un carrito de compras al cual podemos agregar y remover productos.
Cada carrito de compras estaría modelado con un actor, quien responde a los comandos AgregarProducto
y RemoverProducto
. Cuando se modifica el estado interno del actor, se persisten los eventos ProductoAgregado
y ProductoRemovido
.
Ahora bien, imaginemos que necesitamos obtener los carritos que incluyan cierto producto, o que contengan al menos 2 productos. Tendríamos que enviar un comando a todos los actores y agregar las respuestas en una lista de potencialmente cientos o miles de elementos que tendríamos manipular a mano si quisieramos paginarla u ordenarla según algún criterio.
No es imposible, pero definitivamente es impráctico. Especialmente porque muchos de estos actores seguramente ya no estén activos en memoria y necesiten reconstruir su estado.
Hacer la consulta directamente en MongoDB (o Cassandra o donde sea que estemos persistiendo los eventos) tampoco sería una solución. Como lo que estamos persistiendo son los eventos y no el estado actual, tendríamos que básicamente reimplementar la reconstrucción del actor en la consulta.
Cómo podemos resolver este problema? El secreto está en entender que podemos usar un modelo distinto para leer la información que el que usamos para actualizarla.
Es decir, podemos separar nuestra aplicación en un write side, responsable por responder a comandos que modifiquen el estado y un read side, responsable por responder a consultas complejas que no modifican el estado. Este patrón se conoce como Command Query Responsibility Segregation (CQRS).
En el write side vamos a querer resolver problemas como evitar condiciones de carrera y poder reconstruir el historial de cambios, mientras que en el read side vamos a querer resolver problemas como obtener la información de múltiples entidades de forma rápida y poder ordenar y paginar esta información.
Estas necesidades son radicalmente distintas y por ende necesitan soluciones tecnológicas distintas. Así como el modelo de actores nos ayuda a resolver las necesidades del write side, tenemos que pensar qué solución resuelve las del read side sin hacer compromisos por intentar aplicar la misma solución a todos los problemas.
En Tiendanube estamos usando Elasticsearch porque es extremadamente rápido y escalable. También nos permite realizar búsquedas de texto de manera muy simple, para casos como la búsqueda de productos por su nombre, descripción, etc.
Guardamos los documentos de forma desnormalizada para adaptarnos a los tipos de consultas que vamos a necesitar. Por ejemplo, volviendo al caso del carrito de compras, podemos guardar la lista de ids de los productos agregados, así como un campo con la cantidad de productos que contiene.
Otra ventaja de separar los soportes físicos de la información para lecturas y escrituras es que podemos escalarlos de forma independiente. Es muy común que la cantidad de lecturas sea mucho mayor a la cantidad de escrituras, especialmente en el rubro del ecommerce.
Obviamente esto despierta una nueva complejidad: cómo hacemos para mantener la información del read side sincronizada con el event sourcing del write side?
Usando el mecanismo de persistence queries que nos ofrece Akka, podemos recuperar los eventos que fueron persistidos por el write side en orden. Teniendo esto, es simplemente cuestión de plasmar estos cambios en Elasticsearch.
Para cada evento definimos una proyección que nos dice qué llamadas a la API de Elasticsearch debemos realizar. Por ejemplo, si procesamos el evento Producto Creado
debemos hacer un POST con todo el documento, pero si recibimos un Producto Renombrado
, sólo necesitamos hacer un PUT que actualice el valor del nombre del producto.
Observen como la fuente de verdad son siempre los eventos del write side. Si quisiéramos, podríamos reconstruir la información del read side desde cero en cualquier momento. Es decir que podemos pensarlo como una vista materializada que optimizamos para el tipo de consultas que vamos a querer realizar.
Esto implica que si en algún momento queremos cambiar la estructura de los datos, podemos perfectamente construir un nuevo read side y tirar el viejo a la basura.
Por otro lado, esto también implica que vamos a tener que aprender a lidiar con consistencia eventual, pues siempre va a haber una pequeña latencia hasta que se actualice el read side.
Sumado a la complejidad adicional necesaria para implementar y mantener el mecanismo de proyección de los eventos de dominio al read side, es importante destacar que si bien CQRS es un patrón muy poderoso, tiene un costo de implementación del cual no debemos olvidarnos.
Es perfectamente posible que el tipo de lecturas que un sistema debe realizar sea perfectamente compatible con la forma en la que almacenamos los datos, especialmente si no hay que hacer búsquedas complejas. En estos casos recomendaría no agregar complejidad adicional con CQRS y mantener el mismo modelo para lecturas y escrituras.