Feeds:
Entradas
Comentarios

Archive for octubre 2010


Hola a todos, aquí vuelvo con el tema del noSQL, que lo tenía un poco abandonado, y así descansais un poco de tanto REST.  Si recordais, en mi primer post sobre noSQL, defendía la idea de sistemas de persistencia que no necesitaran un esquema rígido ni tampoco un lenguaje de query universal. La idea era aprovechar la OO, y en vez de un esquema de base de datos, tener un modelo OO con toda la lógica de negocio, eliminando así la necesidad de un framework de mapeo objeto relacional y su complejidad y posibles problemas asociados. Dentro de la lógica de negocio incluyo también las consultas, lo que nos evita tener que aprender un lenguaje de query extra (SQL) y las complejidades de la implementación de éste en diferentes motores de base de datos (planes de acceso). De esta forma el sistema es más sencillo de mantener y de optimizar, y podemos decidir nosotros mismos que equilibrio queremos entre factores como la disponibilidad, la escalabilidad y la consistencia.

A raíz de varias presentaciones que hice sobre todo esto, descubrí que a los oyentes les costaba bastante aceptar el tener que programar las consultas en la lógica de negocio. El inconveniente que me planteaban era sobre todo tener que programar las consultas. En este post, y en el siguiente de la serie, pretendo explicar cómo implementar esto de las queries OO usando sistemas noSQL con paradigma clave/valor.

Antes de seguir he de aclarar que muchos sistemas noSQL sí te proporcionan una manera de hacer consultas. Por ejemplo CouchDB te permite hacer algo similar a vistas de datos tradicionales usando javascript. Por otro lado MongoDB proporciona un lenguaje de query universal, similar en espíritu al SQL, y definir declarativamente índices. Esto es perfectamente correcto en escenarios donde no tengas problemas de disponibilidad y escalabilidad, ya sea por que la carga no es muy alta, o porque estos factores no sean los más importantes. También es útil en sistemas de «datawarehouse» o «data mining», donde no se pueden prever de antemano las consultas que realizará el usuario. Sin embargo, si necesitas jugar al límite en el tema de disponibilidad y escalabilidad, necesitas controlar de forma muy precisa como se comporta tu sistema. Sólo conozco una forma de hacer esto, definiendo tu mismo la forma exacta en las que haces las consultas y la indexación, de esta forma podrás controlar hasta la última gota del consumo de tus recursos.

Para realizar este objetivo necesitamos sistemas noSQL que no introduzcan ningún tipo de overhead por tratar de implementar un lenguaje de query y un sistema de indexación genérico. Todos los que conozco con este enfoque «minimalista» se basan en el paradigma clave/valor o similar. Algunos ejemplos son Voldemort, Cassandra o Tokyo Cabinet. En el paradigma clave/valor no tienes ningún tipo de esquema sino que sólo almacenas una colección de valores indexados por una clave simple. Son en realidad tablas hash distribuidas y persistentes. La clave es normalmente un string o un array de bytes. El valor es simplemente otro string o array de bytes resultantes de la serialización de algún objeto de aplicación. Desde el punto de vista de la base de datos, los valores son opacos y no inspeccionables. Por lo tanto no es posible hacer consultas sobre los datos, a no ser que sean basadas en las claves, que de paso es lo único que está indexado. Desde este punto de vista MongoDB y CouchDB no son sistemas clave/valor, sino bases de datos documentales, ya que pueden inspeccionar la estructura de los documentos que contienen y proporcionar así un sistema de query genérico.

Como ya os podéis imaginar los sistemas clave/valor están bastante limitados a la hora de hacer consultas. Todos pueden devolver el valor asociado a una clave de forma eficiente. Alguno pueden consultar rangos de claves, aunque el orden de éstas se define a la hora de crear el sistema y después no puede cambiarse. Pero todo esto yo lo veo (en muchos escenarios) como algo positivo, por las siguientes razones:

  • Estas operaciones, al ser bastante sencillas, son eficientes de implementar y nos proporcionan un tiempo de respuesta predecible. Estas propiedades son cruciales a la hora de montar una buena base sobre la que implementar nuestra lógica.
  • Al fin y al cabo, estamos hablando de lógica de negocio. Queremos que las consultas sean parte de nuestro modelo OO, así que, ¿para qué quiero que el motor de persistencia soporte consultas genéricas si no lo voy a usar?

Armados con nuestro sistema de persistencia clave/valor, de última generación, elástico, ultradisponible, a prueba de bombas y muy «cool», veamos como implementar una búsqueda de estas que nos piden los clientes. Lo primero que debemos tener es una función que actúe como criterio de búsqueda, y que dado un objeto nos devuelva si este pertenece al resultado de la consulta o no. Para los expertos de SQL, esto es lo mismo que la clausula where, pero implementada en nuestro lenguaje de programación favorito y formando parte de nuestra lógica de negocio. Va a ser sencilla de optimizar ya que nosotros controlamos el algoritmo que va a ejecutar. Normalmente sólo necesitamos una condición booleana, que se optimiza simplemente poniendo la evaluación de los términos más restrictivos al principio, y abortando (cortocircuitando) la evaluación del resto de la función en cuanto vemos que la condición no puede ser cumplida. ¿Cómo sabemos cuales son los términos más restrictivos? Pues porque conocemos el dominio de nuestra aplicación, cosa que un algoritmo de tuning de BBDD genérico va a tener muy difícil. Sabiendo las particularidades de nuestro dominio, podemos optimizar fácilmente dicha función de criterios de búsqueda.

Teniendo una función de criterios de búsqueda eficiente caeríamos en la tentación de simplemente hacer un bucle que vaya recorriendo todos los valores de una colección persistente y comprobar mediante dicha función si entra en la búsqueda o no. Este enfoque es muy simple, pero, ¿es correcto? Depende. Si sabemos que dicha colección va a ser pequeña podemos hacerlo así. En el caso que el tiempo de respuesta no sea crítico también podemos tomar este enfoque. Si sabemos que éste no es nuestro caso debemos averiguar como optimizar la consulta. Como ya hemos optimizado la función de criterios de búsqueda, no podemos seguir tirando por ese camino. ¿Qué hacemos? Lo siento, no existe bala mágica, sólo podemos recorrer los miembros de una colección e ir viendo si pertenecen al resultado de la consulta. Esto como ya os imagináis tiene un coste proporcional al número de objetos de la colección y puede dar lugar a un tiempo de ejecución. ¿Tiramos nuestro sistema noSQL y nos vamos al viejo y conocido mySQL? Tranquilos, podemos usar un sencillo truco, sólo tenemos que calcular el resultado de la consulta antes de que un usuario nos la solicite, y almacenar dicho resultado en una cache, ya sea persistente o no. Cuando un usuario nos solicite dicha consulta, ya tendremos el resultado calculado y sólo es cuestión de devolverlo. A los que esto le parezca raro he de decirles que este «truco» ha sido usado por los sistemas SQL durante bastante tiempo. Me refiero a las vistas «materializadas», las tablas temporales y los índices. Sin embargo al estar envuelto en un aura de «transparencia» y «automatización», al final ha resultado ser un tema bastante ignorado por los programadores, que lo han llegado a ver como algo «mágico» y «gratis».

Vamos a meternos al lío, como siempre, tenemos algunos detalles complejos que solucionar para implementar esta idea:

  • Cada vez que se actualice la información en la colección de objetos de la que se alimenta la consulta, hay que recalcular esta.
  • Cada vez que se añade una nueva consulta o se modifican los criterios de una ya existente, hay que calcular desde cero dicha consulta con los datos existentes.
  • ¿Dónde almacenamos los resultados de la consulta?

En función de como afrontemos estos detalles nuestro sistema se comportará de una manera u otra. Empecemos por la más sencilla, la última, ¿dónde almacenar el resultado? Si almacenamos el resultado en memoria tenemos la ventaja de una alta velocidad a la hora de recuperar los resultados. Por otro lado tenemos dos desventajas graves: volatilidad y sobrecarga de memoria RAM. Si alguien tira nuestra aplicación los resultados se desechan y hay que calcularlos de nuevo. Si el resultado es muy grande podemos quedarnos sin memoria RAM en el proceso. Por lo tanto, parece evidente, que en la mayoría de los escenarios, es mejor almacenar de forma persistente estos resultados. El lugar obvio es el propio sistema de persistencia clave/valor que ya estemos usando. Sin embargo quiero que tengáis en cuenta que en algunos escenarios, como consultas con resultados pequeños o sistemas tiempo real, lo mejor es almacenarlos en RAM. También la evolución del hardware tiene mucho que ver, cada vez las máquinas tienen más RAM, con lo que no es descabellado tener un cluster con muchos nodos, usando sólo RAM, que se replican entre si. En este escenario la durabilidad (persistencia) no necesitaría de discos, sino que se alcanzaría a base de replicación entre un número muy elevado de nodos. Como siempre, tenemos la bendición (o maldición) de poder decidir cual es la mejor estrategia para nuestro caso concreto.

El segundo y el primer punto giran en torno a la misma cuestión, ¿cómo y cuándo calcular y recalcular el resultado de una consulta, ya sea la primera vez o cada vez que haya un cambio? En este sentido no tenemos muchas opciones (menos mal):

  • Calcular y/o recalcular las consultas la primera vez que se intenta acceder a ésta y la consulta está marcada como nueva o desactualizada.
  • Hacerlo de forma periódica, por la noche o cuando haya poca actividad. Este es el enfoque «batch» que se hace más de lo que algunos pensáis.
  • Bajo pedido, cuando alguien pida la actualización de forma explícita mediante un comando.
  • Cada vez que se escriba en alguna de las colecciones sobre las que actúa la consulta.
  • En segundo plano y de forma incremental.

Pensad que el hecho de que las consultas se tengan que recalcular cada vez que se actualizan los datos de las colecciones sobre las que operan, hace que pueda producirse un grado de inconsistencia entre los datos reales del sistema, y los ofrecidos por las consultas. De nuevo estamos ante el teorema CAP. Veámoslo a continuación.

En los enfoques más simples, los que implican recalcular las queries en modo batch, la consistencia alcanzada es débil. Si sólo vamos a actualizar las consultas en horas de bajo uso del sistema o bajo pedido explícito, y hay escrituras entre estos dos periodos, lo resultados de las consultas reflejarán datos desactualizados, con lo que el grado de consistencia es baja. Estaríamos ante un caso de consistencia eventual, pero bastante débil, al tener que esperar relativamente bastante tiempo para que los datos devueltos por las consultas sean «frescos». Por lo tanto esto sólo es útil en escenarios donde la frecuencia de escrituras no es muy elevada y la criticidad de la consistencia entre los datos reales y los resultados de las consultas no es muy alto. Es curioso recordar que esto es similar a un típico proceso de consolidación nocturna de datos en modo batch que se realizan en sistemas tradicionales, y nadie que yo conozca se ha quejado de ellos hasta el momento.

En sistemas on-line, donde necesitamos que las consultas se actualicen de forma rápida, debemos usar una estrategia incremental, en vez de batch. Por lo tanto tenemos tres opciones: actualizar al escribir, al leer o asíncronamente, pero en todo caso, de forma incremental. De esta forma podemos alcanzar un grado de consistencia eventual o incluso fuerte. Decimos que las consultas se actualizan incrementalmente, porque actualizamos las consultas cada vez que se produce un cambio o sólo unos pocos. Esto hace que las actualizaciones sean rápidas y podamos hacerlas de forma frecuente, lo que nos permite un mayor grado de consistencia. Veamos:

  • Actualizar al escribir. Si cada vez que creamos, borramos, o cambiamos los datos de un objeto, actualizamos todas las consultas que se alimentan de la colección a la que pertenece dicho objeto, conseguimos un grado de consistencia fuerte. Efectivamente, si cada vez que escribimos algo, actualizamos las consultas, éstas siempre estarán al día. El problema de esta estrategia es que hace que las escrituras sean lentas. Para que la transacción de escritura termine, no sólo hemos de esperar a escribir el dato actualizado, sino a chequear si ese nuevo dato entra en alguna de las consultas que tenemos, y si es así añadirlo a dichas consultas, lo que conlleva más escrituras. Cuantas más consultas a actualizar, más lenta la escritura. Todo esto hace que penalizemos las escrituras para optimizar las consultas. Lógicamente esto no es problema si almacenamos las consultas en RAM. En caso de que las persistamos, entonces depende mucho del sistema noSQL que usemos. Por ejemplo en Cassandra, las escrituras son unas diez veces más rápidas que las lecturas, con lo que no es mala idea hacer varias escrituras para optimizar las lecturas. Finalmente, en escenarios con muchas lecturas y pocas escrituras, esta estrategia es óptima. Sistemas como MongoDB y bases de datos tradicionales SQL usan esta estrategia para actualizar sus índices. Otra plataforma que usa esta estrategia es RavenDB.
  • Actualizar al leer. Consiste básicamente en marcar, durante la escritura, que el dato es nuevo o ha sido modificado. Posteriormente, en el momento de hacer una consulta, comprobar si existen datos modificados o nuevos, para actualizar con estos la consulta. La ventaja con respecto al sistema anterior es que las escrituras se penalizan menos, ya que sólo tenemos que marcar los cambios, no actualizar las consultas. Ciertamente esta marca implica una escritura, pero nos ahorramos el procesado de la consulta y la cantidad de datos a escribir es menor. Se trata en el fondo de un enfoque perezoso, donde la consulta no se actualiza hasta el momento en que se utiliza, con lo que ahorramos procesamiento innecesario. La desventaja es que, aunque agilizamos las escrituras y aprovechamos mejor los recursos, penalizamos las consultas al tener que esperar a actualizarlas. Es un problema importante si accedemos a una consulta por primera vez, ya que tendría que recalcularse desde cero. De nuevo puede ser un buen sistema si la infraestructura noSQL está optimizada para escrituras, ya que necesitamos escribir, además del dato, el hecho de que este haya cambiado. Pero cuando es especialmente útil es si hay más escrituras que lecturas. Esta estrategia, al igual que la anterior consigue una consistencia fuerte. Los que conozcáis CouchDB, sabed que éste actualiza sus «vistas» siguiendo este enfoque.
  • Actualización asíncrona en segundo plano. Es una estrategia intermedia, como antes, al escribir se marcan las consultas que necesitan ser actualizadas. En segundo plano, un demonio va actualizando estas de forma continua. Esto nos permite agilizar por un lado las escrituras, y por otro lado no tener que calcular nada durante las consultas. Es útil en sistemas con un número similar de escrituras que de lecturas. Si no sabemos mucho de nuestro sistema en este sentido, podemos usar este enfoque. El problema es que a cambio de un mejor rendimiento, degradamos la consistencia. En efecto, puede ser que en el momento de leer, las consultas no hayan sido actualizadas del todo, y existan pequeñas inconsistencias. Afortunadamente estas inconsistencias se solucionaran en poco tiempo ya que el demonio terminará por consolidar las consultas de forma incremental. Podemos calificar este sistema pues como de consistencia eventual.

Volvemos a tener el poder en nuestra mano de elegir la estrategia que más nos convenga, en función de los ratios escritura/lectura, la criticidad de la consistencia en lectura, y si nuestro sistema noSQL está optimizado para leer o para escribir. También podemos combinar enfoques, por ejemplo es común combinar la actualización asíncrona con alguna de las otras dos. Resumiendo:

    Strong Consistency Eventual Consistency Low Consistency
    Optimizar escrituras Actualizar al leer Actualización asíncrona Batch
    Optimizar lecturas Actualizar al escribir Actualización asíncrona Batch

Como detalle notad que incluso la estrategia «actualizar al leer» y «actualización asíncrona», penalizan algo la escritura, al tener que escribir en algún sitio que se modificó un dato y hay que actualizar los índices afectados. Aunque no afecta tanto al rendimiento de las escrituras como «actualizar al escribir», seguimos teniendo un pequeño penalti. Esto hace que los sistemas optimizados para la escritura, como Cassandra, sean más eficientes a la hora de implementar todas estas estrategias. Paradójicamente, para leer eficientemente, ¡ tenemos que poder escribir rápido !

Por último quiero aclarar que aunque estoy hablando de sistemas clave/valor, todo lo que cuento es aplicable a otro tipo de sistemas, como bases de datos tradicionales SQL o base de datos orientadas a documentos (MongoDB, CouchDB, RavenDB…). Lo que ocurre es que realizan todas estas operaciones de forma automática y el usuario/programador no tiene que preocuparse de todo esto, mientras su sistema tenga el rendimiento deseado claro. Sin embargo conocer estos conceptos puede ser útil cuando uséis estos sistemas y queráis razonar un poco sobre el nivel de rendimiento que van a ofreceros. De nuevo insisto, en un sistema clave/valor todo esto lo tenéis que planificar e implementar vosotros, pero al menos podéis optimizar todo a vuestras necesidades. En un sistema como MongoDB por ejemplo, vuestro margen de acción es menor, precio a pagar a cambio de una mayor «sencillez» de uso.

Todo esto nos lleva a la fascinante conclusión de que las consultas no son operaciones, sino que se van actualizando conforme las colecciones de las que se nutren cambia. Por si fuera poco, lo normal es que sean además objetos persistentes. Veamos… objetos persistentes, con operación «update»… vaya si al final las consultas son también entidades o singletons, dependiendo del caso. Je, je, la OO y el patrón comando atacan de nuevo.

En mis siguientes posts veremos como organizar todas estas ideas y estrategias desde el punto de vista de sistemas clave/valor.

Read Full Post »