9 de Junio, 2010

Redis, NoSQL y esas hierbas (II)

En esta entrega voy a explicar el modelo de datos que he empleado en la nueva bitácora, una vez expuestas mis conclusiones tras usar Redis como almacenamiento NoSQL.

Cuando me puse a ver la lista de comandos de Redis pensé: sí, no,... o igual sí :). Estamos bien educados en el modelo relacional, rápidamente vemos tablas, así que pensar cómo implementar la aplicación sin tablas ni relaciones me resultó un poco complicado al principio.

Lo que me pareció un primer buen paso fue acotar el problema: ¿qué es una bitácora? Veamos de nuevo la definición que propuse en la anterior entrega:

Una bitácora es una página web que consiste en una serie de artículos ordenados de forma cronológica inversa, de manera que el más reciente se muestra primero, seguido de los más antiguos.

Es decir, necesitamos almacenar una serie de documentos que contienen varios campos (titular, cuerpo, fecha, etc), y tenemos que poder acceder a ellos en orden cronológico inverso.

Adicionalmente, en mi caso, quiero poder recuperar a las anotaciones que están clasificadas en una determinada categoría. No he contemplado más posibilidades (como anotaciones por año o mes, por ejemplo), porque tampoco las utilizaba en el gestor de contenidos antiguo.

En mi concepto de bitácora también tenemos comentarios, que están ordenados según publicación, y están asociados a una anotación.

Por último quiero mostrar en la columna lateral los últimos comentarios, y por simplificar no voy a entrar en el tema de ma moderación de comentarios ;).

De está descripción del modelo de datos, más o menos ordenada :P, veo que necesito tres tipos distintos de entidades en el almacenamiento:

  • Documento: con una estructura compleja, de tipo post o comment.
  • Índice: lo usaremos para asociar documentos bajo un criterio concreto (anotaciones en la bitácora, comentarios de una anotación, anotaciones en una categoría, etc), y estarán ordenados (la mayor parte del tiempo :D).
  • Contador: como un documento puede estar en varios índices, tendré que guardar una referencia, que debe ser única por tipo de documento. Estos contadores serán los que nos den el ID del siguiente elemento.

Con este pequeño análisis ya tuve suficiente para detectar qué tipos de datos en Redis me permitían implementar el almacenamiento adecuadamente:

  Tipo en Redis Operaciones (in/out/del)
Documento String set / get / delete
Índice List rpush / lrange / lrem
Contador String incr / - / -

Para los siguientes ejemplos vamos a suponer que trabajamos con Python y redis-py:

import redis
import simplejson as json

rd = redis.Redis(host='localhost', port=6379, db=0)

Contadores

Voy a empezar por el final, porque es mejor tener clara la función de los contadores antes de ponernos a manejar documentos.

Un contador simplemente almacena un número, que vamos incrementando de forma atómica (una operación que no puede ser interrumpida), de manera que siempre obtengamos un identificador diferente, y que además tenga la propiedad de ser ordenable :).

En mi caso he definido dos contadores: post:next y comment:next.

Inicialmente valen cero, y cuando necesitamos un ID nuevo es tan sencillo como:

id = rd.incr('post:next')

El comando INCR incrementa en uno el valor de la clave que indicamos y nos devuelve su nuevo valor. Es un proceso que no puede ser interrumpido (o funciona, o no funciona), y nos garantiza un identificador único y ordenable.

Documentos

El dato elegido en Redis es simple: una cadena de texto, así que para poder manejar la estructura compleja de uno de nuestros documentos, vamos a utilizar JSON.

Siguiendo el ejemplo de la nueva anotación:

post = dict(
            title = u'Anotación de ejemplo',
            body = u'Este sería el cuerpo de la anotación. etc!',
            tags = [ u'ejemplos', u'pruebas' ],
            date = '2010-06-09 21:00',
        )
        
# en ID teníamos ya nuestro nuevo identificador  
rd.set('post:' + str(id) + ':store', json.JSONEncoder().encode(post))

He decidido seguir el siguiente esquema para los nombres de los documentos: post:ID:store y commment:ID:store, para post y comment respectivamente.

En realidad a Redis le da igual, solo espera que la clave a la que asociar la cadena de texto (nuestro JSON, recordemos), sea una cadena de texto sin espacios ni saltos de linea, pero es buena idea ceñirse a una convención interna.

Recuperar un documento es sencillo, una vez sabemos su ID:

# recuperamos el post con ID = 1
post = rd.get('post:1:store')
post = json.JSONDecoder().decode(post)

Para eliminar un documento, usaremos el comando DEL indicando la clave a borrar (ojo, delete en redis-py).

Índices

Para implementar los índices en Redis he elegido las listas, que son nada más que una serie de elementos enlazados en los que se respeta el orden (A → B → C).

Solo tenemos que decidir cuántos índices necesitamos, y esto es: uno por cada criterio para acceder a los documentos (y siempre siguiendo el orden de la lista, ascendente o descendente).

Por ejemplo, yo he implementado:

  • post::index: lista de todas las anotaciones.
  • comment::index: lista de todos los comentarios, independientemente de la anotación a la que estén asociados.
  • comment:POSTID:index: los comentarios asociados a una anotación concreta.
  • tag:TAG:index: las anotaciones asociadas a una categoría concreta.

Siguiendo con el ejemplo de la anotación:

# indexamos el post donde toca
rd.rpush('post::index', id)

# y en cada índice de cada tag
for t in post['tags']:
    # la clave no puede tener espacios, pero los tags sí
    t = t.replace(' ', '.')
    rd.rpush('tag:' + t + ':index', id)

De forma similar indexaríamos los comentarios asociados a la anotación, usando el índice adecuado (en el ejemplo: comment:1:index para el post con ID igual a 1).

Para recoger un elemento o varios de una lista, empleamos lrange, que maneja rangos de listas. Por ejemplo, para recoger las 10 anotaciones que mostramos en portada:

# cogemos los 10 últimos elementos, esto es: del -10 al -1
index = rd.lrange('post::index', -10, -1)

for p in index:
    post = rd.get('post:' + str(p) + ':store')
    post = json.JSONDecoder().decode(post)
    # hacemos algo con los posts :)
    print post['date'] + ': ' + post['title']

Para eliminar un elemento de un índice podemos usar LREM (que en redis-py intercambia el orden de los parámetros, cuidado) y, si es necesario, con SORT podemos reordenar una lista mediante diferentes criterios.

En mi caso el uso de SORT no es importante, porque siempre añado elementos al final de un índice, así que mis listas están (casi) siempre ordenadas (una excepción son los índices de las categorías, si añadimos una a una anotación vieja, en ese caso llamo a SORT al final del proceso).

Conclusiones

Esta es la solución que he implementado, y me está dando buenos resultados, pero desde luego no es la única posible.

Podría haber usado más intensivamente Redis en lugar de almacenar los documentos con JSON (por ejemplo, haber usado post:ID:title, post:ID:body, post:ID:date, etc), y no sé si hubiera sido mejor o peor. Mi propuesta hace menos llamadas a Redis a cambio de llamadas a simplejson.

Aunque la flexibilidad del modelo de datos es eso: poder diseñar como queramos el almacenamiento de nuestra aplicación, eligiendo lo que funciona mejor para solucionar nuestro problema.

La verdad es que he disfrutado mucho con esta parte del desarrollo, porque es completamente distinto a lo que estaba acostumbrado (SQL, ORM, y el modelo relacional), aunque en muchas etapas del proceso he pensado: uhm, esto con un ORM sería más rápido de programar :D.

Siguiente entrega

Solo me queda hablar del mantenimiendo: revisar la integridad del almacenamiento, hacer copias de seguridad, y una de las cosas más interesantes de una solución NoSQL... ¡replicación!

Anotación por Juan J. Martínez, clasificada en: redis, nosql, blog, python.

Hay 5 comentarios

Gravatar

Muy interesante, la aplicación, a diferencia de usar los post:ID:title , que me gusta más , casi la haría exacta.

Respecto a insertar y hacer los SORT, creo que los Sorted Sets ( http://urlcorta.es/29an ) son el camino a seguir, pero claro, opinión personal, y posiblemente sólo aplicable a estructuras más complejas :D

En definitiva, te has divertido, has aprendido y el blog, vuela. ¿ Se puede pedir más ? :D

por MarcosBL, en 2010-06-10 10:28:11

Gravatar

Desde luego, y tengo que decir que parte de mi confusión venía de tus comentarios a lo largo del proceso :D

por Juanjo, en 2010-06-10 10:53:42

Gravatar

así da gusto aprender sobre este tipo de almacenamiento, enhorabuena por los artículos y no pares de escribir!!

por Jorge, en 2010-06-10 11:01:59

Gravatar

Hombre… intentaba ser útil, pero weno xD

por MarcosBL, en 2010-06-10 11:12:28

Gravatar

Marcos: Está claro :) Gracias!

por Juanjo, en 2010-06-10 11:17:12

Los comentarios están cerrados: los comentarios se cierran automáticamente una vez pasados 30 días. Si quieres comentar algo acerca de la anotación, puedes hacerlo por e-mail.

Algunas anotaciones relacionadas: