Si suele trabajar con bases de datos relacionales, es probable que haya desarrollado hábitos e intuiciones acerca de cómo diseñar un modelo de datos. Dadas no solo las restricciones específicas, sino también los puntos fuertes exclusivos de Azure Cosmos DB, la mayoría de estos procedimientos recomendados no se traduce bien y es posible que le lleve a soluciones que no llegan a ser óptimas. El objetivo de este artículo es guiarle por todo el proceso de modelado de un caso de uso real en Azure Cosmos DB, desde el modelado de elementos a la colocación de entidades y la creación de particiones en contenedores.
Un colaborador de la comunidad ha contribuido a este ejemplo de código y el equipo de Azure Cosmos DB no admite su mantenimiento.
Escenario
Para este ejercicio, vamos a tener en cuenta el dominio de una plataforma de blogs en las que los usuarios pueden crear publicaciones. Los usuarios también pueden indicar que dichas publicaciones les gustan y agregarles comentarios.
Sugerencia
Hemos resaltado algunas palabras en cursiva; dichas palabras identifican el tipo de "cosas" que nuestro modelo va a tener que manipular.
Incorporación de más requisitos a la especificación:
Una página frontal muestra una fuente de publicaciones recientemente creadas.
Podemos capturar todas las publicaciones de un usuario, todos los comentarios de una publicación y todos los "Me gusta" de una publicación.
Las publicaciones se devuelven con el nombre de usuario de sus autores y el número de comentarios y "Me gusta" que tienen.
Los comentarios y "Me gusta" también se devuelven con el nombre de usuario de los usuarios que los han creado.
Cuando se muestran en forma de listas, las publicaciones solo tienen que presentar un resumen truncado de su contenido.
Identificación de los patrones de acceso principales
Para empezar, proporcionamos cierta estructura a nuestra especificación inicial mediante la identificación de los patrones de acceso de nuestra solución. Al diseñar un modelo de datos para Azure Cosmos DB, es importante saber qué solicitudes tendrá que atender nuestro modelo para tener la certeza de que el modelo va a hacerlo de manera eficiente.
Para que el proceso general sea más fácil de seguir, categorizamos las diferentes solicitudes ya sea como comandos o consultas, y tomamos prestado parte del vocabulario de CQRS. En CQRS, los comandos son solicitudes de escritura (es decir, intenciones de actualizar el sistema) y las consultas son solicitudes de solo lectura.
Esta es la lista de solicitudes que expone nuestra plataforma:
[C1] Crear o editar un usuario
[Q1] Recuperar un usuario
[C2] Crear o editar una publicación
[Q2] Recuperar una publicación
[Q3] Enumerar las publicaciones de un usuario en forma abreviada
[C3] Crear un comentario
[P4] Enumerar los comentarios de una publicación
[C4] Gustar una publicación
[Q5] Enumerar los "Me gusta" de una publicación
[P6] Enumerar las x publicaciones más recientes creadas en formato abreviado (fuente)
En esta fase, aún no se ha pensado en los detalles de lo que va a contener cada entidad (usuario, publicación, etc.). Este paso suele estar entre los primeros en abordarse al diseñar en base a un almacén relacional. Comenzamos con este paso porque hay que averiguar cómo se van a traducir esas entidades en términos de tablas, columnas, claves externas, etc. Es una preocupación mucho menor con una base de datos de documentos que no aplica ningún esquema al escribir.
El motivo principal por el que es importante identificar los patrones de acceso desde el principio, es que esta lista de solicitudes va a ser nuestro conjunto de pruebas. Cada vez que iteramos el modelo de datos, pasamos por todas y cada una de las solicitudes y comprobamos su rendimiento y escalabilidad. Se calculan las unidades de solicitud consumidas en cada modelo y se optimizan. Todos estos modelos utilizan la directiva de indexación predeterminada, que se puede invalidar mediante la indexación de propiedades específicas. Esto puede mejorar aún más el consumo y la latencia de RU.
V1: una primera versión
Comenzamos con dos contenedores: users y posts.
Contenedor users
Este contenedor solo almacena elementos de usuario:
JSON
{
"id": "<user-id>",
"username": "<username>"
}
La partición de este contenedor la realizamos por id, lo que significa que cada partición lógica del contenedor solo contiene un elemento.
Contenedor posts
Este contenedor hospeda entidades como publicaciones, comentarios y "Me gusta":
La partición de este contenedor la realizamos por postId, lo que significa que cada partición lógica de dicho contenedor contiene solo una publicación, junto con todos los comentarios y "Me gusta" de la misma.
Hemos introducido una propiedad type en los elementos almacenados en este contenedor para establecer una distinción entre los tres tipos de entidades que este contenedor hospeda.
Además, hemos elegido hacer referencia a los datos relacionados, en lugar de incrustarlo (consulte esta sección para más información acerca de estos conceptos) porque:
no hay límite superior en el número de publicaciones que puede crear un usuario,
las publicaciones pueden ser arbitrariamente largas,
no límite superior con respecto al número de comentarios y "Me gusta" que puede tener una publicación
queremos poder agregar un comentario o un "Me gusta" a una publicación sin tener que actualizar la propia publicación.
¿Hasta qué punto funciona bien nuestro modelo?
Ahora es el momento de evaluar el rendimiento y la escalabilidad de nuestra primera versión. En cada una de las solicitudes que ha identificado anteriormente, medimos su latencia y el número de unidades de solicitud que consume. Esta medida se realiza en un conjunto de datos ficticio que contiene 100 000 usuarios con entre 5 y 50 publicaciones por usuario y hasta 25 comentarios y 100 "Me gusta" por publicación.
[C1] Crear o editar un usuario
Esta solicitud es fácil de implementar, ya que acabamos de crear o actualizar un elemento en el contenedor users. Las solicitudes se esparcen entre todas las particiones gracias a la clave de partición id.
Latency
Carga de unidad de solicitud
Rendimiento
7 ms
5.71 RU
✅
[Q1] Recuperar un usuario
La recuperación de los usuarios se realiza mediante la lectura del elemento correspondiente del contenedor users.
Latency
Carga de unidad de solicitud
Rendimiento
2 ms
1 RU
✅
[C2] Crear o editar una publicación
Del mismo modo que [C1] , solo tenemos que escribir en el contenedor posts.
Latency
Carga de unidad de solicitud
Rendimiento
9 ms
8.76 RU
✅
[Q2] Recuperar una publicación
Empezaremos por recuperar el documento correspondiente del contenedor posts. Pero eso no es suficiente, ya que de acuerdo con nuestra especificación, también debemos agregar el nombre de usuario del creador de la publicación, el recuento de comentarios y el recuento de Me gusta de la publicación. Las agregaciones enumeradas requieren que se emitan 3 consultas SQL más.
Cada uno de los filtros de consultas adicionales de la clave de partición de su respectivo contenedor, que es exactamente lo que deseamos para maximizar el rendimiento y la escalabilidad. Pero eventualmente tenemos que realizar cuatro operaciones para devolver una publicación individual, lo que mejoraremos en una iteración posterior.
Latency
Carga de unidad de solicitud
Rendimiento
9 ms
19.54 RU
⚠
[Q3] Enumerar las publicaciones de un usuario en forma abreviada
En primer lugar, tenemos que recuperar las publicaciones deseadas con una consulta SQL que captura las publicaciones correspondientes al usuario concreto. Pero también tenemos que emitir más consultas para agregar el nombre de usuario del creador y el número de comentarios y "Me gusta".
Esta implementación presenta muchas desventajas:
las consultas que agregan el número de comentarios y "Me gusta" deben emitirse para cada publicación que devuelve la primera consulta,
la consulta principal no se filtra en la clave de partición del contenedor posts, lo que provoca una distribución ramificada y un examen de las particiones en el contenedor.
Latency
Carga de unidad de solicitud
Rendimiento
130 ms
619.41 RU
⚠
[C3] Crear un comentario
Los comentarios se crean mediante la escritura del elemento correspondiente en el contenedor posts.
Latency
Carga de unidad de solicitud
Rendimiento
7 ms
8.57 RU
✅
[Q4] Enumerar los comentarios de una publicación
Comenzamos con una consulta que captura todos los comentarios de la publicación y, una vez más, es preciso agregar los nombres de usuario agregados por separado para cada comentario.
Aunque la consulta principal filtrar por la clave de partición del contenedor, agregar los nombres de usuario por separado penaliza el rendimiento general. Eso lo mejoraremos más adelante.
Latency
Carga de unidad de solicitud
Rendimiento
23 ms
27.72 RU
⚠
[C4] Gustar una publicación
Al igual que [C3] , creamos el elemento correspondiente en el contenedor posts.
Latency
Carga de unidad de solicitud
Rendimiento
6 ms
7.05 RU
✅
[Q5] Enumerar los "Me gusta" de una publicación
Al igual que [Q4] , se consulta los "Me gusta" para la publicación y, después, se agregan sus nombres de usuario.
Latency
Carga de unidad de solicitud
Rendimiento
59 ms
58.92 RU
⚠
[Q6] Enumerar las x publicaciones más recientes creadas en formato abreviado (fuente)
Para capturar las publicaciones más recientes, consultamos el contenedor posts ordenado por fecha de creación orden, de forma descendente, y, después, los nombres de usuario agregados y el número de comentarios y "Me gusta" de cada una de las publicaciones.
Una vez más, la consulta inicial no filtra por la clave de partición del contenedor posts, lo que desencadena una costosa distribución ramificada. Esta es incluso peor, ya que nos dirigimos a un conjunto de resultados más grande y ordenamos los resultados con una cláusula ORDER BY, lo que la hace más cara en términos de unidades de solicitud.
Latency
Carga de unidad de solicitud
Rendimiento
306 ms
2063.54 RU
⚠
Reflexión en el rendimiento de V1
Al examinar los problemas de rendimiento que nos encontramos en la sección anterior, podemos identificar dos clases principales:
algunas solicitudes requieren que se emitan varias consultas para recopilar todos los datos que hay que devolver,
algunas consultas no filtran por la clave de partición de los contenedores a los que van dirigidas, lo que da lugar a una distribución ramificada que impide la escalabilidad.
Vamos a resolver cada uno de estos problemas, empezando por el primero.
V2: presentación de la desnormalización para optimizar las consultas de lectura
El motivo por el que en algunos casos es preciso emitir más solicitudes es que los resultados de la solicitud inicial no contienen todos los datos que necesitamos devolver. La desnormalización de datos resuelve este tipo de problema en nuestro conjunto de datos al trabajar con un almacén de datos no relacional como Azure Cosmos DB.
En nuestro ejemplo, modificamos los elementos de la publicación para agregar el nombre de usuario del autor de la publicación y el número de comentarios y "Me gusta":
Desnormalización del número de comentarios y, "Me gusta"
Lo que queremos conseguir es que cada vez que agregamos un comentario o un "Me gusta", también aumentamos commentCount o likeCount en la publicación correspondiente. A medida que postId particiona nuestro contenedor posts, el nuevo elemento (comentario o "Me gusta") y su publicación correspondiente se colocan en la misma partición lógica. Como resultado, podemos usar un procedimiento almacenado para realizar dicha operación.
Cuando crea un comentario ([C3]), en lugar de simplemente agregar un nuevo elemento al contenedor posts llamamos al siguiente procedimiento almacenado de dicho contenedor:
JavaScript
functioncreateComment(postId, comment) {
var collection = getContext().getCollection();
collection.readDocument(
`${collection.getAltLink()}/docs/${postId}`,
function (err, post) {
if (err) throw err;
post.commentCount++;
collection.replaceDocument(
post._self,
post,
function (err) {
if (err) throw err;
comment.postId = postId;
collection.createDocument(
collection.getSelfLink(),
comment
);
}
);
})
}
Este procedimiento almacenado toma el identificador de la publicación y el cuerpo del nuevo comentario como parámetros y luego:
recupera la publicación
incrementa el valor de commentCount
reemplaza la publicación
agrega el nuevo comentario
Dado que los procedimientos almacenados se ejecutan como transacciones atómicas, el valor de commentCount y el número real de comentarios siempre están sincronizados.
Obviamente llamamos a un procedimiento almacenado similar al agregar nuevos "Me gusta" para incrementar likeCount.
Desnormalización de nombres de usuario
Los nombres de usuario requieren un enfoque diferente, ya que los usuarios no solo se encuentran en particiones distintas, sino también en un contenedor diferente. Cuando tenemos que desnormalizar los datos en las particiones y contenedores, podemos usar la fuente de cambios del contenedor de origen.
En nuestro ejemplo, usamos la fuente de cambios del contenedor users para reaccionar cuando los usuarios actualizan sus nombres de usuario. Cuando esto ocurre, propagamos el cambio llamando a otro procedimiento almacenado del contenedor posts:
JavaScript
functionupdateUsernames(userId, username) {
var collection = getContext().getCollection();
collection.queryDocuments(
collection.getSelfLink(),
`SELECT * FROM p WHERE p.userId = '${userId}'`,
function (err, results) {
if (err) throw err;
for (var i in results) {
var doc = results[i];
doc.userUsername = username;
collection.upsertDocument(
collection.getSelfLink(),
doc);
}
});
}
Este procedimiento almacenado toma el identificador del usuario y el nuevo nombre de usuario del usuario como parámetros y luego:
recupera todos los elementos que coinciden con userId (que puede ser publicaciones, comentarios, o "Me gusta")
en cada uno de los elementos
reemplaza el valor de userUsername
reemplaza el elemento
Importante
Esta operación es costosa porque requiere que este procedimiento almacenado se ejecute en todas las particiones del contenedor posts. Suponemos que la mayoría de los usuarios eligen un nombre de usuario adecuado en el registro y que nunca lo cambiará, por lo que esta actualización se ejecutará con muy poca frecuencia.
¿Cuáles son las mejoras de rendimiento de V2?
Hablemos de algunas de las mejoras de rendimiento de V2.
[Q2] Recuperar una publicación
Ahora que la desnormalización está en vigor, solo tenemos que capturar un elemento para controlar la solicitud.
Latency
Carga de unidad de solicitud
Rendimiento
2 ms
1 RU
✅
[Q4] Enumerar los comentarios de una publicación
Aquí podemos volver a compartir solicitudes adicionales que han capturado los el nombres de usuario y acabar con una sola consulta que filtra por la clave de partición.
Latency
Carga de unidad de solicitud
Rendimiento
4 ms
7.72 RU
✅
[Q5] Enumerar los "Me gusta" de una publicación
Exactamente la misma cuando se enumeran los "Me gusta".
Latency
Carga de unidad de solicitud
Rendimiento
4 ms
8.92 RU
✅
V3: asegurarse de que todas las solicitudes se pueden escalar
Todavía hay dos solicitudes que no hemos optimizado completamente al examinar nuestras mejoras generales de rendimiento. Estas solicitudes son [Q3] y [Q6]. Son las solicitudes que implican consultas que no filtran por la clave de partición de los contenedores a los que se dirige.
[Q3] Enumerar las publicaciones de un usuario en forma abreviada
Esta solicitud ya se beneficia de las mejoras introducidas en V2, que comparte más consultas.
Pero la consulta restante no se filtra por la clave de partición del contenedor posts.
La manera de pensar en esta situación es sencilla:
Esta solicitud tiene que filtrar por userId, ya que deseamos recuperar todas las publicaciones de un usuario en concreto.
No funciona bien porque se ejecuta en el contenedor posts, que no se particiona mediante userId.
Empezando por lo obvio, podríamos resolver nuestro problema de rendimiento mediante la ejecución de esta solicitud en un contenedor particionado con userId.
Resulta que ya tenemos ese contenedor: el contenedor users.
Por tanto, introducimos un segundo nivel de desnormalización mediante la duplicación de publicaciones completas en el contenedor users. Al hacerlo, obtenemos una copia de nuestras publicaciones, en las que solo se crean particiones en dimensiones diferentes, lo que hace que sea mucho más eficaz recuperarlas por userId.
El contenedor users ahora tiene dos tipos de elementos:
Hemos introducido un campo type en el elemento de usuario para distinguir a los usuarios de las publicaciones,
También hemos agregado un campo userId en el elemento de usuario, que es redundante con el campo id, pero es obligatorio, ya que el contenedor users ahora está particionado con userId (no id como antes)
Para lograr dicha desnormalización, usamos una vez más la fuente de cambios. Esta vez reaccionamos ante la fuente de cambios del contenedor posts para enviar cualquier publicación nueva o actualizada al contenedor users. Y como la enumeración de publicaciones no requiere devolver todo su contenido, podemos truncarlas en el proceso.
Ahora podemos enrutar nuestra consulta al contenedor users, filtrando por la clave de partición del contenedor.
Latency
Carga de unidad de solicitud
Rendimiento
4 ms
6.46 RU
✅
[Q6] Enumerar las x publicaciones más recientes creadas en formato abreviado (fuente)
Tenemos que tratar con una situación similar aquí: incluso después de compartir las consultas adicionales dejadas como innecesarias por la desnormalización introducida en V2, la consulta restante no se filtra por la clave de partición del contenedor:
Siguiendo el mismo enfoque, la maximización del rendimiento y escalabilidad de esta solicitud requiere que solo acceda a una partición. Solo se puede alcanzar una sola partición porque solo tenemos que devolver un número limitado de elementos. Con el fin de rellenar la página principal de nuestra plataforma de blogs, solo debemos obtener las cien publicaciones más recientes, sin necesidad de paginar en todo el conjunto de datos.
Por lo que para optimizar esta última solicitud, se introduce un tercer contenedor en nuestro diseño, completamente dedicado a atender esta solicitud. Desnormalizamos nuestras publicaciones en ese nuevo contenedor feed:
El campo type particiona este contenedor, que siempre es post en nuestros elementos. Eso garantiza que todos los elementos de este contenedor se encontrarán en la misma partición.
Para lograr la desnormalización, solo tenemos que enlazar a la canalización de la fuente de cambios que hemos introducido anteriormente para enviar las publicaciones a ese nuevo contenedor. Hay algo importante que se debe tener en cuenta, que necesitamos asegurarnos de que solo almacenamos las 100 publicaciones más recientes; de lo contrario, el contenido del contenedor puede crecer más allá del tamaño máximo de una partición. Esta limitación se puede implementar llamando a un desencadenador posterior cada vez que se agrega un documento en el contenedor:
Este es el cuerpo del desencadenador posterior que trunca la colección:
JavaScript
functiontruncateFeed() {
const maxDocs = 100;
var context = getContext();
var collection = context.getCollection();
collection.queryDocuments(
collection.getSelfLink(),
"SELECT VALUE COUNT(1) FROM f",
function (err, results) {
if (err) throw err;
processCountResults(results);
});
functionprocessCountResults(results) {
// + 1 because the query didn't count the newly inserted docif ((results[0] + 1) > maxDocs) {
var docsToRemove = results[0] + 1 - maxDocs;
collection.queryDocuments(
collection.getSelfLink(),
`SELECT TOP ${docsToRemove} * FROM f ORDER BY f.creationDate`,
function (err, results) {
if (err) throw err;
processDocsToRemove(results, 0);
});
}
}
functionprocessDocsToRemove(results, index) {
var doc = results[index];
if (doc) {
collection.deleteDocument(
doc._self,
function (err) {
if (err) throw err;
processDocsToRemove(results, index + 1);
});
}
}
}
El último paso es para volver a enrutar nuestra consulta a nuestro nuevo contenedor feed:
Latency
Carga de unidad de solicitud
Rendimiento
9 ms
16.97 RU
✅
Conclusión
Echemos un vistazo a las mejoras en el rendimiento y escalabilidad generales que hemos introducido en las distintas versiones de nuestro diseño.
V1
V2
V3
[C1]
7 ms / 5.71 RU
7 ms / 5.71 RU
7 ms / 5.71 RU
[Q1]
2 ms / 1 RU
2 ms / 1 RU
2 ms / 1 RU
[C2]
9 ms / 8.76 RU
9 ms / 8.76 RU
9 ms / 8.76 RU
[Q2]
9 ms / 19.54 RU
2 ms / 1 RU
2 ms / 1 RU
[Q3]
130 ms / 619.41 RU
28 ms / 201.54 RU
4 ms / 6.46 RU
[C3]
7 ms / 8.57 RU
7 ms / 15.27 RU
7 ms / 15.27 RU
[Q4]
23 ms / 27.72 RU
4 ms / 7.72 RU
4 ms / 7.72 RU
[C4]
6 ms / 7.05 RU
7 ms / 14.67 RU
7 ms / 14.67 RU
[Q5]
59 ms / 58.92 RU
4 ms / 8.92 RU
4 ms / 8.92 RU
[Q6]
306 ms / 2063.54 RU
83 ms / 532.33 RU
9 ms / 16.97 RU
Hemos optimizado un escenario en el que se realizan muchas lecturas
Es posible que haya observado que hemos concentrado nuestros esfuerzos en mejorar el rendimiento de las solicitudes de lectura (consultas) a costa de las solicitudes de escritura (comandos). En muchos casos, las operaciones de escritura desencadenan una desnormalización posteriores a través de fuentes de cambios, lo que hace que requieran más procesos computacionales y tarden más tiempo en materializarse.
Justificamos este enfoque en el rendimiento de lectura por el hecho de que una plataforma de blogs (como la mayoría de las aplicaciones sociales) es de lectura intensiva. Una carga de trabajo de lectura intensiva indica que la cantidad de solicitudes de lectura que tiene que servir suele ser órdenes de magnitudes más alta que la cantidad de solicitudes de escritura. Por consiguiente, tiene sentido realizar solicitudes de escritura cuya ejecución sea más costosas, con el fin de que las solicitudes de lectura sean mejores y más baratas.
Si examinamos la optimización más extrema que hemos realizado, [Q6] pasó de más de 2000 RU a solo 17 RU; esto lo hemos logrado mediante la desnormalización de publicaciones con un costo de aproximadamente 10 RU por elemento. Como se atenderían muchas más solicitudes de fuentes que de creación o actualizaciones de publicaciones, el costo de esta desnormalización es nimio, si se tiene en cuenta el ahorro general.
La desnormalización se puede aplicar de forma incremental
Las mejoras de escalabilidad que se han analizado en este implican la desnormalización y duplicación de datos en el conjunto de datos. Debe tenerse en cuenta que estas optimizaciones no necesariamente deben entrar en vigor el día 1. Las consultas que filtran por claves de partición funcionan mejor a gran escala, pero las consultas entre particiones pueden ser aceptables si se llama muy de vez en cuando o en un conjunto de datos limitado. Si solo está compilando un prototipo o iniciando un producto con una base de usuarios pequeña y controlada, probablemente pueda reservar esas mejoras para más adelante. Lo que es importante entonces es supervisar el rendimiento del modelo para poder decidir si es momento de implementarlas y cuándo implementarlas.
La fuente de cambios que usamos para distribuir las actualizaciones a otros contenedores almacena todas las actualizaciones sistemáticamente. Esta persistencia permite solicitar todas las actualizaciones desde la creación del contenedor y arrancar las vistas desnormalizadas como una operación de puesta al día que se realiza una sola vez, incluso si el sistema ya tiene muchos datos.
Pasos siguientes
Después de esta introducción práctica al modelado de datos y a la creación de particiones, es posible que desee consultar los artículos siguientes para revisar los conceptos que hemos tratado:
Únase a la serie de reuniones para crear soluciones de inteligencia artificial escalables basadas en casos de uso reales con compañeros desarrolladores y expertos.
Obtenga información sobre el modelado de datos en bases de datos NoSQL, las diferencias entre el modelado de datos en una base de datos relacional y una base de datos de documentos.
Aprenda a crear particiones, conozca las particiones lógicas y físicas de Azure Cosmos DB, y descubra los procedimientos recomendados para poder elegir una clave de partición y administrar particiones lógicas.
Aprenda a usar las claves de partición sintética en los contenedores de Azure Cosmos DB para distribuir los datos y la carga de trabajo de manera uniforme a través de las claves de partición.
Las vistas materializadas son contenedores de solo lectura con una copia persistente de datos de un contenedor de origen. Se pueden usar para implementar el patrón de índice secundario global en Azure Cosmos DB.
Obtenga información sobre cómo definir y usar claves únicas para una base de datos de Azure Cosmos DB. En este artículo también se describe cómo las claves únicas agregan una capa de integridad de datos.