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 »