NoSQL : Ejemplos prácticos en Cassandra. Películas

NoSQL - Cassandra

Ejemplo práctico

Películas

Versión 1.1

Mayo 2017

Por Fernando Dodino

Distribuido bajo licencia Creative Commons Share-a-like

Indice

1 Tecnologías en las que vamos a trabajar

1.1 Conceptos equivalentes

2 Primer ejemplo: Base de Películas

2.1 Casos de uso posibles y modelo conceptual

3 Implementación del modelo en Cassandra

3.1 Column Family Películas

3.2 Column Family Actores

3.3 Algunas consultas

3.3.1 Conocer los estrenos de las películas

3.3.2 Ver datos de una película

3.3.3 Perfil de un actor

4 Aplicación para consultar estrenos

4.1 Implementación del Repo

4.2 Resumen

5 Para el lector interesado


1 Tecnologías en las que vamos a trabajar

  • primero que nada vamos a pensar nuestro modelo según las reglas de particionamiento y almacenamiento que propone Cassandra
  • luego lo vamos a implementar en CQL, un lenguaje de alto nivel para hacer consultas contra el motor
  • y finalmente vamos a construir una aplicación Xtend/Java + el driver Astyanax para integrar el modelo de objetos con el de Cassandra

1.1 Conceptos equivalentes

En el modelo relacional...

se conoce en un modelo NoSQL columnar como...

base de datos/instancia

keyspace

tabla

column family

id

uuid (universally unique id): es válido para todas las particiones

primary key

partition key - primary key: es la clave que se utiliza para particionar una entidad en los distintos clusters

2 Primer ejemplo: Base de Películas

Queremos modelar los estrenos de películas de un portal de contenidos por streaming. Las entidades que surgen como candidatas son

  • películas[1]
  • actores

Lo primero que tenemos que hacer es olvidarnos de la representación “tradicional” del modelo relacional:

  • podemos tener atributos multivaluados
  • en nuestras consultas no podemos hacer joins, cada entidad requiere una consulta específica al motor
  • corolario del punto anterior: es una buena estrategia diseñar la información desnormalizada, en base a los requerimientos de los usuarios.

2.1 Casos de uso posibles y modelo conceptual

Imaginemos algunos casos de uso:

  1. Conocer los estrenos de las películas que salieron para una fecha determinada
  2. Ver datos de una película
  3. Acceder al perfil de un actor

En base a esto ya podemos empezar a diseñar un modelo conceptual:

  • queremos particionar las películas
  • tienen un identificador unívoco
  • el dato fecha de estreno es muy importante, porque determina el criterio con el que accedemos a la partición donde están
  • datos que puede tener una película: título y sinopsis, un resumen que explica brevemente el argumento y los actores que participaron
  • datos que podrían participar en un desarrollo comercial pero que para simplificar el ejercicio no incorporamos: género/s, distribuidora, directores, escritores, ranking, presupuesto, etc.

Sharding peliculas

Sharding peliculas

  • los actores
  • tienen un identificador unívoco
  • y un nombre
  • además queremos saber en qué películas participó

Tendremos una relación bidireccional

3 Implementación del modelo en Cassandra

Cada entidad se modela como un mapa <RowKey, RowValue>

  • El valor que almacena no es opaco, sino que admite tanto
  • columnas estáticas o de tamaño fijo, que tienen un tipo (int, text, uuid, etc.)
  • y columnas dinámicas, el equivalente a tener un atributo multivaluado, como veremos en breve.

Recordamos qué características tienen los mapas[2]:

  • son muy eficientes para encontrar elementos si conocemos la clave
  • no son tan buenos para hacer consultas por otros criterios, eso requiere hacer un “scan” o recorrido completo de la estructura para encontrar los elementos que satisfacen ese criterio

Si tenemos una gran cantidad de películas y queremos hacer la búsqueda por título, tenemos que repensar nuestro modelo para facilitar ese requerimiento.

3.1 Column Family Películas

Para crear las entidades vamos a utilizar CQL (Cassandra Query Language), un lenguaje que permite subir el nivel de abstracción de las operaciones básicas sobre el motor. Por ejemplo, al definir la entidad películas sabemos que nuestra clave de particionamiento será la fecha de estreno. Como tenemos varias películas que comparten esa fecha, la forma de identificar unívocamente cada película será utilizar un id universal o uuid. Luego vienen los datos estáticos de la película: el título y la sinopsis. Por último, tenemos que diseñar la relación one to many entre película y actores. Hay dos formas de lograr esto:

  • mediante estructuras list, set o map
  • o utilizando una entidad aparte, donde la clave primaria está compuesta por el row key del padre + un identificador para cada una de las columnas hijas (las que van a formar la relación “muchos”)

¿Qué información queremos mostrar de cada actor que participa en una película? Esta es la pregunta crucial que determina qué opción elegimos.

Algunas opciones posibles

  • cada película tiene un list de actores desnormalizado
  • como un list está ordenado, esta alternativa permite modelar actores principales (los que están “primero”) y secundarios (los que están después), y se acerca a lo que necesita el “negocio”
  • pero tener un list de una estructura compuesta, partiendo de un tipo de dato definido por el usuario (“Actor”) está pensado para versiones posteriores, no para el estado actual del motor
  • otra opción sería tener una lista normalizada de identificadores de actores
  • esto requiere hacer una consulta extra por cada actor que participe en la película (si necesitamos mostrar datos del actor, como el nombre)
  • o bien tener una lista con los nombres de los actores, algo que puede ser útil si sólo necesitamos visualizar los nombres
  • una variante es tener un mapa de actores: la clave es el identificador del actor y el valor es el nombre del actor
  • la desventaja es que un mapa no está ordenado, tenemos que hablar con el usuario para ver si hay algún problema con esto: por ejemplo podrían cargarse solo los actores principales en lugar de todos. O bien puede relajarse el requerimiento de que aparezcan actores por orden de importancia, y simplemente mostramos la lista de todos los que participaron en una película, no importa si es un bolo, un cameo, un papel secundario o un protagónico.
  • por otra parte, al desnormalizar los datos del actor e incorporarlos dentro de la estructura de una película, ganamos notablemente en performance ya que cada columna se almacena en forma contigua (los desplazamientos en la unidad de almacenamiento son mínimos, como ocurre con los índices clustered de las bases de datos relacionales)
  • la última alternativa podría ser almacenar en una column family aparte la relación película-actor
  • esto implica poder incorporar más información del actor asociado a cada película
  • pero necesita una consulta adicional (el acceso a la column family película + a la column family películas-actores)

Elegimos tener un mapa de actores, con el aval de nuestro usuario. El script en CQL que armamos es:

CREATE TABLE IF NOT EXISTS "peliculas" (
        
fecha_estreno timestamp,
        
id uuid,
        
titulo text,
        
sinopsis text,
        
actores map<uuid, text>,
        
primary key (fecha_estreno, id)        
        
);

¿Cómo se traduce en el modelo?

Películas

Fíjense que tenemos un conjunto de mapas:

  • el primer acceso es por column family (la tabla Películas)
  • el segundo acceso es por la partition key. En este caso la clave Fecha de estreno tiene como valor una serie de columnas.
  • por último, las columnas se dividen en un mapa: las claves son
  • el nombre de la columna para el caso de uuid, título y sinopsis
  • el nombre de la columna actores + el identificador del actor para este caso
  • para todos los casos en el valor se almacena el dato propiamente dicho

3.2 Column Family Actores

Queremos que los actores conozcan las películas en las que trabajaron. Pero tener una lista de identificadores de las películas no es conveniente:

  • el motor de base de datos relacional asume que se trabaja con un repositorio centralizado, entonces el identificador sirve como clave primaria
  • pero si las películas tienen una clave de particionamiento por fecha de estreno, hacer una búsqueda por identificador -por más unívoco que sea- nos obliga a hacer una recorrida secuencial en cada uno de los clusters: esto presupone un problema de performance si tenemos millones de películas.

Entonces la estrategia que vamos a adoptar es tener dos column families por separado:

  • en “actores” ubicamos los datos estáticos, como una tabla en el modelo relacional
  • en “actores-películas” definimos la relación one-to-many con una column family dinámica,
  • la clave se compone mediante los identificadores de actor y película
  • y tenemos columnas adicionales: fecha del estreno (importante para poder acceder a la column family película) y título.

CREATE TABLE IF NOT EXISTS "actores" (
        
id uuid primary key,
        
nombre text
);

CREATE TABLE IF NOT EXISTS "actores_peliculas" (
        
actor_id uuid,
        
pelicula_id uuid,
        
fecha_estreno timestamp,
        
titulo text,
        
primary key (actor_id, pelicula_id)
);

Vemos el modelo traducido:

Actores

Actores_Películas

3.3 Algunas consultas

Resolvemos los requerimientos en CQL[3]

3.3.1 Conocer los estrenos de las películas

SELECT fecha_estreno, id, sinopsis, titulo, actores
 
FROM peliculas
WHERE fecha_estreno = '2014-02-01';

                                   

fecha_estreno

id

titulo

actores

2014-02-01 00:00:00-0300

08cbbce4-4341-4f26-888f-5a842af819f8

Closed circuit

{272505d8-0e33-4837-90a9-cd79ad79396f: 'Rebecca Hall', 77f92efc-f48d-4f01-80a2-25c12ec9915c: 'Eric Bana'}

2014-02-01 00:00:00-0300

0a1ec3aa-9333-458f-b859-4cf149c71bc8

Enders Game

{4239a7c1-4427-4321-b588-de50590994b2: 'Asa Butterfield', c51be3f9-4963-4675-91f2-f314d0287e17: 'Harrison Ford', d596f571-51ff-4282-8594-180a2bfdf2b4: 'Ben Kingsley', eee25de1-2939-4ca7-9598-eb30b3077b97: 'Hailee Steinfeld'}

...

La sintaxis de CQL se parece mucho a la del SQL, sin embargo, veremos que una consulta inocente como buscar una película por título nos vuelve a poner en contexto sobre la forma de almacenar que utiliza el motor Cassandra:

SELECT fecha_estreno, id, titulo, actores
 
FROM peliculas
WHERE titulo = 'Gravity';

No podemos hacer una consulta por cualquier columna si esa columna

  • no forma parte de la clave principal
  • no forma parte de un índice secundario

sin que explícitamente le digamos al motor que estamos dispuestos a pagar el costo de la performance de dicho query. Esto lo hacemos agregando al final de la consulta el parámetro ALLOW FILTERING:

SELECT fecha_estreno, id, titulo, actores
 
FROM peliculas
WHERE titulo = 'Gravity'
ALLOW FILTERING;

Tampoco podemos hacer una consulta que involucre más de una tabla (no están permitidos los JOINs[4]).

3.4.1 Ver datos de una película

Supongamos que conocemos el id de una película, al ejecutar esta consulta en CQL

SELECT fecha_estreno, id, titulo, actores
 
FROM peliculas
WHERE id = 7011f15d-37a4-4df6-8cba-9c7095a08e49;

Nuevamente vemos un cartel de advertencia del motor:

Unable to execute CQL script on 'local': Cannot execute this query as it might involve data filtering and thus may have unpredictable performance. If you want to execute this query despite the performance unpredictability, use ALLOW FILTERING.

Entonces incorporamos la cláusula ALLOW FILTERING en la consulta:

SELECT fecha_estreno, id, titulo, actores
 
FROM peliculas
WHERE id = 7011f15d-37a4-4df6-8cba-9c7095a08e49;

 ALLOW FILTERING;

El problema de esta consulta es que requiere hacer una búsqueda en cada uno de los clusters, por eso es muy importante que el negocio asuma que yo conozco la fecha de estreno de una película a la que quiero acceder. De otra forma tendré que cambiar mi modelo de datos o asumir una notable degradación de la performance[5].

3.4.2 Perfil de un actor

Si queremos conocer el perfil de un actor, tenemos que acceder por la clave del mapa que es su identificador:

SELECT id, nombre
 
FROM actores
WHERE id = 805a8a59-2e34-438c-8949-8a515a673809;

id

nombre

805a8a59-2e34-438c-8949-8a515a673809

John Cusack

Y para conocer las películas de ese actor, necesitamos otro acceso a la column family por id de actor:

/* Nuevo query, vemos las películas de John Cusack */
SELECT * 
 
FROM actores_peliculas
WHERE actor_id = 805a8a59-2e34-438c-8949-8a515a673809;

id

pelicula_id

fecha_estreno

titulo

805a8a59-2e34-438c-...

7011f15d-37a4-4df6-...

2014-02-01

The frozen ground

805a8a59-2e34-438c-...

f4d4449e-d0df-4fb3-...

2014-02-01

The Butler

...

Fíjense que en esta column family desnormalizamos los datos más importantes de un film:

  • la fecha de estreno, que junto con el identificador de la película forman su clave primaria
  • y el título como información que evita en algunos casos de uso el acceso a la CF películas

Recordemos la clave primaria de la CF actores_peliculas:

CREATE TABLE IF NOT EXISTS "actores_peliculas" (
        
...
        
PRIMARY KEY (actor_id, pelicula_id)

Y la definición de la CF peliculas:

CREATE TABLE IF NOT EXISTS "peliculas" (
        
...

        actores map<uuid, text>,
        
PRIMARY KEY (fecha_estreno, id)        
);

Esta redundancia nos permite atacar satisfactoriamente dos requerimientos:

  • saber qué actores participaron de una película (CF peliculas, a partir del mapa de actores)
  • saber en qué películas participó un actor (CF actores_peliculas)

3.4 Sobre la definición de índices

Los índices en esta tecnología tienen que tener una alta cardinalidad, que tenga una buena cantidad de valores. Debemos tener en cuenta estas restricciones a la hora de modelar las entidades.

4 Aplicación para consultar estrenos

Pensemos una arquitectura sencilla para una aplicación que permita consultar los estrenos de películas por fecha[6]:

  • la UI la construimos en Arena, que nos ayuda a definir vista y controllers. La vista
  • permite ingresar una fecha
  • tiene un botón que dispara la búsqueda
  • una grilla con los datos de la película
  • y un list que cuando seleccionamos una película nos muestra los actores que participan en ella
  • diseñamos un application model que facilita el binding con los elementos visuales, tendremos
  • un Date donde almacenamos la fecha de búsqueda
  • una lista de películas
  • cuál es la película seleccionada
  • y conocemos al repositorio o home
  • tendremos los objetos de dominio Película y Actor
  • la película tiene una lista de actores
  • y cada actor una lista de películas
  • y utilizaremos una implementación especial de home que llamaremos RepoPeliculasImpl
  • por el momento tendremos un único método: getPeliculas(Date) que nos devuelve una lista de películas con sus actores. Cortaremos el grafo de selección de objetos en el actor, esto significa que la colección de películas de cada actor quedará vacía, puede llenarse a partir de otro método del repositorio (getActor(id)).

4.1 Implementación del Repo

El Repo utiliza el driver[7] Astyanax[8]:

  • se implementa como un Singleton
  • en el método init() se define la conexión con el motor y se almacena esa información en la variable keyspace.
  • explicamos el método getPeliculas(Date)

Tenemos que armar la consulta con el Date que recibimos, esto se puede hacer

  • construyendo el query a partir de una column family
  • utilizando una sintaxis objetosa que trae Astyanax

         

        val statement = QueryBuilder

                .select()

                .all()

                .from("imdb", "peliculas")

                .where(QueryBuilder.eq("fecha_estreno", fechaEstrenoPosta))

                .limit(10)

                .enableTracing()

        val rs = session.execute(statement)

Luego, tenemos que transformar (mapear) la información que recibimos al modelo de objetos. Por eso

  • cada fila representará una “película” (el value del mapa)
  • el título y la sinopsis hay que convertirlos a string
  • el id a un uuid
  • pero además por cada fila de película hay un mapa de actores que debemos recorrer. Debemos definir los tipos de ese mapa: la clave es el UUID, el valor es un string.

                 

                          

val peliculas = rs.all.map [ fila |

        new Pelicula => [

                id = fila.getUUID("id")

                titulo = fila.getString("titulo")

                sinopsis = fila.getString("sinopsis")

                // Transformo el mapa de actores en una List<Actor>

                val mapaActores = fila.getMap("actores", UUID, String)

                actores = mapaActores.entrySet.map [ entryActor |

                         new Actor(entryActor.key, entryActor.value)

                 ].toList

        ]

][9]

En negrita y resaltado mostramos las asignaciones a cada atributo de película.


4.2 Resumen de la arquitectura

Arquitectura ejemplo peliculas

5 Para el lector interesado

 de


[1] Incluiremos en esta categoría a las series, pero por fines didácticos sólo lo aclaramos aquí

[2] Mapa de Java o su equivalente Dictionary de Smalltalk, hablamos de un conjunto de pares clave/valor

[3] Utilizamos la herramienta Datastax DevCenter 1.6.0, que viene con la suite de instalación de Cassandra

[4] Para profundizar más puede verse la documentacion sobre Data Manipulation Language de Cassandra

[5] Por eso es importante entender la tecnología, para no echarle la culpa a ella de nuestras malas decisiones

[6] El link al ejemplo se encuentra en https://github.com/uqbar-project/eg-peliculas-cassandra 

[7] Un driver, también llamado controlador, es el medio de comunicación entre el motor de la base de datos y nuestra aplicación hecha en Xtend (Java)

[8] https://github.com/Netflix/astyanax, construido por el equipo de desarrollo Netflix y basado en otro driver existente llamado Héctor

[9] En CQL la forma de acceder a la column family varía muy ligeramente, para más información ver el branch astyanax2 de https://github.com/uqbar-project/eg-peliculas-cassandra