Skip to content

Apache Kafka (1)

Cuando se pulsa una tecla en el teclado de un ordenador se produce una interrupción. Lo que quiere decir que el ordenador debe dejar de hacer lo que estaba haciendo para atender dicha interrupción. El sistema operativo recoge el carácter correspondiente a la tecla pulsada y lo notifica a las aplicaciones. Esto permite por ejemplo que una aplicación web pueda indicarle a un navegador que ejecute un determinado trozo de código JavaScript cuando se produzca la pulsación de una tecla.

El término interrupción se utiliza habitualmente para referirse a la acción que sucede a bajo nivel. En un nivel más alto de abstracción estas acciones se conocen como eventos. La mayoría de aplicaciones procesan eventos tales como la pulsación de una tecla o un botón de un ratón de manera local. Algunas además los reenvían a un servidor remoto. El análisis de estos eventos permite conocer de forma muy precisa como interaccionan los usuarios con una aplicación.

Enviar eventos de teclado o ratón a un servidor remoto implica generar una gran cantidad de tráfico. Aunque no por ello deja de ser una réplica del modelo de más bajo nivel. Una aplicación notifica eventos a un servidor de igual forma que un teclado o ratón los notifica a un ordenador. Lo que se puede generalizar diciendo que todo sistema de información puede entenderse como un conjunto de procesos que se comunican generando y respondiendo a eventos. La tecnología o estrategia utilizada para procesar la información puede variar, pero hay patrones recurrentes. Utilizar algún tipo de almacén intermedio para evitar la pérdida de eventos es uno de ellos. El buffer que utiliza un teclado para almacenar las últimas teclas pulsadas es similar al que utiliza un servidor para almacenar los eventos recibidos. Y en este sentido, Apache Kafka puede verse como uno de estos almacenes de eventos.

Kafka

Apache Kafka es una plataforma altamente escalable, creada originalmente por LinkedIn, que facilita el proceso de ingentes cantidades de información en tiempo real. Su caso de uso más habitual es su utilización como sistema de mensajería. Donde la publicación de un mensaje por parte de un proceso representa un evento que se produce en el sistema, conteniendo el mensaje el detalle concreto del evento generado, y permitiendo que una aplicación lo procese con objeto de consumirlo completamente, o lo transforme con objeto de volver a publicarlo como parte de una cadena de procesamiento mayor.

La versión actual de Kafka es la 2.0.0, una major release liberada a finales de julio de este mismo año.

La introducción de tan bajo nivel de los primeros párrafos es para tratar de responder las preguntas habituales acerca de para qué sirve Kafka y cuando se debe utilizar. Se podría utilizar siempre, para procesar todos los eventos generados por un sistema de cualquier tamaño, pero se debe utilizar preferentemente cuando el volumen de información a gestionar, sobre todo en tiempo real, sea enorme. El rendimiento y la escalabilidad son dos de los factores clave que deberían tenerse en cuenta a la hora de tomar la decisión de utilizarlo.

Persistencia

Kafka está diseñado para almacenar y distribuir grandes volúmenes de eventos de forma muy eficiente. Una característica que lo distingue del resto de productos similares es que almacena los eventos de forma duradera. Una aplicación puede suscribirse para recibir los nuevos eventos que se produzcan, o recibir los eventos que se produjeron en un periodo de tiempo concreto dado. Esta característica permite ejecutar procesos sobre eventos generados en el pasado como si se estuvieran produciendo en tiempo real. Y posibilita la repetición a posteriori en caso de error de un mismo proceso con los mismos eventos.

En este punto es importante entender la visión que ofrece Kafka respecto a los eventos. Son las secuencias de información que representan la historia del proceso realizado por un sistema. Almacenando el histórico de eventos consumidos y producidos por un sistema se puede reproducir el proceso realizado por el mismo. Los eventos se pueden consumir en tiempo real, por ejemplo para responder de forma inmediata a las peticiones realizadas por un cliente a través de una aplicación, pero también pueden procesarse con una menor prioridad, por ejemplo para agregar información de cara a su posterior análisis con algún tipo de herramienta a modo de cuadro de mandos. Con esta visión en mente, es fácil entender que Kafka se denomine a sí mismo “structured commit log”, es decir, un sistema de log que almacena los mensajes de forma estructurada para facilitar el tratamiento de los mismos, a diferencia de un log tradicional donde los mensajes son cadenas de texto añadidos en un fichero sin ninguna estructura.

Estructura

Kafka escala horizontalmente gracias a su arquitectura distribuida en forma de cluster con una alta tolerancia a fallos basada en Zookeeper. Un cluster de Kafka almacena información organizada en categorías llamadas tópicos e identificadas por un nombre. Donde cada tópico es un almacén de secuencias ordenadas de una unidad mínima de información denominado registro. Y cada registro es un paquete de información compuesto de una clave, un valor y un timestamp.

Los tópicos se organizan internamente en estructuras llamadas particiones. Estas particiones son similares a las existentes en muchas bases de datos relacionales, permitiendo almacenar los registros agrupados por algún criterio determinado con objeto de mejorar los tiempos de acceso. Los clientes que producen registros eligen la partición en la que deben publicarse estos. Cuando se publica un registro en un tópico se almacena en una partición y se le asigna un offset que indica su posición dentro de la partición. Un offset es un número secuencial único que identifica de forma única a un registro. Una característica de Kafka a este respecto es que los clientes que consumen registros son los que llevan el control de los offsets; cuando se subscriben a un tópico indican el offset del primer registro que quieren recibir. El control lo lleva el cliente, no el servidor, lo que permite simplificar al máximo la lógica del servidor. De hecho, Kafka gestiona los offsets de los consumidores con un tópico, lo que le evita tener que mantener estructuras privadas dedicadas y le permite reutilizar toda la infraestructura existente.

Los registros son persistidos en los tópicos en base a una política de retención configurable. Los registros pueden almacenarse durante horas, días, o indefinidamente según cada caso de uso concreto. Kafka garantiza un tiempo de respuesta constante independientemente del número de registros almacenados.

Todas las lecturas y escrituras sobre una determinada partición son realizadas por un único servidor denominado bróker y que actúa como líder. Por cada líder pueden existir otros brokers actuando como seguidores que replican la partición de forma pasiva. Si el líder de una partición cae entonces uno de sus seguidores se promociona automáticamente a líder haciéndose cargo de gestionar la partición. Incluso es posible replicar un mismo cluster en varios datacenters, ya sea como mecanismo de backup, o con el propósito de tener la información distribuida por regiones geográficas distintas.

Grupos de Consumidores

Un concepto importante en Kafka son los denominados grupos de consumidores. Cada grupo de consumidores está ligado a un tópico, y cada consumidor dentro de un grupo está ligado a una partición. Si todos los consumidores de un tópico pertenecen al mismo grupo entonces los registros se distribuyen de forma que se garantiza que un mismo registro es consumido una única vez por un único consumidor del grupo. Si los consumidores pertenecen a varios grupos entonces un mismo registro se distribuye a un único consumidor de cada grupo.

Esto permite configurar Kafka siguiendo los patrones habituales utilizados en los sistemas de mensajería clásicos. Si se utiliza un único grupo entonces el sistema se comporta como una cola, donde un mensaje es consumido por único proceso. Si se utilizan múltiples grupos entonces el sistema se comporta como un sistema de publicación/suscripción, donde un mensaje es consumido por varios procesos. Tener un único consumidor por cada partición garantiza que los registros se consumen en el mismo orden que se publican, pero implica que Kafka sólo garantiza el orden a nivel de partición, no de tópico.

API

Kafka está escrito en Scala y Java, aunque existen clientes para prácticamente todos los lenguajes de programación de uso más habitual. El cliente para Java es desarrollado por el propio proyecto de forma oficial.

Kafka expone su funcionalidad a través de un protocolo sobre TCP mediante el que oferta servicios agrupados en base a su cometido. Cada grupo de servicios es un API distinto. Cinco de ellos constituyen el núcleo del sistema. Producer API permite publicar registros en los tópicos. Consumer API permite suscribirse a los tópicos y consumir registros. Streams API permite consumir registros de tópicos para volver a publicarlos transformados en otros tópicos. Connector API permite construir pasarelas entre Kafka y otros sistemas, como por ejemplo una base de datos relacional. Y AdminClient API que permite gestionar los distintos componentes que conforman una instancia o cluster de Kafka.

Para el caso de uso de Kafka como un sistema de procesamiento de streams, además del API, existe un proyecto llamado KSQL que se liberó este mismo año y permite escribir procesos que consuman streams con un lenguaje similar a SQL.

Resumiendo, Kafka es una plataforma que aúna distintas características que permiten utilizarlo como un sistema de almacenamiento, un sistema de mensajería, y un sistema de procesamiento de streams muy eficiente y altamente escalable.

dartis (y 2)

En este artículo se continúa la revisión de las características ofrecidas por dartis, un cliente para Redis escrito en Dart que publiqué hace unos días.

Pub/Sub

Redis permite que los clientes entren en un modo de funcionamiento especial en el que se implementa el patrón Publish/Subscribe. En este modo los clientes pueden suscribirse a canales y recibir mensajes a través de ellos. Es decir, que Redis se comporta como un sistema de mensajería. En este modo los clientes sólo pueden ejecutar los comandos SUBSCRIBE, UNSUBSCRIBE, PSUBSCRIBE, PUNSUBSCRIBE, PING y QUIT.

El servidor envía, tanto las respuestas a las suscripciones a canales, como los propios mensajes publicados en dichos canales, en un stream de eventos con una estructura de mensajes distinta a la que se recibe cuando el cliente se encuentra operando en el modo normal, por lo que se debe conocer en todo momento en que modo se encuentra trabajando el cliente para poder procesar correctamente las respuestas recibidas del servidor.

La conexión a este modo de funcionamiento se realiza de forma explícita en dartis a través de una factoría estática. Y los eventos recibidos se publican a través de un Stream.

Tener una clase especializada para implementar este modo permite reducir la complejidad del código. Máxime cuando Redis sólo permite a los clientes en este modo ejecutar un conjunto de comandos muy concretos y salir terminando la conexión con el servidor. Aunque impide que el cliente ejecute otros comandos antes de entrar en este modo, como por ejemplo validarse contra el servidor utilizando una clave.

Monitor

Redis permite también que los clientes trabajen en modo monitor. En este modo no pueden ejecutar ningún comando, se limitan a recibir mensajes del servidor. Los mensajes recibidos contienen un texto con detalles acerca de todos los comandos ejecutados por todos los clientes contra el servidor. Es decir, que cada vez que un cliente ejecuta un comando, el servidor envía un mensaje informando de tal acción a todos los clientes que se encuentran en modo monitor. Esto resulta de utilidad para depurar aplicaciones de forma no intrusiva.

La conexión a este modo de funcionamiento se realiza de forma explícita en dartis a través de una factoría estática. Y los eventos recibidos se publican a través de un Stream.

De igual forma que en el modo del apartado anterior, disponer de una clase especializada reduce la complejidad de la implementación, pero no permite ejecutar ningún comando previo a la entrada en este modo.

Inline Commands

Redis permite la ejecución de comandos sin tener que implementar el protocolo RESP. En este modo los comandos se envían como cadenas de texto plano al servidor. Es similar a como se haría a través de una sesión Telnet, escribiendo directamente en una consola de texto.

Las respuestas recibidas desde el servidor en este modo están formateadas siguiendo el protocolo RESP, por lo que este modo es adecuado para comprobar la respuesta exacta del servidor ante un determinado comando, evitando cualquier tipo de agente intermedio que la procese o altere de alguna forma.

Transacciones

Redis permite ejecutar varios comandos dentro del contexto de una transacción. Aunque no es una acción de “todo o nada” como habitualmente se sobreentiende que funciona una transacción con la mayoría de software existente.

Una transacción empieza en Redis ejecutando el comando MULTI y termina ejecutando el comando EXEC. Todos los comandos que se ejecutan después de MULTI se encolan y se ejecutan cuando se llama a EXEC. Si se produce un error encolando un comando la transacción se aborta en el momento que se llama a EXEC, no en el momento que se produce el error encolando el comando. Y si se produce un error durante la ejecución de un comando encolado se continúa con el siguiente comando encolado, no se produce un rollback de lo ejecutado hasta el momento.

EXEC retorna el resultado de la ejecución de todos los comandos ejecutados en el contexto de la transacción, tanto los terminados con éxito como los terminados con error, para dar la oportunidad al cliente de actuar en función del resultado individual de cada comando.

Con este patrón no se puede utilizar await  sobre cada comando, ya que el Future de cada comando no se completa hasta que se obtiene la respuesta del servidor, algo que no ocurre hasta ejecutar EXEC.

Una transacción iniciada con MULTI puede abortarse ejecutando el command DISCARD antes de llamar a EXEC. El comando DISCARD termina la transacción en curso descartando todos los comandos encolados hasta el momento.

Por último, comentar que en el contexto de una transacción no se debe utilizar el comando CLIENT REPLY, ya que en determinados casos el cliente puede perder la sincronía con el servidor. Es por ello que las transacciones en Redis están documentadas como una funcionalidad a ser deprecada, prefiriéndose el uso de scripts en Lua en el servidor.

Scripts Lua

Redis permite ejecutar scripts en Lua en el servidor. Esta característica es ofrecida a través de comandos, por lo que no requiere ninguna implementación especial por parte del cliente.

La única característica particular a tener en cuenta con esta funcionalidad es que la salida de la ejecución de un script puede ser de cualquier tipo. Lo mismo puede resultar en una primitiva, como una cadena de texto o un entero, o un array de valores heterogéneos. Para lidiar con esta situación, dartis admite un parámetro que permite mapear la respuesta del servidor en cualquier tipo que se quiera.

En el ejemplo del código anterior, el mapper convierte la respuesta del servidor en una lista de cadenas de texto utilizando las facilidades que ofrece dartis para crear serializadores y deserializadores personalizados.

Serializadores/Deserializadores

dartis tiene un sistema de conversores dinámico que permite añadir conversores nuevos o sobreescribir los existentes. Un conversor es una clase que convierte una primitiva en una lista de bytes de cara a ser enviado al servidor, y convierte una lista de respuestas del servidor o bytes en una primitiva de cara a que un cliente pueda utilizarla en una aplicación.

Por defecto hay registrados conversores para los tipos de uso más habitual como enteros, decimales, cadenas de texto y listas. Utilizando UTF-8 por defecto para las cadenas de texto.

Los conversores que transforman primitivas en listas de bytes se denominan codificadores, y son clases que se crean extendiendo de Converter o Encoder. Por ejemplo, el siguiente código muestra un conversor que transforma instancias del tipo DateTime de Dart en listas de bytes.

Por su parte, los conversores que transforman listas de respuestas del servidor o bytes en una primitiva se denominan decodificadores, y son clases que se crean extendiendo de Converter o Decoder. Por ejemplo, el siguiente código muestra un conversor que transforma una respuesta del servidor en una instancia del tipo DateTime de Dart.

Todos los codificadores se registran utilizando el atributo codec del cliente.

Permitir definir tipos personalizados facilita trabajar de forma sencilla con los comandos propios que pueda definir cualquier módulo para Redis. Siendo un módulo una extensión que se puede añadir a un servidor Redis, a modo de plugin, y que dartis soporta de manera natural.

Y estas son básicamente las principales características de la librería. En el propio repositorio del proyecto puede encontrarse más documentación y ejemplos de uso.

Para terminar, comentar que el proyecto está desarrollado utilizando Visual Studio Code como IDE con tan sólo un par de extensiones. EditorConfig para garantizar la uniformidad del formato de los ficheros, y Dart Code para la integración de las herramientas del SDK, en particular el formateador de código y el analizador de código estático. Travis como servidor de integración continua y Coveralls para el análisis de la cobertura del código. El proyecto tiene implementadas unos trescientos casos de prueba, incluyendo tanto pruebas unitarias como de integración, con un noventa por ciento de cobertura.

dartis (1)

Hace un par de días liberé dartis, un cliente para Redis escrito en Dart. Empecé a desarrollarlo para probar algunas de las novedades de la versión 2 de Dart y al final he acabado con un cliente sencillo pero bastante completo.

RESP

REdis Serialization Protocol (RESP) es el protocolo que implementa Redis para comunicar los clientes con el servidor. Es un protocolo muy básico sobre TCP, con tan sólo cinco tipos de paquetes distintos. Los mensajes son todos de tipo texto, con un primer carácter identificador del tipo de paquete, seguido de una longitud en algunos casos, a continuación el contenido del mensaje en sí mismo, y un par de caracteres de terminación.

El hecho de que sea un protocolo de tipo texto tiene la ventaja de que resulta fácil de implementar y depurar, pero tiene el inconveniente de que hay que convertir entre binario y texto continuamente, en particular las longitudes de los mensajes. En principio he optado por utilizar las facilidades del propio lenguaje para las conversiones, sin realizar ninguna medición del rendimiento, pero podría estudiarse la posibilidad de hacer una implementación más específica.

Por otra parte, debido a que los paquetes no contienen ningún tipo de identificador, es necesario guardar la secuencia de cada comando enviado de forma ordenada y casarla contra las respuestas recibidas en el mismo orden. Con el hándicap de que es posible deshabilitar las respuestas del servidor, por lo que en algunos casos los paquetes han de enviarse sin esperar respuesta. En determinadas circunstancias muy concretas el protocolo no impide que se produzcan pérdidas de sincronía entre cliente y servidor.

Conexión

La conexión al servidor se realiza siguiendo el típico patrón consistente en utilizar un método estático en la clase del cliente a modo de factoría.

Como parámetro se admite una cadena de conexión siguiendo el formato clásico de protocolo, host y puerto. Lo que posibilita una configuración muy sencilla y ampliable.
Redis permite trabajar con varias bases de datos dentro de una misma instancia, pero esta forma de trabajo no está totalmente soportada cuando se utiliza una instalación de Redis en cluster, por lo que es mejor no utilizar esta característica si no es absolutamente necesaria. Resulta más sencillo levantar más instancias.

Redis permite proteger de forma opcional con una clave los accesos al servidor, pero normalmente las instancias de Redis no se encuentran abiertas al mundo exterior, sino como dentro de una DMZ, por lo que no suele utilizarse, aunque ello depende en gran medida de las políticas de seguridad de cada cual.

Algunos puntos abiertos en la implementación de las conexiones con el servidor son la posibilidad de indicar un timeout, realizar reconexiones automáticas en caso de pérdida de conexión, y un pool de conexiones.

Comandos

Un cliente le comunica a un servidor Redis lo que quiere hacer a través de comandos. Y existen más de 200 comandos distintos. dartis implementa una interface genérica que permite ejecutar cualquier tipo de comando, a la vez que ofrece una interface más específica que expone todos los comandos ofrecidos por Redis de forma fuertemente tipada.

dartis funciona de forma totalmente asíncrona, por lo que todos los comandos retornan un Future que se completa cuando el servidor retorna el resultado.

Además, el conjunto de comandos está expuesto utilizando Generics, lo que permite que cada aplicación que utilice la librería pueda decidir el tipo concreto de datos que quiere utilizar, en vez de limitarse a utilizar String como hacen otras librerías.

Redis almacena secuencias de bytes, no cadenas de textos, por lo que puede utilizarse para gestionar cualquier tipo de información.

Los comandos están expuestos en forma de vista sobre el cliente, es decir, es la misma instancia pero exponiendo sólo una interface sobre la misma, por lo que pueden obtenerse distintas vistas del mismo cliente para trabajar con distintos tipos de datos.

Es seguro trabajar con varias vistas a un mismo tiempo, ya que todas utilizan el mismo cliente.

Pipelining

Redis permite enviar una al servidor una serie de comandos a un mismo tiempo, en vez de uno a uno. Aunque en la práctica esta es más una característica propia de las comunicaciones que de Redis. El cliente puede enviar más de un comando dentro de un mismo paquete TCP. Y el servidor puede procesar paquetes TCP que contengan más de un comando.
Por defecto dartis sólo envía un comando en cada paquete TCP, deshabilitando de forma explícita el algoritmo de Nagle sobre el socket, para garantizar que cada comando se envía de forma inmediata al servidor, pero permite activar el pipelining llamando al método pipeline:

En buena lógica, con este patrón no se puede utilizar await sobre cada comando, ya que el Future de cada comando no se completa hasta que se obtiene respuesta del servidor, y los comandos no se envían al servidor hasta que se vacía el pipeline con la llamada al método flush.

Otra opción es utilizar la lista de Futures retornada por el método flush para esperar hasta que se completen todos los comandos.

Esta forma de trabajo se utiliza sobre todo para realizar cargas masivas de información o clientes con casos de uso concretos que requieren un mayor rendimiento.

Fire And Forget

Redis permite que los clientes indiquen al servidor que no quieren recibir respuestas del servidor. Es decir, se pueden enviar comandos para su ejecución y desentenderse del resultado (fire and forget).

Esta es una característica que Redis ofrece a través del comando CLIENT REPLY. El comando necesita un parámetro que admite el valor OFF para deshabilitar todas las respuestas, SKIP para deshabilitar sólo la respuesta del comando siguiente, y ON para habilitar todas las respuestas.

En este modo los comandos se completan de forma inmediata con el valor null cuando las respuestas del servidor están deshabilitadas.

La implementación de esta característica no es compleja, pero requiere que el cliente detecte de manera explícita que se está ejecutando el comando CLIENT REPLY, y llevar la cuenta de para que comandos se debe esperar respuesta y para cuáles no. Si el propio comando CLIENT REPLY es el que falla entonces puede llegar a perderse la sincronía entre servidor y cliente, aunque se presupone una casuística con una probabilidad muy baja de que suceda.

MongoDB: Hands on!

MongoDB es una base de datos NoSQL distribuida y escalable que gestiona objetos almacenados en un formato llamado BSON. Un formato inspirado en el formato JSON, pero que almacena la información de forma binaria. Es habitual pensar en los objetos almacenados en MongoDB, llamados documentos, como objetos en formato JSON.

Una de las características más significativas de MongoDB es que es una base de datos de esquema dinámico, es decir, que no fuerza un esquema fijo predefinido como las base de datos relacionales. Cada documento (objeto JSON) puede tener los campos y tipos que se quieran. Aunque si realmente se necesita, es posible forzar unas reglas de validación siguiendo la especificación JSON Schema.

MongoDB permite almacenar los documentos agrupados en colecciones, equivalente a las tablas del modelo relacional, y realizar consultas sobre estas colecciones. Estas consultas pueden realizarse sobre cualquier campo de los documentos de una manera eficiente, ya que es posible crear índices, tanto sobre campos de primer nivel, como campos de objetos JSON anidados, e incluso arrays. Además, es posible realizar consultas agregadas sobre cualquier campo, a la manera de una cláusula GROUP BY en SQL. MongoDB da una gran importancia a las agregaciones, permitiendo incluso ejecutar varias agregaciones en paralelo con una única consulta.

Instalación

La instalación de MongoDB puede realizarse en local descargando un fichero comprimido, o alternativamente usando una imagen de Docker.

En Windows el ejecutable del servidor es mongod.exe, y para arrancarlo hay que pasarle el parámetro --dbpath con el nombre de un directorio existente. El cliente de línea de comandos es mongo.exe, y al arrancarlo se conectará automáticamente al servidor local por defecto.

El cliente GUI oficial de MongoDB se llama Compass, disponible a través de suscripción o de una versión Community que requiere registro. Como alternativa pueden encontrarse distintos clientes no oficiales, desarrollados tanto por empresas como particulares en régimen de código abierto. Uno de los clientes más populares es Robomongo, que fue adquirido por una empresa llamada 3T Software Labs, y le ha cambiado el nombre a la herramienta por el de Robo 3T. Su versión gratuita portable para Windows no requiere instalación, basta con descomprimir el fichero descargado en un directorio y lanzar el ejecutable.

En lo que sigue se entenderá que los ejemplos se ejecutan directamente sobre el cliente de línea de comandos mongo.exe contra una base de datos local. En la práctica se puede utilizar cualquier cliente contra cualquier servidor, ya sea local o proporcionado por un tercero. La propia MongoDB proporciona instancias en la nube a través de su servicio Atlas.

Comandos

MongoDB ofrece una extensa lista de comandos organizados en los siguientes grupos:

  • Conexiones
  • Bases de Datos
  • Gestión de Usuarios
  • Gestión de Roles
  • Colecciones
  • Cursores
  • Construcción de Objetos
  • Actualización Masiva de Datos
  • Planes de Ejecución de Consultas
  • Réplica
  • Balanceado de Carga
  • Monitorización
  • Invocación de Comandos Nativos

La lista de comandos es demasiado extensa como para examinarlos todos, por lo que es imprescindible revisar la documentación oficial que constituye la referencia más actualizada que se puede encontrar al respecto.

Como es habitual, los comandos que más se usan son los de acceso de datos, es decir, los de lectura y escritura sobre colecciones. En cualquier caso, la secuencia de creación de una base de datos y una colección es tan sencilla como la ejecución de los siguientes comandos:

La sintaxis del shell por defecto es JavaScript y resulta sencilla de entender. En las dos líneas anteriores lo único que resulta conveniente aclarar es que el objeto global db del segundo comando referencia automáticamente a la base de datos seleccionada en el primer comando. Si la base de datos no existe se instancia en el momento que se crea una colección.

CRUD

Los documentos (objetos JSON) se insertan y recuperan en la colecciones siguiendo la sintaxis de JSON:

Como se observa, MongoDB añade automáticamente a los documentos el atributo _id  con un UUID autogenerado a modo de clave primaria. Opcionalmente se puede suministrar este valor desde el cliente, pero en la mayoría de casos esta opción no se utiliza.

Donde MongoDB se sale un poco del tiesto con la nomenclatura es en las modificaciones. Por defecto la base de datos trabaja con documentos completos, no parciales, por lo que para modificar o eliminar un atributo es necesario reescribir el documento completo. Para evitar dicho trasiego de información, MongoDB permite indicar lo que denomina operadores de actualización (update operators) en los comandos de modificación de documentos:

Como se observa, los operadores empiezan por el símbolo del dolar, e indican la operación concreta a realizar sobre los atributos. $set añade un nuevo atributo al documento, $incr incrementa el valor de un atributo, y existen otros como  $unset que elimina un atributo. El mismo concepto se utiliza para realizar filtros con condiciones lógicas, aplicando operadores con nombres bastantes descriptivos como $and y $or por citar un par de ellos. Leer la documentación para hacerse una idea de las opciones disponibles es imprescindible.

El borrado de documentos es trivial utilizando un filtro:

Índices

MongoDB crea automáticamente un índice por el campo _id de todas las colecciones, y permite además definir índices sobre otros campos para mejorar el rendimiento de las consultas más habituales:

El valor 1 pasado como parámetro en la operación indica que se quiere que el orden del índice sea ascendente. Un valor -1 indicaría que se quiere que sea descendente.

En la práctica MongoDB permite crear una gran variedad de índices en función de los elementos sobre los que se definan:

  • Sobre un único campo
  • Sobre múltiples campos
  • Sobre los elementos de un campo de tipo array
  • Sobre campos de coordenadas geoespaciales
  • Sobre campos de tipo string para búsquedas de texto
  • Sobre colecciones distribuidas por varios servidores
  • Sobre un conjunto filtrado de documentos de una colección
  • Sobre documentos que tengan un determinado campo
  • Sobre documentos que expiran transcurrido un tiempo

Interesante comentar en este punto que MongoDB permite obtener el plan de ejecución de una consulta, incluyendo los índices utilizados:

Joins

Para recuperar dos documentos relacionados, que se encuentren en dos colecciones distintas, es necesario indicar el nombre de las dos colecciones y el nombre de los dos campos que las relacionan:

Como se observa, MongoDB trata esta operación como una agregación en vez de una consulta simple, y es en el parámetro $lookup donde se indican los nombres y colecciones sobre los que realizar el join, así como el nombre del objeto sobre el que proyectar el resultado.

MongoDB trata todos los joins como “left outer joins“, por lo que las consultas siempre retornan resultado incluso cuando no puede satisfacerse el join.

Agregaciones

Las agregaciones en MongoDB son un tipo de consultas que van más allá de la simple lectura de uno o varios documentos de una colección. De hecho, MongoDB las considera un framework en si mismo, una alternativa al patrón map-reduce.

Las agregaciones se construyen como una secuencia de operaciones, denominada pipeline, que se ejecuta sobre una colección. Cada una de las operaciones, denominadas stages, generan un resultado o modifican el resultado de la operación anterior.

Como se observa, el parámetro de entrada es un array de objetos (pipeline). Cada objeto es una operación (stage). Y MongoDB las ejecuta de forma secuencial, por lo que el orden es importante. Se puede empezar con una operación e ir añadiendo operaciones hasta que se obtenga el resultado deseado. El tipo de operaciones que se puede realizar es bastante extenso, por lo que es recomendable consultar la documentación de referencia para hacerse una idea de todas las opciones disponibles.

Las operaciones más comunes son $match para filtrar, $sort para ordenar, $group para agrupar, $count para contar ocurrencias, $skip para ignorar elementos, y $limit  para limitar el tamaño del resultado. Otras operaciones interesantes son por ejemplo $unwind para extraer elementos de los arrays embebidos en los documentos, $facet para calcular varias agregaciones en paralelo, y $graphLookup para hacer búsquedas recursivas.

Otros

Para terminar, comentar que MongoDB permite definir colecciones con un tamaño máximo prefijado que se caracterizan por ofrecer un rendimiento muy eficiente. Cuando el número de documentos insertados en dichas colecciones supera dicho máximo los documentos más antiguos se sobreescriben por los nuevos. Si se realiza una consulta sin especificar ningún orden se garantiza que los documentos se retornarán en el mismo orden en que se insertaron, pudiéndose indicar también que se retornen en el orden inverso. Este tipo de colecciones permiten además crear cursores sobre los últimos N elementos de los mismos, como el comando tail de UNIX, por lo que pueden utilizarse para almacenar documentos como si fueran entradas en un log.

Además, ofrece la posiblidad de definir un tiempo de expiración para los documentos, de forma que cuando se supere dicho tiempo el documento será automáticamente eliminado de la base de datos. Una característica muy popular en otra base de datos NoSQL como es Redis.

Y por supuesto, es una base de datos altamente escalable que posibilita implantarla utilizando distintas configuraciones de réplica y alta disponibilidad, incluyendo la posibilidad de distribuir colecciones sobre diversos servidores.

En definitiva, el servidor de base de datos orientado a documentos más popular hoy en día por el tiempo que lleva en el mercado y las posibilidades que ofrece.

MongoDB

Estos últimos meses están siendo muy prolíficos en cuanto a major releases se refiere. MongoDB recientemente publicó la versión 4 de su conocida base de datos NoSQL. La mayor novedad es que permite realizar operaciones sobre varios documentos dentro de una misma transacción de forma atómica. Una característica que al parecer llamó más la atención cuando se anunció que ahora que se ha liberado. No obstante, el cambio es importante, posiblemente el de mayor impacto desde que en la versión 3.2 se introdujo la posibilidad de realizar “left outer joins” entre documentos de colecciones distintas.

Decidir si MongoDB es la herramienta adecuada para un proyecto es un tema recurrente cuando se decide la arquitectura de una aplicación. A veces la tecnología viene impuesta por contrato, la mayoría de veces simplemente se utiliza lo ya que se conoce, y en algunas ocasiones se utiliza sólo para poder añadirla al portfolio de la empresa.

MongoDB es una base de datos, por lo que decidir utilizarla dependerá en buena medida del modelo de datos y los casos de uso fundamentales que se necesitan cubrir, ya que un modelo de datos no tiene porque ser visto sólo como un mero almacén de información. Las operaciones que se van a realizar sobre él son tan importantes como sus entidades y atributos. Y junto a ellos, las estimaciones de los volúmenes de información que se espera manejar de cara a decidir que gestor de base de datos utilizar.

Modelo Dinámico

MongoDB dice de si mismo que es adecuado cuando los modelos de datos no sean estáticos, cuando instancias de una misma entidad puedan tener un conjunto distinto de atributos. Un ejemplo: los productos de una gran superficie comercial. No es lo mismo una barra de pan que un calentador de agua, pero no por ello dejan de ser “productos”. El calentador tendrá marca, modelo, altura, anchura, profundidad, capacidad, y otra serie de atributos propios de acuerdo a sus especificaciones técnicas. Una barra de pan tendrá peso, tamaño, variedad, y otros atributos de acuerdo a su composición química. Es bastante probable que sus datos provengan de fuentes muy distintas y se realice un tratamiento distinto para  cada uno de ellos, pero no por eso dejan de ser productos de un mismo catálogo que sería deseable tratar de una forma conjunta.

En la práctica la mayoría de este tipo de problemas se resuelven en tiempo de diseño adoptando soluciones fáciles de implementar. Siguiendo con el ejemplo del catálogo de productos, es bastante habitual que todos los atributos específicos de un producto acaben en un campo de texto de gran tamaño a modo de descripción. Lógicamente, el mayor inconveniente de esta solución es que la información no se puede explotar de una manera sencilla al no estar estructurada. Otra solución es utilizar MongoDB.

En MongoDB las entidades se llaman documentos y se guardan en colecciones mediante transacciones ACID. Cada documento dentro de una misma colección puede tener el número de campos que se quiera, y cada campo puede ser del tipo que se quiera. Lógicamente se tiende a guardar documentos del mismo tipo en una misma colección. Así, una colección de “productos” podría contener un documento con campos que describan una barra de pan y otro que describan un calentador de agua. Es un caso de uso que MongoDB cubre de una manera natural gracias a que trabaja con un modelo de datos dinámico, frente a una base de datos relacional que trabaja con un modelo de datos predefinido.

JSON

MongoDB capturó la atención de muchos desarrolladores presentando los documentos en formato JSON. Debido a que el uso de este formato estaba universalmente extendido con el auge de JavaScript, pensar que las colecciones de documentos de MongoDB eran arrays de objetos en formato JSON facilitaba trabajar con ellas. No obstante, también llevaba a equívocos. Es un caso similar a lo que ocurría años atrás con el formato XML. En algunos modelos de datos se almacenaban datos en formato XML en columnas de tipo texto plano. El inconveniente es que todo el procesamiento de dichas columnas había que hacerlo manualmente, ya fuese en el servidor de aplicaciones tras recuperarlas de base de datos, o en procedimientos PL/SQL en el propio servidor de base datos. Algunas bases de datos relacionales reaccionaron llevando a cabo desarrollos específicos para que las columnas de las tablas pudieran almacenar y explotar información directamente en formato XML, pero al no ser parte de ningún estándar implicaba ligarse a un determinado proveedor. Con el formato JSON ocurre algo similar, algunas base de datos permiten almacenar información en formato JSON, pero en muchos casos no dejan de ser columnas en formato de texto plano optimizadas para eliminar espacios en blancos y claves duplicadas de los objetos JSON almacenados en ellas.

MongoDB no almacena los objetos en formato JSON, lo hace en un formato binario llamado BSON. Y gracias a esto MongoDB puede realizar operaciones de una forma eficiente sobre cualquier campo, de igual forma que una base de datos relacional opera con el valor de una columna de una tabla.

Joins

El gran caballo de batalla.

Tener la información totalmente estructurada pasa por tenerla totalmente normalizada. Cada cosa en su sitio, y un sitio para cada cosa. Por ejemplo, el registro de los clientes en una tienda web puede requerir una serie de campos básicos como nombre y apellidos, pero también formas de contacto como teléfonos y direcciones, que pueden ser de varios tipos, como dirección de envío o dirección de facturación, y a su vez las direcciones pueden hacer referencia a países, o localidades más concretas dentro de un país. Es decir, es posible identificar un conjunto de entidades perfectamente delimitadas con los atributos que le son propios, así como sus relaciones con otras entidades.

Sin embargo, cuanto más estructurada esté la  información, más esparcida estará por el modelo. Insertar, recuperar, actualizar o borrar una entidad completa puede suponer tener que acceder a una gran cantidad de entidades relacionadas con ella. Lógicamente, hacer una consulta por cada entidad sería muy ineficiente, por lo que el procedimiento habitual es realizar una única consulta que recupere todas las entidades de una sola vez a través de sus relaciones. Es decir, mediante lo que normalmente se denomina “hacer un join”. Cuantas más relaciones existan, más joins será necesario realizar, más compleja y costosa será la consulta, y todo ello sin tener en cuenta la relaciones opcionales, es decir, lo que normalmente se denomina “hacer un left outer join”. Otra solución es utilizar MongoDB.

En MongoDB las entidades se pueden almacenar de forma anidada unas dentro de otras. Es decir, un documento puede tener dentro otro documento, o conjunto de ellos. O dicho de otra forma, un objeto JSON puede tener un campo que a su vez sea otro objeto JSON, o un array de objetos JSON. Volviendo al ejemplo anterior, un documento de la colección “clientes” puede tener un atributo “direcciones” que sea un array. De esta forma, en una única operación, ya sea de lectura o escritura, se tiene acceso a la entidad completa. Esta es la diferencia fundamental entre una base de datos relacional y una base de datos orientada a documentos como es MongoDB.

No obstante, llegados a este punto, surgen de manera natural muchas dudas acerca de las entidades embebidas. Continuando con el ejemplo de los clientes, ¿hay que repetir el nombre de un mismo país en todas las direcciones de todos los clientes de dicho país?, ¿no se puede tener una colección de países y que las direcciones hagan referencia a ella?, ¿cómo se garantiza la integridad referencial?, … Resumiendo: ¿cómo se crean “foreign keys” y se hacen “joins” en MongoDB?

Vayamos por partes.

Cada documento en MongoDB tiene un campo de nombre “_id” que contiene un valor que identifica dicho documento de manera unívoca. Es decir, es equivalente a una “primary key” en una base de datos relacional. Por tanto, para crear una “foreign key” entre un documento A (dirección) y un documento B (país) hay que añadir un campo en el documento A (dirección) que contenga el valor del identificador de B (país). Pero teniendo siempre presente que MongoDB no garantiza la integridad referencial. Es responsabilidad de los desarrolladores. MongoDB no es una base de datos relacional, es orientada a documentos.

Por otra parte, comentar que inicialmente MongoDB no permitía recuperar varios documentos relacionados de una sola vez. Si todos los documentos tenían todos sus atributos entonces no era necesario realizar más consultas, dicha opción no tenía sentido. Si se quería obtener un documento relacionado había que hacer una segunda consulta. No obstante, con el tiempo, MongoDB añadió esta característica. Es decir, a día de hoy, cuando se hace una consulta en MongoDB, se puede hacer un “join” entre dos colecciones indicando en una cláusula el nombre de las colecciones y campos sobre los que realizar el “join”.

Pero a pesar de contar con todas estas opciones, los creadores de MongoDB defienden un modelo mixto. No ven mal el uso de “foreign keys” y “joins”, pero recomiendan copiar los atributos más habitualmente usados de los documentos referenciados en los documentos que contienen la referencia. Lo que aplicado al ejemplo de los clientes de una tienda web quiere decir que una dirección debería tener un campo con una referencia al país, pero también debería tener un campo con el nombre del país si el caso de uso más habitual es imprimir el nombre del país junto a la dirección. La redundancia de datos la justifican aduciendo que hoy en día el almacenamiento es económico y los campos habitualmente candidatos a ser redundados no suelen cambiar nunca con el tiempo.

Índices

Hubo un tiempo en que toda la información se presentaba en registros en un formato tabular. Cada valor en un columna distinta. Cada columna con un nombre. Y junto a cada nombre un botón para ordenar ascendente o descendentemente. Era un desastre.

Las base de datos son buenas almacenando información y recuperándola por clave primaria, pero penalizan mucho el rendimiento cuando se realizan consultas por cualquier campo de manera indiscriminada, sobre todo cuando el número de registros es elevado. El problema es que se les obliga a recorrer todos los registros para comprobar cuales cumplen los criterios de filtrado u ordenación de la consulta. Por ello dejar que cualquier tabla se ordenase por cualquier columna era una receta para el desastre.

La solución a este tipo de problema es obviamente cambiar la forma de presentar la información, restringir las opciones, y crear índices cuando sea preciso. Cuando se diseña un modelo de datos es importante identificar las consultas que se realizarán y planificar los índices adecuados sobre las entidades correspondientes.

MongoDB en este apartado no se diferencia del resto de base de datos. Permite crear índices sobre los campos de los documentos para agilizar las consultas. De hecho, hacer un “join” en MongoDB es inviable sin definir un índice en la colección a la que se referencia. Y penaliza de igual forma que cualquier otra base datos, con una mayor necesidad de espacio y con un mayor tiempo al insertar o actualizar.

Fragmentación

Los gestores de base de datos relacionales reservan un espacio determinado para las filas de una tabla. Espacio que normalmente se llama bloque y coincide en tamaño con el número de bytes que pueden recuperar de disco en un sólo acceso de forma óptima. Si se inserta un registro con muchas columnas a nulo queda mucho espacio que pueden aprovechar otras filas. Si posteriormente se actualiza la fila cambiando sus nulos por valores no nulos entonces la fila reclama su espacio dentro del bloque. Si no hay espacio disponible dentro del bloque la fila se reparte entre dos bloques. Acceder a un bloque implica leer de disco duro, por lo que cuanto más bloques se requiera para una fila más lento será el acceso.

MongoDB gestiona el espacio de una forma similar. Insertar un documento dentro de una colección requiere tanto espacio como campos con valores no nulos tenga. Si posteriormente se actualiza el documento con nuevos campos no nulos reclamará su espacio. Si no hay espacio disponible el documento quedará fragmentado.

Por mi experiencia, este detalle del funcionamiento interno de los gestores de base de datos no se suele tener en cuenta, pero puede afectar al rendimiento de una aplicación. La situación ideal es tener entidades que no contengan valores nulos y que una vez insertadas no se modifican. Parece un escenario utópico, pero debería ser considerado parte de los objetivos de diseño más a menudo.

Si se da la circunstancia, es bueno saber que MongoDB ofrece comandos para desfragmentar las colecciones de igual forma que las base de datos relacionales, ya que puede afectar significativamente al rendimiento según el caso de uso.

Agregaciones

Hay una tendencia a evaluar una base de datos pensado en su rendimiento de cara a proporcionar información en tiempo real a una interface de usuario. No obstante, hay casos de uso que pueden resolverse mediante ejecuciones de procesos desatendidos donde el tiempo de respuesta no es un factor crítico. En esos casos la facilidad para manipular la información debería ser el factor más importante a tener en cuenta.

Utilizar sentencias SQL para generar informes puede que no sea la mejor solución, ya que si bien es cierto que permiten extraer registros de una forma sencilla, también lo es que carecen de capacidades avanzadas para agregar y manipular dicha información de una forma sencilla. Otra solución es utilizar MongoDB.

MongoDB proporciona un API exclusivo para realizar agregaciones. Es decir, para recolectar información existente en las colecciones y generar nueva información a partir de ellas. El API es bastante versátil y permite definir una secuencia de operaciones a realizar. Las operaciones generan resultados o modifican el resultado de las operaciones anteriores. En esencia es como programar pequeños fragmentos de código, añadirlos a una lista y pedir al servidor que los ejecute sobre una colección.

Conclusiones

Desde el punto de vista de un desarrollador puro y duro, hoy en día no hay muchas diferencias entre MongoDB y una base de datos relacional. Hay “primary keys”, “foreign keys”, “joins”, índices, agrupaciones, …

Si el modelo de dominio del negocio se adecua a las características de un modelo orientado a documentos entonces se puede sacar partido del hecho de poder almacenar y recuperar toda una entidad de una sola vez. Y si no se adecua a dicho modelo entonces siempre se puede realizar un modelado de forma similar al que se haría en una base de datos relacional.

No obstante, si la única ventaja de MongoDB fuera que permite trabajar con las entidades de forma completa como objetos en formato JSON entonces habría que considerar otras soluciones. Por ejemplo, la exitosa versión 10 de PostgreSQL permite definir columnas de tipo JSONB, que es una representación de JSON en formato binario, para poder operar sobre los campos de un JSON de forma eficiente, e incluso definir índices sobre los mismos para agilizar las consultas como MongoDB.

Por su parte, la falta de integridad referencial no debería ser un factor crítico a tener en cuenta. No recuerdo haber tenido que resolver nunca un problema en producción por culpa de una “foreign key”. Ese tipo de error se detecta normalmente de forma muy temprana durante el propio desarrollo.

Otro punto importante es el rendimiento, pero los benchmarks son muy difíciles de hacer y muy fáciles de malinterpretar. Una batería de pruebas de una base de datos no puede servir para predecir como se comportarán todas las aplicaciones en producción que utilicen dicha base de datos. No importa que la base de datos sea extremadamente rápida realizando escrituras cuando el caso de uso típico de una aplicación son las lecturas. Y de igual forma, no importa que la base de datos sea capaz de realizar miles de lecturas por segundo cuando el caso de uso típico de la aplicación es de decenas de lecturas por minuto.

Hace unos pocos años hubo una presentación de un caso de éxito de MongoDB. El rendimiento era espectacular. Hasta que dijeron las especificaciones del CPD. Era tan impresionantes que es bastante posible que el sistema hubiera rendido igual de bien con una base de datos relacional.

¿Entonces?

En mi opinión, el factor clave no es el conjunto de características que MongoDB ofrece a los desarrolladores, ni que el setup sea simple, ni que existan drivers para los principales lenguajes de programación e integración con los frameworks más populares. El factor clave es la escalabilidad horizontal. MongoDB fue diseñada desde el principio con ese objetivo específico en mente. Hoy funciona para cien, mañana funciona para un millón. Las bases de datos relaciones tradicionales no escalan de igual forma que MongoDB, ni que la mayoría de las bases de datos NoSQL en general. Estas últimas han sido creadas en un momento muy distinto, con unas coyunturas y necesidades totalmente distintas.

MongoDB es una alternativa a considerar cuando se quiere un desarrollo más dinámico, donde se pueda ir construyendo y ampliando. Y sobre todo cuando se prevea un crecimiento exponencial. Las bases de datos relaciones tradicionales no suelen tener mecanismos de escalado de esas dimensiones, y en caso de ofertar algo similar es normalmente mediante soluciones parciales, como el particionado de tablas, o con sofware adicional con licencias propietarias o productos de terceros.