Feeds:
Entradas
Comentarios

Archive for the ‘noSQL’ Category


¡ Bienvenidos de nuevo al mundo de CQRS ! Hoy nos vamos a centrar en ver como podemos implementar la persistencia del subsistema de comandos. La opción más tradicional es persistir una instantánea del modelo de datos directamente en una base de datos relacional mediante un ORM o similar. Los amantes de noSQL querrán grabar dicha instantánea usando sus sistema noSQL favorito. Pero ambas opciones se basan en grabar una instantánea del estado del modelo de datos. Sin embargo existe un sistema más simple y que aprovecha el ya existente mecanismo de eventos. Me estoy refiriendo a Event Sourcing.

Concepto y motivación

Ya hemos decidido que el subsistema de comandos va a emitir eventos de negocio, lo cuales serán procesados por el subsistema de consultas para actualizar su esquema de datos interno. Esto tiene algunas consecuencias cruciales:

  1. El subsistema de comandos no va a ser nunca leído por los clientes del sistema, ya que ellos usan siempre el subsistema de consulta.
  2. El subsistema de consultas es el único cliente que realiza operaciones de lectura sobre el subsistema de comandos.
  3. Lo único que va a pedir el subsistema de consultas al de comandos es la lista de eventos de negocio ocurridos desde la última vez que se produjo una sincronización.
  4. Si ocurre un desastre y perdemos todos los datos del subsistema de consultas, basta con levantarlo sin datos, y hacer que le pida al subsistema de comandos todos los eventos ocurridos desde el principio. Por lo tanto el estado del sistema puede residir por entero en el subsistema de comandos. Más sobre esto en futuros posts.

Visto esto, ¿para qué vamos a molestarnos en persistir un esquema de datos basado en entidades y relaciones? Al fin y al cabo ni los clientes ni el subsistema de consulta están interesados en estas entidades. Además lo único que necesitamos para recuperar el sistema en caso de desastre es la lista de eventos de negocio. Por lo tanto lo único que debería ser persistente es la lista de dichos eventos y no un hipotético modelo entidad/relación o un modelo de objetos.

Y este es precisamente el famoso Event Sourcing: almacenar todo el estado del sistema únicamente como una secuencia ordenada de eventos. Por lo tanto si nos decidimos por un mecanismo de sincronización basado en eventos de negocio, el enfoque de Event Sourcing a la persistencia es muy natural. No necesitamos almacenar entidades, ni las relaciones entre ellas, sino serializar una lista de eventos. Cada evento puede poseer campos, pero nunca vamos a necesitar buscar eventos por dichos campos, sino sólo por orden temporal, o como mucho por tipo de evento. El subsistema de consultas sólo va a realizar accesos como el que sigue: «dame todos los eventos sobre pedidos desde el momento X hasta la actualidad por orden cronológico», o «todos los eventos desde el principio».

Otra ventaja de event sourcing es que tenemos todo el historial de operaciones del sistema, lo que lo hace ideal para auditorias, depuración e informática forense.

No digo que debamos usar siempre event sourcing, pero siempre y cuando usemos eventos de negocio como mecanismo de sincronización, me cuesta trabajo pensar en escenarios donde event sourcing no tenga ventajas sobre grabar el modelo tal cual.

Transaccionalidad y consistencia

A nivel transaccional es bastante sencillo: cada comando es una transacción, cada transacción termina con la generación de un evento. A nivel de consistencia podemos tener varios niveles, en función de cuando el comando (la transacción) se da por terminado:

  • Fin de transacción cuando el evento se genera en memoria. Tanto la persistencia del evento como su transmisión al subsistema de consultas se produce de forma asíncrona, en segundo plano. Esto nos da el máximo de escalabilidad y rendimiento. Pero la consistencia es eventual y  podemos perder eventos (durabilidad baja).
  • Fin de transacción cuando el evento se persiste. La sincronización se produce de forma asíncrona, en segundo plano. De esta forma no perdemos eventos, a costa de tener una consistencia eventual entre las consultas y los comandos. Sin embargo la escalabilidad es bastante buena, a condición de que las escrituras sean rápidas. Es un buen compromiso como norma general.
  • Fin de transacción cuando el evento se persiste, y además el subsistema de consultas ha sido actualizado. Aquí tenemos consistencia estricta y durabilidad, pero penalizamos el rendimiento de las escrituras. Si por cualquier caso el subsistema de consulta está muy estresado, podemos perder disponibilidad en la escritura. Si realmente te encuentras en un escenario que requiere una consistencia tan estricta como esta, tal vez no deberías estar usando ni event sourcing ni CQRS.

Como vemos debemos usar un mecanismo de persistencia que escale muy bien a nivel de escritura (excepto en el primer caso) para que nuestro subsistema de comandos escale. No es tan importante en este caso la escalabilidad a nivel de lectura, ya que de ello se encarga el subsistema de consultas, que puede tener otro mecanismo de persistencia totalmente distinto. Además nos basta con un mecanismo que permita escribir en modo “append only” y soporte consultas cronológicas y por tipo. ¿Cómo implementamos pues la persistencia?

Alternativas de implementación

Podemos, como siempre, usar una BBDD relacional como soporte sobre el que almacenar la secuencia de eventos. Pero aunque factible, no es muy ventajoso. Al fin y al cabo nunca vamos a consultar por columnas, o por clave primaria. Tampoco vamos a tener que hacer joins. Ojo, no digo que no podamos usar SQL, lo que quiero hacer notar es que en este escenario el paradigma relacional no nos ofrece muchas ventajas.

Parece mejor usar un paradigma más sencillo. Por ejemplo, en un clave/valor podríamos usar como clave el número de orden del evento y como valor el propio evento serializado directamente. Una simple consulta por rango de clave nos valdría. Otra opción es usar algo como REDIS, que tiene soporte directo para listas y operaciones atómicas de incremento. Por otro lado, algunos sistemas noSQL, como Cassandra optimizan la escritura sobre la lectura, lo que viene bien en el caso de event sourcing.

Podemos simplificar aun más, y usar directamente el sistema de ficheros. Si lo pensáis bien en Event Sourcing no hay ni modificación ni borrado. Toda modificación en el sistema ocurre a través de un comando, que como resultado cambia el estado de este. Como consecuencia del cambio de estado siempre se produce un nuevo evento, que habremos de añadir a la secuencia de eventos ya existente. Lo mismo ocurre con las operaciones de borrado. A nivel de negocio raramente ocurre un borrado como tal. Podemos cancelar un pedido, o anularlo, o sea ejecutar otro comando que termina produciendo otro evento. Como se ve no se necesita ni modificar ni borrar ningún evento. Esto es importante porque tanto los discos magnéticos como los SSD se comportan bastante bien con sistemas que sólo añaden información por el final. En teoría no debería ser muy complicado hacer un sistema que abra un fichero en modo append only… qué curioso acabo de resucitar el concepto de log transaccional o log de operaciones, que se ha usado durante décadas en sistemas transaccionales robustos. Pero meterse a niveles tan bajos sólo compensa si realmente necesitas mucho rendimiento.

Ya existen sistema de persistencia especializados para event sourcing. Son los llamados Event Storages. Sin embargo todos los que conozco no acceden directamente al sistema de ficheros, sino que mapean el concepto de event sourcing sobre otros motores de persistencia como mySQL, Redis o Cassandra.

Snapshoting

Sin embargo la técnica de event sourcing tiene dos debilidades: ficheros que siempre crecen y arranques de sistema lentos.

Como vimos, no es común hacer borrados de los eventos ya que casi todas las operaciones terminan en un evento nuevo que hay que añadir al sistema de ficheros. Esto hace que el espacio de almacenamiento requerido crezca sin parar. Podemos definir eventos de negocio que ocupen muy poco espacio, y usar técnicas de compresión, pero eso sólo hace que el espacio ocupado crezca más lentamente.

Por otro lado, cada vez que el sistema se para y tiene que ser reiniciado, debe leer todo el log de eventos y reprocesarlos para obtener una instancia del modelo de negocio en memoria. Esto hace que el tiempo de arranque sea largo. Este problema afecta al subsistema de comandos principalmente. El subsistema de consultas suele almacenar directamente el estado del modelo de consultas, y sólo necesita procesar los eventos desde la última vez que se paró. Por lo tanto este problema no es importante en el caso del subsistema de consultas.

En los casos en que estos dos problemas son importantes, no se usa event sourcing puro, sino una técnica híbrida llamada snapshoting. La idea es usar event sourcing, pero en segundo plano, y cada cierto tiempo, ir procesando los eventos para generar una instantánea del estado del sistema que será persistida. Esta instantánea estará desfasada con respecto a la secuencia de comandos. La idea es almacenar tanto el log de eventos como la instantánea, por lo que se puede considerar event sourcing puro. Las ventajas son las siguientes:

  • Evitamos volver a procesar el log de eventos completo en caso de arranque. Cuando el sistema arranca lee la instantánea, y procesa sólo los eventos que ocurrieron posteriormente a la creación de dicha instantánea.
  • Se pueden borrar los eventos anteriores a la creación de la instantánea, a condición de que se pueda generar una secuencia de eventos equivalente a ella a partir de la instantánea.

Conclusión

Aprovechando que estamos usando eventos de negocio para sincronizar ambos subsistemas, y usar Event Sourcing como paradigma de persistencia en sistemas CQRS. Este enfoque se integra de forma natural con CQRS, es sencillo de mantener y evolucionar, y en general es una buena base para conseguir sistemas escalables. A nivel de rendimiento es importante que nuestro mecanismo de persistencia sea capaz de escalar bien en escritura.

En el próximo post nos adentraremos en el mundo del subsistema de consultas.

Read Full Post »


Tras dos o tres semanas sin escribir, aquí vuelvo de nuevo continuando con la serie sobre como hacer consultas sin SQL. Resumiendo el anterior post, vimos que los sistemas clave/valor, aunque muy escalables, eran algo limitados desde el punto de vista de los lenguajes de query, ya que sólo permiten consultar por clave, y no ofrecen un lenguaje de query genérico. Para solucionar esto, propuse definir las consultas como objetos de primera clase dentro de nuestro modelo de negocio OO y persistirlos directamente. Sin embargo esto implicaba que cada vez que modificáramos la información de nuestro sistema habría que recalcular, de forma incremental, los cambios en el resultado de la consulta y volverlos almacenar. En concreto exploramos las distintas estrategias que se pueden seguir, y como afectan éstas tanto al rendimiento como a la posible falta de consistencia de las consultas con respecto a los datos actuales. Sin embargo, no conté nada sobre cómo almacenar las consultas en un soporte persistente siguiendo el paradigma clave valor, y ser capaz de recuperar el resultado de forma eficiente después. Nos ocuparemos de ésto en el presente post.

Empecemos por el caso más sencillo. Éste se produce cuando tenemos consultas que no dependen de parámetros externos. En estos casos podemos tener consultas con una función de selección arbitrariamente compleja, pero ésta está definida completamente y no admite parámetros. En SQL esto sería equivalente a una sentencia SELECT con una WHERE sin parámetros. Ejemplo, todos los varones mayores de edad y “ricos”:

SELECT *
FROM PERSONAS
WHERE EDAD > 18 AND SEXO = 'VARON' AND SUELDO > 25000 ORDER BY EDAD

En este caso sólo tenemos que darle un nombre único a esta consulta. Como estamos modelando la consultar como un objeto propiamente dicho, podemos usar el nombre de dicha clase como clave, y almacenar bajo esa clave todos los resultados. Usando notación JSON, el resultado de almacenar la query anterior podría ser por ejemplo:

{
  'query:varonesMayoresEdadRicos':{
    'totalCount':2344,
    '1':{
      'key':'k1',
      'nombre':'Juan',
      'edad':18,
      'empresa':'Acme S.A.',
      'sueldo':30000,
      'sexo':'VARON',
      'tfno':4234564
    },'2':{
      'key':'k76',
      'nombre':'Pedro',
      'edad':19,
      'empresa':'Acme S.A.',
      'sueldo':45000,
      'sexo':'VARON',
      'tfno':42321564
    },'3':{
      'key':'k34987',
      'nombre':'Eduardo',
      'edad':19,
      'empresa':'Acme S.A.',
      'sueldo':30000,
      'sexo':'VARON',
      'tfno':7664666
    }
// ....Restantes resultados...
  },
  'query:empleadosEnEdadDePrejubilacion': {
    // ....Restantes resultados...
  },
  'query:empleadosConPocoSueldo': {
    // ....Restantes resultados...
  }
// Otras consultas....
}

Nótese que hemos usado el nombre de la consulta como clave, y como valor hemos almacenado un objeto con varios campos. El primero, totalCount, es el número total de objetos que contiene la consulta. En este sentido tenemos libertad para almacenar otros campos con metainformación adicional, y con resultados estadísticos. Por ejemplo: clave del primer resultado, del último resultado, media de edad, sueldo máximo y mínimo, etc. Después tenemos los resultados propiamente dichos, usando un campo por cada objeto resultante de la consulta, que como nombre tiene el número de orden dentro de la consulta. El valor de estos campos es cada objeto, completo con todo sus campos.
El almacenar el objeto completo es desnormalizar nuestro almacenamiento de datos, con lo que vamos a duplicar información con el consiguiente gasto de espacio en disco. Este enfoque tiene la ventaja de mejorar el rendimiento a la hora de leer, debido a que el objeto completo se encuentra almacenado en la propia consulta. Podemos por otro lado ahorrar espacio sacrificando algo el rendimiento. Para ello no almacenamos los objetos completos, sino sólo sus claves. Esto está más en consonancia con un enfoque “normalizado” al almacenamiento de datos. La cosa quedaría por ejemplo:

{
  'query:varonesMayoresEdadRicos':{
    'totalCount':2344,
    '1':'k1',
    '2':'k76',
    '3':'k34987'
// ....Restantes resultados...
  }
}

En este caso, cuando recuperemos la consulta, tendremos que hacer un acceso extra al almacén donde están guardadas las entidades para traernos los datos de cada una de ellas. En el fondo esto no es más que un “join” hecha manualmente.
De nuevo hay que ver que nos interesa más, si desnormalizar y duplicar información, o ahorrar espacio de almacenamiento y hacer el “join”. Lo bueno es que podemos elegir que opción queremos.

En muchos otros casos nos encontraremos con consultas donde no queremos recuperar el objeto entero sino sólo algunos de sus campos.

SELECT NOMBRE, EMPRESA
FROM PERSONAS
WHERE EDAD > 18 AND SEXO = 'VARON' AND SUELDO > 25000

En estos caso el enfoque “desnormalizado” tiene más sentido:

{
  'query:varonesMayoresEdadRicos':{
    'totalCount':2344,
    '1':{
      'nombre':'Juan',
      'empresa':'Acme S.A.'
    },'2':{
      'nombre':'Pedro',
      'empresa':'Acme S.A.'
    },'3':{
      'nombre':'Eduardo',
      'empresa':'Acme S.A.'
    }
// ....Restantes resultados...
  }
}

Algunos os preguntaréis porque almaceno los resultados como “campos”. Un enfoque más intuitivo sería el de crear un único campo ‘resultados’ que fuera un array de objetos o de claves. El problema de este enfoque es que cuando fueras a recuperar la consulta tendrías que deserializar todo el array completo. Esto sólo es práctico con consultas con pocos resultados, pero no con las que tengan miles o millones de resultados. Algunos sistemas clave/valor, como BigTable o Cassandra, permiten recuperar los objetos parcialmente, es decir, traerte sólo unos campos del objeto y otros no. En estos sistemas el diseño que propongo nos permite traer los resultados de uno en uno o paginando. Esto no sería posible si almacenara los resultados en un único campo de tipo array. Sin embargo, ¿qué ocurre si mi sistema clave/valor me obliga a traerme el objeto entero? En este caso debemos cambiar de diseño. Veamos:

{
  'query:varonesMayoresEdadRicos:globalData':{
    'totalCount':2344,
    // Otros datos globales y metainformación de la consulta....
  },
  'query:varonesMayoresEdadRicos:1':{
    'key':'k1',
    'nombre':'Juan',
    'edad':18,
    'empresa':'Acme S.A.',
    'sueldo':30000,
    'sexo':'VARON',
    'tfno':4234564
  },
  'query:varonesMayoresEdadRicos:2':{
    'key':'k76',
    'nombre':'Pedro',
    'edad':19,
    'empresa':'Acme S.A.',
    'sueldo':45000,
    'sexo':'VARON',
    'tfno':42321564
  },
  'query:varonesMayoresEdadRicos:3':{
    'key':'k34987',
    'nombre':'Eduardo',
    'edad':19,
    'empresa':'Acme S.A.',
    'sueldo':30000,
    'sexo':'VARON',
    'tfno':7664666
  }
// ....Restantes resultados...
}

O si preferimos un enfoque normalizado:

{
  'query:varonesMayoresEdadRicos:globalData':{
    'totalCount':2344,
    // Otros datos globales y metainformación de la consulta....
  },
  'query:varonesMayoresEdadRicos:1':'k1',
  'query:varonesMayoresEdadRicos:2':'k76',
  'query:varonesMayoresEdadRicos:3':'k34987'
// ....Restantes resultados...
}

El truco en ambos casos es usar una clave y/o “fila” por cada resultado de la consulta, y una entrada adicional para la metainformación. De esta forma podemos paginar la consulta en cualquier sistema clave valor que soporte accesos por rango de claves. La última dificultad reside en si estamos usando sistemas clave/valor que no soportan accesos por rango de claves. En estos sistemas sólo podemos acceder a las claves de una en una. Para solucionar este problema podemos usar otro diseño en nuestra persistencia de las queries. Usando un enfoque “normalizado”:

{
  'query:varonesMayoresEdadRicos:globalData':{
    'totalCount':2344,
    // Otros datos globales y metainformación de la consulta....
  },
  'query:varonesMayoresEdadRicos:page1':{
    'pageSize':3,
    'nextPage':'query:varonesMayoresEdadRicos:page2',
    '1':'k1',
    '2':'k76',
    '3':'k34987'
  },
  'query:varonesMayoresEdadRicos:page2':{
    'pageSize':3,
    'prevPage':'query:varonesMayoresEdadRicos:page1',
    'nextPage':'query:varonesMayoresEdadRicos:page3',
    '1':'k256g',
    '2':'k365',
    '3':'k487'
 }
// ....Restantes páginas de resultados...
}

En este caso tenemos el trabajo adicional de preparar las páginas al persistir la consulta. Cada página es una fila o clave dentro del almacén de persistencia. Además de los resultados tenemos campos indicando el tamaño de cada página, y las páginas anteriores y siguiente.

Hasta ahora nos hemos preocupado por las consultas sin parámetros. Sin embargo normalmente tenemos muchas consultas que son parametrizables. Como ejemplo, varonesMayoresEdadConSueldo(sueldo: float). En SQL:

SELECT *
FROM PERSONAS
WHERE EDAD > 18 AND SEXO = 'VARON' AND SUELDO = #sueldo:FLOAT# ORDER BY EDAD

En este caso la solución es tratar esta consulta como si fueran N consultas sin parámetros, cada una representando cada uno de los posibles valores del parámetro. Obviamente hay infinitos sueldos posibles, pero sólo nos interesan los posibles valores de éste que contengan datos en nuestro sistema. Usando un enfoque normalizado, y usando una fila o par clave/valor por resultado, el ejemplo quedaría como sigue:

{
  'query:varonesMayoresEdadConSueldo:00002345600:globalData':{
    'totalCount':344 // Resultados con sueldo igual a 23.456 euros
    // Otros datos globales y metainformación de la consulta....
  },
  'query:varonesMayoresEdadRicos:00002345600:000000001':'kX3456GF',
  'query:varonesMayoresEdadRicos:00002345600:000000002':'k76dfw',
  'query:varonesMayoresEdadRicos:00002345600:000000003':'k349sd8s7',
// ....Restantes resultados para sueldo igual a 23.456...
  'query:varonesMayoresEdadConSueldo:00012000075:globalData':{
    'totalCount':2 // Resultados con sueldo igual a 120.000,75 euros
    // Otros datos globales y metainformación de la consulta....
  },
  'query:varonesMayoresEdadRicos:00012000075:000000001':'kZZZZ',
  'query:varonesMayoresEdadRicos:00012000075:000000002':'k666f3'
// ....Restantes resultados con otros sueldos...
}

La forma en que construyo las claves es curiosa. Cada clave es la concatenación, separado con ‘:’ de ‘query’, el nombre de la consulta, el sueldo y el orden en que deben aparecer (por edad). Como en realidad estoy usando claves que son strings, el orden de almacenamiento es lexicográfico. Para que el orden coincida con el sueldo he tenido que normalizar el sueldo en formato string, rellenando con 0s y reservando las dos últimas posiciones para los decimales. También he formateado el orden por edad. De esta forma las filas se almacenan por orden, principalmente por sueldo y secundariamente por edad. ¿Por qué molestarme en todo esto? Al fin y al cabo sólo quiero buscar las personas con un sueldo concreto. Desde este punto de vista tiene sentido formatear el orden por edad, pero no el sueldo, para conseguir paginación. La respuesta es sencilla, si formateamos el sueldo, la siguiente query sale gratis:

-- Todos los varones mayores de edad que ganan menos de un sueldoMáximo
SELECT *
FROM PERSONAS
WHERE EDAD > 18 AND SEXO = 'VARON' AND SUELDO < #sueldoMaximo:FLOAT# ORDER BY EDAD

Y también:

-- Todos los varones mayores de edad que ganan más que un sueldoMinimo
SELECT * FROM PERSONAS WHERE EDAD > 18 AND SEXO = 'VARON' AND SUELDO > #sueldoMinimo:FLOAT# ORDER BY EDAD

Nos basta con hacer una consulta por rango de claves para implementar ambas queries. Con lo que simplemente una vez que tenemos una query paramétrica con el operador igual obtenemos gratis las consultas con el operador mayor y menor, sólo necesitamos actualizar la primera de las tres. Por supuesto esto sólo va bien si nuestro sistema clave/valor admite consultas por rangos de claves, como es el caso de REDIS, Toky Cabinet’s B+Tree, BigTable y Cassandra. Si no tenemos esta capacidad entonces tendremos que tratar estas queries con mayor y menor como independientes de la primera, aunque otras soluciones más sofisticadas son posibles.

Algunos sistemas clave/valor te permiten definir como quieres interpretar las claves, si como números, fechas, strings, etc. De esta forma podríamos simplificar la forma de montar las claves, y tratarlas como numéricos. Hay que tener claro como va a tratar nuestro sistema de almacenamiento las claves y que capacidades de query tiene, para diseñar de la mejor manera nuestro esquema de almacenamiento de consultas. Por ejemplo, Cassandra nos permite:

  • Definir filas con claves ordenadas dentro de una “column family”. Las filas se almacenan por orden.
  • Queries por rango de claves. Como las filas se almacenan por orden las consultas por rango son eficientes.
  • Recuperar parcialmente una fila (sólo algunas columnas o campos).
  • Ordenar las columnas o campos de una misma fila, y traerse rangos de columnas. La query de rangos de claves se puede combinar con la de rangos de columnas.
  • Definir el orden de las filas y las columnas usando distintas funciones de ordenación: numérico, lexicográfico, fechas, etc.

Como vemos Cassandra es bastante potente y flexible. Usándolo podemos crear un esquema de almacenamiento flexible y eficiente como el que sigue:

{
// KeySpace: representa todo el esquema global de almacenamiento
  'query:varonesMayoresEdadConSueldo': {
  // ColumnFamily, cada fila tiene como clave el sueldo, ordenado numéricamente (float)
  // Las filas se almacenan por orden y lo más próximas posibles en disco
    '23456': {
    // Fila, tiene N columnas, ordenadas numericamente. Cada nombre de columna es el orden en la consulta (por edad)
    // El valor da cada columna es la clave de la persona
      '1':'kX3456GF',
      '2':'k76dfw',
      '3':'k349sd8s7'
    // Otros resultados para este sueldo
    },
    '120000.75': {
      '1':'kZZZZ',
      '2':'k666f3'
    },
// ... otros sueldos ...
  }
// Otras column families: otras queries, datos de persona etc.
}

Mucha gente se enfrenta a Cassandra por primera vez sin haber reflexionado sobre como hacer queries complejas con él. Por esto suelen exclamar “What the fuck !” cuando ven el modelo de KeySpaces, ColumnFamilies, SuperColumnFamilies y demás. Simplemente intentan hacer analogías con las “tablas de toda la vida”, y se pierden que una motivación importante es realizar esquemas de almacenamiento de consultas flexibles y eficientes. Como podéis apreciar si lo pensamos desde el punto de vista de implementar consultas, toda esta historia de ColumnFamilies tiene perfecto sentido:

  • Una ColumnFamily para almacenar entidades.
  • La ColumnFamily nos sirve para almacenar queries paramétricas, usando el enfoque normalizado, como hemos visto antes.
  • Si queremos desnormalizar la consulta, podemos usar SuperColumnFamilies

Si añadimos sus capacidades de escalabilidad, tolerancia a fallos y CAP ajustable, yo veo a Cassandra y BigTable una opción muy potente a las bases de datos tradicionales.

Como hemos visto somos capaces de reflejar de forma persistente, y eficiente, el resultado de un consulta cualquiera. Sin embargo todo esto está muy bien cuando tenemos una aplicación concreta, en la que sabemos que se van a realizar una serie concreta de consultas. Es decir, que el conjunto de consultas a realizar está prefijado. Este es el caso de la mayoría de las aplicaciones de hoy en día, dado un funcional, o un conjunto de historias de usuario, podemos definir y programar todas las queries necesarias para nuestro sistema.

Existe otro tipo de aplicaciones donde este conjunto de consultas no se sabe de antemano. En esta clase de aplicaciones el usuario final puede definir mediante algún lenguaje de query (textual o visual) cualquier consulta que se le ocurra. Es el caso del data warehousing y el business intelligence o el data mining. ¿Cómo podemos implementar este tipo de sistemas si no tenemos SQL? Al fin y al cabo no podemos pedirle al usuario que programe las consultas él con nuestro lenguaje de programación favorito ¿Debemos usar en este caso un sistema especializado? Bien, esto último es una buena opción, pero no es la única… cómo veremos en mi siguiente post.

Read Full Post »


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 »


Como estoy liado con la CAS2010 os dejo otra de mis encuestitas. La verdad es que me interesa bastante saber vuestra opinión. El qué propiedades CAP son más importantes es algo que depende del dominio de negocio en si.

La semana que viene intentaré montar otro post de los míos.

Como siempre os dejo la zona de comentarios por si quereis debatir un poquito…

Read Full Post »


Como no puedo atender temporalmente este blog como es debido, os dejo una encuesta para que os vayáis entreteniendo.

Por supuesto dejad los comentarios que queráis, yo vuelvo pronto.

Read Full Post »


Continúo con el tema del noSQL. Técnicamente, no se si realmente el uso de transacciones no ACID, entra en el movimiento noSQL, pero la verdad es que normalmente van de la mano. Además hay una identificación muy estrecha entre transacciones ACID y las bases de datos tradicionales (aunque esto no es necesariamente cierto).

Todos hemos aprendido desde chiquititos lo que son las transacciones ACID y lo bondadosas que son. Para refrescar un poco:

  • A de Atomic. Las transacciones se ejecutan correctamente o no. Si fallan es como si ni siquiera se hubieran intentado ejecutar.
  • D de Durability. El resultado de las transacciones es un cambio en el estado del sistema persistente. Si apagamos la máquina y la arrancamos de nuevo el cambio producido por la transacción aun está, persiste.
  • C de Consistencia. Un sistema consistente es uno que garantiza que cada vez que se hace un cambio en el estado del sistema, a partir de ese momento, éste reportará el nuevo valor del estado a todos los clientes que lo soliciten, mientras no se vuelva a cambiar explícitamente el dato.
  • I de Isolation. Cuando varias transacciones se ejecutan en paralelo, cada una de ellas ve el sistema de la misma manera a como lo vería si se ejecutaran de forma aislada o secuencial.

Esto es algo básico que se enseña en todas las universidades. Sin embargo no suelen enseñar en la facultad el teorema CAP (al menos a mi no). El teorema CAP viene a decir, de forma resumida, sólo puedes tener dos de las siguientes tres propiedades, pero no las tres a la vez:

  • Consistencia fuerte (la C en ACID y CAP). En el sentido de la transacciones ACID. Todos los clientes ven la misma versión de los datos, incluso en presencia de actualizaciones, de forma consistente.
  • Disponibilidad o Availability (la A de CAP pero no en ACID).  El sistema está disponible y responde en un tiempo adecuado a todos los clientes. Este concepto está muy relacionado con el tiempo de respuesta. Si el tiempo de respuesta excede un umbral, el sistema se considera no disponible. Este umbral puede ser el timeout de un socket, pero también puede ser la paciencia del usuario.
  • Tolerancia a fallos (la P en CAP, en inglés Partition Tolerance). Incluso en presencia de fallos en el servidor, todos los clientes pueden tener servicio y poder acceder a los datos. Un sistema tolerante a fallos sigue funcionando aunque uno de sus servidores se caiga o se corten algunas de las conexiones de red entre servidores. Un sistema completamente tolerante a fallos sólo puede dejar de funcionar si caen todos los servidores o se pierde el contacto con todos ellos.

El teorema CAP nos dice que no hay nada bueno, bonito y barato en el mundo de la persistencia, y que debemos reflexionar sobre que propiedades queremos escoger. Hay que enfatizar que no tiene que ser una decisión de todo o nada. Podemos sacrificar algo de consistencia en vez de toda la consistencia. También podemos sacrificar un poco de disponibilidad, que en la práctica es permitir que se degrade el tiempo de respuesta e incluso que algunos clientes pierdan disponibilidad por un tiempo limitado. O bien se puede decidir perder algo de tolerancia a fallos, que en la práctica significa tolerar una cantidad limitada determinada de fallos pero nada por encima de un límite.

La forma en que podemos conseguir tolerancia a fallos es tener muchos nodos de forma que si cae uno haya otro capaz de tomar el relevo. Si queremos un sistema tolerante a fallos y que además tenga consistencia absoluta, debemos replicar los datos entre los nodos cada vez que hagamos una escritura. Al escribir, todos los nodos deben replicarse y debemos esperar hasta que este hecho suceda para poder dar por terminada la operación. Esto como es normal hace que la operación sea tanto más lenta cuanto más nodos tengamos, lo que degrada el tiempo de respuesta y por lo tanto la disponibilidad. Otro efecto negativo es que aumenta la probabilidad de que la operación no se produzca con éxito, ya que debemos escribir en todos los nodos, y alguno puede estar caído, y para que la operación sea exitosa debe escribir en todos los nodos. La probabilidad de que al menos un nodo esté caído aumenta con el número de nodos totales del sistema, con lo que cuanto más nivel de replicación tenemos, más probabilidad de fallar en la operación y por lo tanto de falta de disponibilidad. Esto va en contra de la disponibilidad del sistema. Como contrapartida sólo necesitamos leer de un nodo, con lo que en lectura tenemos gran rendimiento, disponibilidad y tolerancia a fallos. Las bases de datos tradicionales suelen usar el enfoque anteriormente expuesto como mecanismo de tolerancia a fallos.

Si queremos aumentar la disponibilidad, la operación debe terminar antes, lo que implica que no podemos esperar a que se repliquen todos los nodos, sino sólo uno o quizás unos cuantos pero no todos. El hecho de que la operación termine antes de que todos los nodos se repliquen aumenta la disponibilidad pero a su vez degrada la consistencia al no tener todos los nodos la misma información. Así si un cliente lee de un nodo que pudo ser replicado no tendrá problema, pero si lee de un nodo que aún no ha sido replicado se producirá una lectura inconsistente con la escritura anterior. Normalmente los sistemas que permiten sacrificar consistencia por disponibilidad, tiempo de respuesta y tolerancia a fallos implementan un modelo de consistencia llamado eventually consistency (consistencia eventual). En sistemas con consistencia eventual, si bien al terminar una operación el sistema puede estar inconsistente, se garantiza que al cabo de un tiempo el sistema quedará en un estado consistente, es decir,  que eventualmente se conseguirá un estado consistente. Cuanto más tiempo pase entre la escritura y la lectura más probabilidad habrá de que el sistema sea consistente. No se puede especificar un tiempo mínimo para que se produzca esta consistencia ya que el sistema puede sufrir algún fallo (congestión de red, caída de un nodo, etc).

El hecho de que podamos tener inconsistencia complica la lógica de los clientes ¿Cómo podemos detectar y resolver problemas de consistencia? Para explicarlo os pongo un escenario:

  1. Un cliente escribe un dato con un grado de consistencia intermedio. Cuando acaba la operación está garantizado que la mitad de los nodos más uno están actualizados.
  2. Otro cliente lee la misma entidad que el anterior modificó. Si leyera con consistencia baja, es decir de sólo un nodo, es probable que lea un dato antiguo. Esto en algunos sistemas donde la consistencia no es importante no es problemático. Si necesitamos un nivel intermedio de consistencia sí es un problema ya que no somos capaces de detectar que la lectura es inconsistente.
  3. Otro cliente más hace una lectura de dicha entidad, pero esta vez con consistencia intermedia (mitad nodos más uno). De esta manera se asegura que leerá de al menos un nodo actualizado, recordad que la escritura garantizó que se escribió en la mitad de los nodos más uno, con lo que en el peor de los casos el conjunto de los nodos de los que se lee y en los que se escribió tendrán un nodo en común. Todo esto implica que el cliente va a recibir dos versiones del mismo dato, la antigua obtenida de los nodos no actualizados, y la nueva obtenida de los nodos actualizados. Es decir, al contrario que en un sistema de persistencia tradicional, el sistema nos puede devolver más de una versión del mismo dato. Si esto ocurre el cliente se da cuenta de que se ha producido una inconsistencia, ya que si no se hubiera producido sólo nos devolvería una versión (a condición de que la escritura se haya hecho con consistencia intermedia).

Aunque en el escenario me centro en la lectura, la inconsistencia también puede presentarse en la escritura, en el caso en el que intentásemos escribir en varios nodos, cada uno con una versión diferente. Como vemos, en sistemas con grados de consistencia no estricto, el cliente tiene la posibilidad de detectar inconsistencias, pero por contra le queda la papeleta de resolver el conflicto entre varias versiones. A algunos les parecerá esto una torpeza, ¡ pero a mi me parece genial ! Esto quiere decir que la resolución de conflictos queda en mano de reglas de negocio en vez de en algoritmos genéricos (que es lo que en realidad hacen las bases de datos tradicionales de forma encubierta). Tal vez nos dé más trabajo programar estas reglas de negocio, pero como son específicas para cada situación de nuestro sistema, seguro que son más eficientes. Algunos algoritmos típicos:

  • Ante conflictos de consistencia fallar siempre. Regla sencilla pero matona.
  • En caso de escritura y conflicto, sobrescribir siempre (éxito asegurado).
  • Si la consistencia me da igual cojo una versión al azar. Si este es el caso es mejor realizar la operación con un grado de consistencia mínimo (un solo nodo) que es más eficiente.
  • Escojo la versión más reciente. Normalmente los sistemas basados en consistencia eventual te proporcionan un timestamp de cual es el dato más reciente. Si no a podemos implementarlo manualmente, poniendo en cada dato una fecha de cambio.
  • No resuelvo y le paso todas las versiones al cliente en un formato amigable para el usuario. Esto se va pareciendo a un sistema de control de versiones…
  • Fusiono (merge) las varias versiones usando alguna regla de negocio. Lo dicho, un sistema de control de versiones, Enrique, que se te va la olla.

Bueno, espero que el cuerpo se os haya quedado bien.

Otro hecho interesante de este tipo de sistemas es que podemos especificar un grado de consistencia diferente a las operaciones de lectura y escritura. Esto nos permite optimizar el rendimiento de las escrituras o de las lecturas en función de lo que nos interese.

Obviamente podemos tener una vía alternativa: sacrificar la tolerancia a fallos y tener sólo un nodo. De esta forma sólo se necesita escribir y leer del único nodo del sistema, con lo que conseguiremos consistencia y disponibilidad mientras el nodo no se caiga. Ni que decir tiene que estos sistemas tienen tolerancia a fallos cero. Realmente este es el enfoque clásico de una base de datos tradicional sin tolerancia a fallos. Es interesante que este enfoque no está totalmente libre de la degradación del tiempo de respuesta o de la disponibilidad aunque no se haya producido ningún fallo. Pensad en lo que ocurre si dos clientes quieren ejecutar dos transacciones concurrentes que afectan al mismo dato. En este momento se produce un conflicto. Si forzamos ACID estricto, podemos invocar la propiedad de aislamiento transaccional y decidir serializar las transacciones ya que intentan acceder al mismo dato (en BBDD relacionales, la misma fila). Para ello se suele implementar un mecanismo de bloqueo a nivel de fila, aunque también es típico bloqueo a nivel de tabla. Bien este mecanismo se suele llamar concurrencia pesimista, y puede llevar a la degradación del tiempo de respuesta o incluso a la indisponibilidad del sistema con alta carga de transacciones concurrentes. Recordad que esto está pasando en un sistema con un único nodo, es decir con tolerancia a fallos cero. Los fabricantes de base de datos lo saben y por ello te permiten relajar la consistencia mediante una disminución en el grado de aislamiento de las transacciones. Sin entrar en detalles, diré que si relajamos el grado de aislamiento, podemos usar concurrencia optimista, que consiste en no bloquear filas, y al escribir comprobar si la versión que está en la BBDD es la misma que la versión que leímos al principio de la transacción. En caso de que sea así no hay conflicto. En caso de que sean distintas versiones se produce un conflicto y hay que solucionarlo. Los algoritmos para solucionarlos son exactamente los mismos que los expuestos anteriormente: fallar, sobrescribir, fusionar automáticamente o fusión manual por parte del usuario. Como veis si tenéis un sistema de persistencia tradicional, no tolerante a fallos, y queréis escalar, tenéis que bajar el aislamiento transaccional y usar concurrencia optimista, lo que os lleva también a la necesidad de gestionar conflictos e inconsistencias en la aplicación.

El objetivo de diseño de un sistema tradicional siempre ha sido favorecer la consistencia sobre las otras propiedades del sistema. Esto nos lleva al dilema de o bien sacrificar la disponibilidad o la tolerancia a fallos. Es decir si queremos un sistema disponible y consistente, que responda a todos los clientes en un tiempo razonable, no puede ser tolerante a fallos y por lo tanto no se puede caer ningún servidor o línea de comunicación. Si tenemos un sistema tolerantes a fallos y consistente, y se produjera un fallo de red o la caída de una máquina, el sistema seguiría funcionando, pero o bien el tiempo de respuesta se degradaría o algunos clientes perderían el servicio temporalmente. Estos compromisos entre disponibilidad y tolerancia a fallos pueden ser aceptables en aquellas aplicaciones donde la consistencia es crítica, y el coste de un fallo en la consistencia es mayor que el coste de estar un tiempo sin servicio o que el sistema responda muy lentamente.

Sin embargo en algunos escenarios esto no es aceptable. Por ejemplo en una tienda online, la perdida de servicio es desastrosa, ya que equivale a cerrar completamente el negocio y es mucho más costoso que una inconsistencia puntual de vez en cuando. También es costoso que el usuario de una tienda online perciba un tiempo de respuesta muy lento o que no pueda entrar de vez en cuando. En estos sistemas se requiere una tolerancia a fallos y disponibilidad a toda costa, con lo que se relajan los requisitos de consistencia. Es una pura decisión práctica guiada por las fuerzas de mercado.

En un sistema que deba ofrecer información en tiempo real, la degradación del tiempo de respuesta es catastrófica ya que hace que el sistema deje de ser útil. Por lo tanto o se sacrifica la consistencia o la tolerancia a fallos. En el caso de que el sistema sea crítico se sacrifica la consistencia.

Normalmente en el negocio de los sistemas web a gran escala lo que realmente se necesita es disponibilidad, bajo tiempo de respuesta y tolerancia a fallos. Google, Amazon o eBay no pueden permitirse estar caídos. Twitter, Facebook o LinkedIn pueden tolerar grandes grados de inconsistencia pero no una caída o una degradación del rendimiento. No es de extrañar que éstas empresas hayan abandonado total o parcialmente el modelo de bases de datos tradicionales con transacciones ACID, y hayan investigado en sistemas de persistencia alternativos.

En este apartado merecen una mención especial Dynamo de Amazon y Cassandra (de Apache y facebook). Este último está basado en el propio Dynamo mezclado con BigTable (Google). En Cassandra puedes especificar al leer o escribir el nivel de consistencia que quieres tener en la operación. El nivel de consistencia más bajo es 1, que indica que al leer o escribir te basta con completar la operación en uno de los nodos. También puedes realizar operaciones con máxima consistencia, donde todos los nodos deben replicarse al escribir o bien responder en la lectura. Por último tiene un modo intermedio de consistencia llamado de quorum, donde se busca mayoría simple (la mitad más uno) para considerar una operación completada. Además tanto Dynamo como Cassandra implementan consistencia eventual y tolerancia a fallos mediante una arquitectura distribuida que no necesita de máquinas especiales. Basta con poner una buena cantidad de PCs para tener un sistema muy robusto. Ellos claman que no se necesitan procedimientos de instalación y explotación complejos pero dada mi falta de experiencia con estos productos en este sentido no os lo puedo asegurar.

Así que ya sabéis, si necesitáis un sistema a prueba de bombas, que esté siempre disponible con tiempos de respuesta decentes, y que escale a volúmenes de carga grande (decenas o cientos de miles de usuarios), id pensando en sacrificar consistencia y pasaros a la consistencia eventual, pero, ¡ apretaos los cinturones de seguridad !

Read Full Post »


Hago un alto con la serie de posts sobre la estructuración de los equipos para hacer una introducción a un tema candente, las bases de datos no relacionales y el movimiento noSQL.
Algunos pensarán que esto del noSQL es una moda más, pero lo curioso es que se enfrenta a otra moda mucho más poderosa e influyente, el SQL y las base de datos relacionales. En muchos sitios me enerva una práctica muy común que consiste en que al diseñar la aplicación y analizar los requisitos lo primero que se hace es… ¡el modelo de tablas relacional! Muchos consideran que la aplicación está casi hecha en cuanto tenemos un esquema de base de datos y unas sentencias SQL, y acoplan todo su código a esto. Esta forma de diseñar aplicaciones está muy bien cuando trabajas con COBOL pero desperdicia por completo las características de cualquier lenguaje de programación OO moderno. La cosa llega a tal extremo que muchas personas sólo piensan en aplicaciones CRUD y de hecho hay frameworks como Grails o Rails que te proporcionan una aplicación CRUD muy rápidamente si no te importa acoplarte con la BBDD.
Mi filosofía es un poco diferente, usar Acceptance TDD y DDD y DSL (a.k.a. API Sexy) para definir un core de negocio orientado a objetos que sea fácil de probar, fácil de usar y modele el dominio de la aplicación. Es decir, como uso OO, lo que hago es realmente modelar el negocio mediante un modelo de objetos, que refleja la realidad del problema que queremos resolver y contiene la lógica de negocio. Sobre este modelo monto un DSL para poder trabajar con el modelo escribiendo código que parezca lenguaje natural o al menos sea legible por el analista funcional. En esta filosofía de trabajo la interfaz de usuario y la capa de persistencia son periféricas a este core de negocio. De hecho se puede diseñar el sistema para que tenga distintas interfaces de usuario y puedas cambiar el mecanismo de persistencia de forma sencilla.

Desde este punto de vista, si yo diseño mi sistema con un core OO de negocio robusto, y necesito almacenar los objetos de forma persistente, entonces debo usar una capa de persistencia, y no me importa especialmente cual sea mientras cumpla mis requisitos de la forma más sencilla posible. Desde este punto de vista analicemos lo que nos ofrece una base de datos relacional:

  • Persistencia. Ok, es lo que busco.
  • Lógica de negocio, en forma de procedimientos almacenados y triggers. Pues no, esto me sobra, lo tengo en el core de negocio.
  • Transacciones ACID. Esto depende, si estoy haciendo operaciones críticas que deben realizarse de forma instantánea desde el punto de vista de negocio entonces sí. Si estoy escribiendo una aplicación de redes sociales y quiero escribir una actualización de estado, no me importa mucho si falla al grabarse, o tengo lecturas fantasma. Otro ejemplo de operación no ACID es el de una transferencia monetaria bancaria. Ciertamente das de alta la transferencia de dinero de forma atómica, pero la consolidación de los balances entre la cuenta destino y origen puede tardar días, teniendo mientras tanto un estado “inconsistente”.
  • SQL. Un lenguaje de query complejo, que me permite hacer casi cualquier consulta y que para optimizarlo tengo que ser un experto en BBDD. No gracias, las consultas complejas las hago en mi core de negocio, en mi lenguaje de programación favorito, y por que no, con mi DSL. Yo sólo quiero grabar, leer, y hacer unas cuantas consultas muy concretas, no cualquiera. Además, ¿cómo puede una BBDD ser más óptima en la ejecución de la consulta que mi código, hecho específicamente para mi problema? Es el típico caso de que una solución genérica a un problema no puede ser más eficiente que una solución específica para un problema específico. En este caso SQL es un lenguaje de query genérico, y no conoce los detalles específicos y posibles optimizaciones de mi aplicación, sólo puede usar técnicas generales.
  • Esquema. Estructura rígida y tipada de almacenamiento en forma de tablas con columnas ¿Para que quiero eso, si la estructura y el modelo y los tipos y validaciones del sistema está en el core OO? Realmente esto es un dolor, todos habréis sufrido con problemas de conversión de tipos entre columnas de tablas y campos de objetos, o entre objetos y tablas. Tal es el problema que nuestra aplicación termina haciendo uso de algún framework de mapeo objeto relacional, como Hibernate o iBatis o JPA, que añade otro grado de complejidad y configuración innecesario. Es innecesario porque la estructura de la información ya está en el modelo de objetos, no es necesaria replicarla en la capa de persistencia.

Si somos prácticos y aplicamos el principio KISS, vemos que una BBDD relacional puede ser, en muchos casos, algo que nos añade una complejidad innecesaria. Nos añade funcionalidades duplicadas con el core OO o simplemente innecesarias en algunos casos. Lo que ocurre es que el típico escenario de aplicación, el típico caso de uso, está cambiando respecto a lo que era antes. Cada vez tenemos más aplicaciones que tienen alguna o todas de las siguientes características:

  • Deben soportar grandes volúmenes de datos. Pensad en cualquier red social o en aplicaciones de Google, como el google maps.
  • Deben soportar altas cargas de transacciones online, debido a que el número de usuarios en la web puede ser enorme. De hecho cuantos más usuarios tengamos, los señores de negocio estarán más contentos.
  • El rendimiento no debe degradarse debido a los dos puntos anteriores.
  • ¡Que no se caiga! Hoy en día la caída e indisponibilidad de una aplicación tiene consecuencias monetarias muy fuertes.
  • En aplicaciones como las redes sociales, la consistencia no es crítica, con lo que las transacciones ACID no son necesarias.
  • ¡Fácil de usar! No quiero sufrir para simplemente grabar datos y hacer cuatro consultas básicas. No quiero saber nada de configuraciones de Hibernate o explains de Oracle.
  • ¡Fácil de integrar con un core OO de negocio!

Como vemos en muchos tipos de aplicaciones, las bases de datos relacionales te ofrecen capacidades que no te interesan a cambio de complicarte la vida. Por otro lado puedes tener muchos problemas con los requisitos de rendimiento, escalabilidad y disponibilidad. Para solucionar esos problemas vas a necesitar expertos, máquinas y productos normalmente caros. Lo peor que es que dichos expertos pueden coger una posición de poder y empezar a imponer sus reglas. Lo ideal es que las reglas las ponga el cliente, no un grupo de sacerdotes de una oscura tecnología.

Hasta ahora por tradición y por moda (o por imposición) cada vez que se nos planteaba la necesidad de persistir información elegíamos una BBDD relacional de forma automática y sin pensar. Como la integración entre un core OO y la BBDD relacionales es complicada, la gente llegó a varias soluciones (no necesariamente excluyentes):

  • No usar un core OO de negocio. Me hago una aplicación igual que la que hacía en COBOL pero traducida literalmente a JAVA.
  • Uso un Grails o un Rails, me acoplo al esquema de la BBDD y ya está.
  • Uso mi framework de mapeo objeto relacional (Hibernate, iBatis, etc),  e invierto esfuerzo en configurarlo y optimizarlo.

Esto es un razonamiento torticero, como tengo que usar BBDD relacionales entonces me complico la vida o paso de la OO. Es decir, me creo problemas “artificiales”, que no existían originalmente, porque elijo no solucionar el problema original (la persistencia) de forma óptima, sino hacer según manda la tradición y la moda. Lo lógico es analizar que tiene que hacer tu sistema de persistencia y después decidir qué usas. Puedes terminar usando una BBDD relacional si realmente la necesitas, pero en la mayoría de los casos os daréis cuenta que no os hace falta. Este es precisamente el punto de partida del movimiento noSQL.

Resumiendo, el movimiento noSQL, entre otras cosas, te propone:

  • No tienes porque usar una BBDD relacional, tienes alternativas para la persistencia de tu modelo de objetos.
  • No te hace falta un lenguaje de triggers ni de consultas complejas ya que la lógica de negocio está en la aplicación, no en el sistema de persistencia.
  • No necesitas un esquema rígido en tu sistema de persistencia, ya tienes tu modelo de objetos. Puede que incluso trabajes con un lenguaje de programación de tipado dinámico (js, ruby, etc). Simplemente quieres persistir objetos enteros o documentos. La estructura exacta de lo que guardes no debe ser de la incumbencia del sistema de persistencia, sino de tu modelo de negocio.
  • Te vale una API sencilla y un modelo de consulta sencillo. Uno muy popular es el clave valor, o tabla hash persistente. Guardas objetos o documentos y los asocias a una clave para poder recuperarlos.
  • Puedes prescindir de ACID para conseguir alta disponibilidad, escalabilidad y bajos tiempos de respuesta.
  • No odies SQL, simplemente debes saber cuando usarlo y cuando no. Hay casos en los que es la mejor solución, pero no en todos, y creo que cada vez en menos.

En siguientes posts hablaré sobre que sistemas de persistencia noSQL son más usados, su paradigma de desarrollo y como podemos eliminar, en muchos casos, la necesidad de transacciones ACID y el 2PC, con el objetivo de aumentar la disponibilidad y escalabilidad. Iré mezclando posts de noSQL con los de estructuración de equipo para que haya variedad.

Read Full Post »

Seguir

Recibe cada nueva publicación en tu buzón de correo electrónico.

Únete a otros 43 seguidores