Примечание.
Для доступа к этой странице требуется авторизация. Вы можете попробовать войти или изменить каталоги.
Для доступа к этой странице требуется авторизация. Вы можете попробовать изменить каталоги.
Эта статья основана на нескольких концепциях Azure Cosmos DB, таких как моделирование данных, секционирование и подготовленная пропускная способность , чтобы продемонстрировать, как решать реальные задачи проектирования данных.
Если обычно вы работаете с реляционными базами данных, вероятно, вы разработали привычки для разработки моделей данных. Из-за специфических ограничений, а также уникальных преимуществ Azure Cosmos DB, большинство из этих рекомендаций не работают и могут привести к неоптимальным решениям. Цель этой статьи — провести полный процесс моделирования реального варианта использования в Azure Cosmos DB, от моделирования элементов до совместного размещения сущностей и секционирования контейнеров.
Пример, демонстрирующий основные понятия в этой статье, скачайте или просмотрите созданный сообществом исходный код.
Это важно
Участник сообщества внес свой вклад в этот пример кода. Команда Azure Cosmos DB не поддерживает его сопровождение.
Сценарий
В этом упражнении мы рассмотрим домен платформы блогов, где пользователи могут создавать записи. Пользователи также могут добавлятькомментарии к этим записям.
Подсказка
Некоторые слова выделены курсивом , чтобы определить тип "вещи", которыми управляет наша модель.
Добавление дополнительных требований к нашей спецификации:
- На главной странице отображается лента недавно созданных записей.
- Мы можем получить все записи пользователя, все комментарии к публикации и все отметки 'нравится' к публикации.
- Записи возвращаются с именем пользователя их авторов и числом комментариев и лайков.
- Комментарии и отметки 'Нравится' также возвращаются с именами пользователей, создавших их.
- При отображении в виде списков записи должны представлять только усеченные версии их содержания.
Определение основных шаблонов доступа
Для начала мы предоставляем некоторую структуру нашей начальной спецификации, определяя шаблоны доступа решения. При разработке модели данных для Azure Cosmos DB важно понимать, какие запросы нашей модели должны служить, чтобы убедиться, что модель эффективно обслуживает эти запросы.
Чтобы упростить общий процесс, мы классифицируем эти различные запросы как команды или запросы, заимствуя некоторый словарь из разделения ответственности запросов команд (CQRS). В CQRS команды — это запросы на запись (то есть намерения обновить систему), а queries — это запросы только для чтения.
Ниже приведен список запросов, предоставляемых нашей платформой:
- [C1] Создание или изменение пользователя
- [Q1] Извлечение данных пользователя
- [C2] Создание или изменение записи
- [Q2] Извлечение записи
- [Q3] Вывод списка записей пользователя в короткой форме
- [C3] Создание комментария
- [Q4] Список комментариев поста
- [C4] Поставить лайк посту
- [Q5] Список отметок "нравится" для публикации
- [Q6] Создайте список из x последних записей, представленных в краткой форме (фид)
На этом этапе мы еще не задумывались о деталях того, какие данные содержатся в каждой сущности (пользователь, запись и т. д.). Этот шаг обычно является одним из первых, которые необходимо решить при проектировании для реляционного хранилища. Мы начнем с этого шага, потому что нам нужно разобраться, как эти сущности переводятся с точки зрения таблиц, столбцов, внешних ключей и т. д. Это гораздо меньше проблем с базой данных документов, которая не применяет ни одну схему при записи.
Важно определить наши шаблоны доступа с самого начала, так как этот список запросов будет нашим набором тестов. Каждый раз, когда мы итерируем модель данных, мы просматриваем каждый из запросов и проверяем его производительность и масштабируемость. Мы вычисляем единицы запроса (ЕЗ), используемые в каждой модели, и оптимизируем их. Все эти модели используют политику индексирования по умолчанию, и ее можно переопределить путем индексирования определенных свойств, что может повысить потребление запросов и задержку.
Версия 1. Первая версия
Начнем с двух контейнеров: users и posts.
Контейнер пользователей
Этот контейнер хранит только элементы пользователя:
{
"id": "<user-id>",
"username": "<username>"
}
Мы делим этот контейнер по id, что означает, что каждая логическая секция в этом контейнере содержит только один элемент.
Контейнер сообщений
В этом контейнере размещаются такие сущности, как записи, комментарии и лайки.
{
"id": "<post-id>",
"type": "post",
"postId": "<post-id>",
"userId": "<post-author-id>",
"title": "<post-title>",
"content": "<post-content>",
"creationDate": "<post-creation-date>"
}
{
"id": "<comment-id>",
"type": "comment",
"postId": "<post-id>",
"userId": "<comment-author-id>",
"content": "<comment-content>",
"creationDate": "<comment-creation-date>"
}
{
"id": "<like-id>",
"type": "like",
"postId": "<post-id>",
"userId": "<liker-id>",
"creationDate": "<like-creation-date>"
}
Мы разбиваем этот контейнер на разделыpostId, что означает, что каждый логический раздел в этом контейнере содержит одну запись, все комментарии к этой записи и все отметки «нравится» для этой записи.
Мы представили свойство type в элементах, хранящихся в этом контейнере, чтобы различать три типа сущностей, которые он размещает.
Кроме того, мы решили ссылаться на связанные данные вместо их встраивания, потому что:
- Максимальное количество записей, которые пользователь может создать, нет.
- Записи могут быть произвольно длинными.
- Нет верхнего предела на количество комментариев и лайков, которые может получить запись.
- Мы хотим иметь возможность ставить лайк или добавлять комментарий к публикации, при этом не обновляя сам пост.
Дополнительные сведения об этих понятиях см. в статье "Моделирование данных" в Azure Cosmos DB.
Насколько хорошо работает наша модель?
Теперь пришло время оценить производительность и масштабируемость нашей первой версии. Для каждого из указанных ранее запросов мы измеряем задержку и сколько единиц запросов он потребляет. Это измерение выполняется для фиктивного набора данных, содержащего 100 000 пользователей с 5 до 50 публикаций на пользователя, а также до 25 комментариев и 100 лайков на публикацию.
[C1] Создание или изменение пользователя
Этот запрос прост в реализации, так как мы просто создадим или обновляем элемент в контейнере users . Запросы равномерно распределялись по всем разделам благодаря ключу id раздела.
| Latency | Единицы запросов | Производительность |
|---|---|---|
7 мс |
5.71 RU |
✅ |
[Q1] Получение пользователя
Извлечение пользователя выполняется путем чтения элемента users из соответствующего контейнера.
| Latency | Единицы запросов | Производительность |
|---|---|---|
2 мс |
1 RU |
✅ |
[C2] Создание или изменение записи
Аналогично [C1], нам просто нужно записать в posts контейнер.
| Latency | Единицы запросов | Производительность |
|---|---|---|
9 мс |
8.76 RU |
✅ |
Восстановить запись
Начнем с получения соответствующего posts документа из контейнера. Однако это не всё, как указано в нашей спецификации, мы также должны агрегировать имя пользователя автора публикации, количество комментариев и количество лайков для публикации. Агрегаты, перечисленные в списке, требуют выдачи еще трех запросов SQL.
Каждый из запросов фильтрует по ключу раздела соответствующего контейнера, что именно и нужно для максимизации производительности и масштабируемости. Но в конечном итоге нам придется выполнить четыре операции, чтобы вернуть один пост, поэтому мы улучшаем это в следующей итерации.
| Latency | Единицы запросов | Производительность |
|---|---|---|
9 мс |
19.54 RU |
⚠ |
[Q3] Вывод списка записей пользователя в короткой форме
Во-первых, необходимо получить нужные записи с sql-запросом, который извлекает записи, соответствующие конкретному пользователю. Но мы также должны выполнять больше запросов, чтобы агрегировать имя автора и подсчитать количество комментариев и лайков.
Эта реализация представляет множество недостатков:
- Запросы, агрегирующие количество комментариев и отметок 'Нравится', должны быть выданы для каждой публикации, возвращаемой первым запросом.
- Основной запрос не фильтрует ключ раздела
postsконтейнера, что приводит к вентилированию и сканированию разделов в контейнере.
| Latency | Единицы запросов | Производительность |
|---|---|---|
130 ms |
619.41 RU |
⚠ |
[C3] Создание комментария
Комментарий создается путем записи соответствующего элемента в контейнере posts .
| Latency | Единицы запросов | Производительность |
|---|---|---|
7 мс |
8.57 RU |
✅ |
[Q4] Вывод списка комментариев публикации
Начнем с запроса, который извлекает все комментарии для этой публикации и еще раз, мы также должны агрегировать имена пользователей отдельно для каждого комментария.
Хотя основной запрос фильтрует ключ раздела контейнера, агрегирование имен пользователей отдельно негативно сказывается на общей производительности. Мы улучшили это позже.
| Latency | Единицы запросов | Производительность |
|---|---|---|
23 мс |
27.72 RU |
⚠ |
[C4] Поставьте лайк посту
Как и [C3], мы создадим соответствующий элемент в контейнере posts .
| Latency | Единицы запросов | Производительность |
|---|---|---|
6 мс |
7.05 RU |
✅ |
[Q5] Список лайков публикации
Так же, как [Q4], мы запрашиваем лайки для этой записи, а затем собираем их имена пользователей.
| Latency | Единицы запросов | Производительность |
|---|---|---|
59 мс |
58.92 RU |
⚠ |
[Q6] Вывод списка последних записей x, созданных в короткой форме (веб-канал)
Мы извлекаем последние записи, делая запрос к контейнеру posts, отсортированному по дате создания по убыванию, а затем агрегируем имена пользователей и количество комментариев и лайков для каждой записи.
Снова наш первоначальный запрос не фильтрует по ключу раздела posts контейнера, что активирует дорогостоящий развертывание. Ситуация усугубляется, так как мы нацелены на более крупный результирующий набор и сортируем результаты с использованием ORDER BY, что делает его более дорогим с точки зрения единиц запроса.
| Latency | Единицы запросов | Производительность |
|---|---|---|
306 мс |
2063.54 RU |
⚠ |
Размышление о производительности версии V1
Рассмотрим проблемы с производительностью, с которыми мы столкнулись в предыдущем разделе, мы можем определить два основных класса проблем:
- Некоторые запросы требуют выдачи нескольких запросов для сбора всех необходимых данных.
- Некоторые запросы не фильтруются по ключу раздела целевых контейнеров, что приводит к разветвлению и препятствует нашей масштабируемости.
Давайте разрешим все эти проблемы, начиная с первого.
Версия 2. Введение денормализации для оптимизации запросов на чтение
Причина, по которой мы должны выдавать больше запросов в некоторых случаях, заключается в том, что результаты первоначального запроса не содержат все данные, которые нам нужно вернуть. Денормализация данных решает эту проблему в нашем наборе данных при работе с нереляционным хранилищем данных, таким как Azure Cosmos DB.
В нашем примере мы изменим элементы публикации, чтобы добавить имя пользователя автора публикации, количество комментариев и количество нравится:
{
"id": "<post-id>",
"type": "post",
"postId": "<post-id>",
"userId": "<post-author-id>",
"userUsername": "<post-author-username>",
"title": "<post-title>",
"content": "<post-content>",
"commentCount": <count-of-comments>,
"likeCount": <count-of-likes>,
"creationDate": "<post-creation-date>"
}
Мы также изменяем элементы комментариев и лайков, чтобы добавить имя пользователя, создавшего их.
{
"id": "<comment-id>",
"type": "comment",
"postId": "<post-id>",
"userId": "<comment-author-id>",
"userUsername": "<comment-author-username>",
"content": "<comment-content>",
"creationDate": "<comment-creation-date>"
}
{
"id": "<like-id>",
"type": "like",
"postId": "<post-id>",
"userId": "<liker-id>",
"userUsername": "<liker-username>",
"creationDate": "<like-creation-date>"
}
Денормализация количества комментариев и отметок "нравится"
То, что мы хотим достичь, заключается в том, что каждый раз, когда мы добавляем комментарий или лайк, мы также увеличиваем commentCount или likeCount в соответствующей публикации. Как postId разделяет на части наш posts контейнер, новый элемент (комментарий или лайк) и соответствующая запись находятся в том же логическом разделе. В результате для выполнения этой операции можно использовать хранимую процедуру .
При создании комментария ([C3]) вместо простого добавления нового элемента в контейнер мы вызываем следующую хранимую процедуру в posts этом контейнере:
function createComment(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
);
}
);
})
}
Эта хранимая процедура принимает идентификатор записи и текст нового комментария в качестве параметров, а затем:
- извлекает публикацию.
- увеличивает значение
commentCount. - заменяет публикацию.
- добавляет новый комментарий.
Так как хранимые процедуры выполняются как атомарные транзакции, значение commentCount и фактическое количество комментариев всегда остается в синхронизации.
Мы, разумеется, вызываем аналогичную хранимую процедуру при добавлении новых отметок "нравится" для увеличения likeCount.
Денормализация имен пользователей
Имена пользователей требуют другого подхода, так как пользователи не только сидят в разных секциях, но и в другом контейнере. При денормализации данных между секциями и контейнерами можно использовать канал изменений исходного контейнера.
В нашем примере мы используем поток изменений контейнера users, чтобы реагировать, когда пользователи обновляют свои имена пользователей. В этом случае мы распространяем изменения, вызывая другую хранимую процедуру в контейнере posts :
function updateUsernames(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);
}
});
}
Эта хранимая процедура принимает идентификатор пользователя и новое имя пользователя в качестве параметров, а затем:
- извлекает все элементы, соответствующие
userId(это могут быть записи, комментарии или лайки). - для каждого из этих элементов:
- Заменяет
userUsername. - заменяет элемент.
- Заменяет
Это важно
Эта операция дорогая, так как для каждого раздела posts контейнера требуется выполнить эту хранимую процедуру. Мы предполагаем, что большинство пользователей выбирают подходящее имя пользователя во время регистрации и никогда не изменят его, поэтому это обновление выполняется очень редко.
Каковы преимущества производительности версии 2?
Давайте поговорим о некоторых достижениях производительности версии 2.
Восстановить запись
Теперь, когда денормализация завершена, нам нужно получить только один элемент для обработки этого запроса.
| Latency | Единицы запросов | Производительность |
|---|---|---|
2 мс |
1 RU |
✅ |
[Q4] Вывод списка комментариев публикации
Здесь мы можем уменьшить количество дополнительных запросов, которые извлекали имена пользователей, и в итоге использовать один запрос, который фильтруется по ключу раздела.
| Latency | Единицы запросов | Производительность |
|---|---|---|
4 мс |
7.72 RU |
✅ |
[Q5] Список лайков публикации
Точно такая же ситуация, когда перечисляются предпочтения.
| Latency | Единицы запросов | Производительность |
|---|---|---|
4 мс |
8.92 RU |
✅ |
Версия 3. Убедитесь, что все запросы масштабируются
Есть еще два запроса, которые не были полностью оптимизированы в контексте наших общих улучшений производительности. Эти запросы : [Q3] и [Q6]. Это запросы, которые не используют фильтрацию по ключу раздела целевых контейнеров.
[Q3] Вывод списка записей пользователя в короткой форме
Этот запрос уже использует улучшения, представленные в версии 2, что уменьшает количество запросов.
Но оставшийся запрос по-прежнему не фильтруется по ключу раздела контейнера posts.
Способ думать об этой ситуации прост:
- Этот запрос должен отфильтровать по
userId, поскольку мы хотим получить все публикации для конкретного пользователя. - Он не работает хорошо, так как он выполняется в
postsконтейнере, который не имеетuserIdсекционирования. - Очевидно, что мы бы решили нашу проблему производительности, выполнив этот запрос на контейнере, секционированном с
userId. - Оказывается, что у нас уже есть такой контейнер:
usersконтейнер!
Поэтому мы вводим второй уровень денормализации, копируя все записи в контейнер users. Таким образом, мы фактически получаем копию наших записей, разделенную только по другому измерению, что делает их более удобными для получения по их userId.
Теперь users контейнер содержит два типа элементов:
{
"id": "<user-id>",
"type": "user",
"userId": "<user-id>",
"username": "<username>"
}
{
"id": "<post-id>",
"type": "post",
"postId": "<post-id>",
"userId": "<post-author-id>",
"userUsername": "<post-author-username>",
"title": "<post-title>",
"content": "<post-content>",
"commentCount": <count-of-comments>,
"likeCount": <count-of-likes>,
"creationDate": "<post-creation-date>"
}
В этом примере:
- Мы ввели
typeполе в элементе пользователя, чтобы отличить пользователей от записей. - Мы также добавили
userIdполе в элемент пользователя, что избыточно по сравнению с полемid, но требуется, так какusersконтейнер теперь секционирован сuserIdи не сid, так как ранее.
Для достижения этой денормализации мы снова используем поток изменений. На этот раз мы реагируем на поток изменений контейнера posts, чтобы отправить любую новую или обновленную запись в контейнер users. Поскольку перечисление записей не требует возврата полного содержимого, мы можем сократить их в процессе.
Теперь мы можем перенаправить запрос в users контейнер, отфильтровав ключ секции контейнера.
| Latency | Единицы запросов | Производительность |
|---|---|---|
4 мс |
6.46 RU |
✅ |
[Q6] Вывод списка последних записей x, созданных в короткой форме (веб-канал)
Мы должны иметь дело с аналогичной ситуацией здесь: даже после того, как больше запросов стало ненужными из-за денормализации, введенной в версии 2, оставшийся запрос не фильтрует по ключу раздела контейнера.
Следуя тому же подходу, максимизация производительности и масштабируемости этого запроса требует, чтобы он попал только в одну секцию. Обработка только одного раздела представляется возможной, поскольку нам нужно вернуть лишь ограниченное количество элементов. Чтобы заполнить домашнюю страницу платформы блогов, нам просто нужно получить 100 последних записей без необходимости разбиения на страницы по всему набору данных.
Таким образом, чтобы оптимизировать этот последний запрос, мы введем третий контейнер в наш дизайн, полностью посвященный обслуживанию этого запроса. Мы денормализуем наши записи в этот новый feed контейнер:
{
"id": "<post-id>",
"type": "post",
"postId": "<post-id>",
"userId": "<post-author-id>",
"userUsername": "<post-author-username>",
"title": "<post-title>",
"content": "<post-content>",
"commentCount": <count-of-comments>,
"likeCount": <count-of-likes>,
"creationDate": "<post-creation-date>"
}
Поле type секционирует этот контейнер, который всегда post находится в наших элементах. Это гарантирует, что все элементы в этом контейнере будут располагаться в одном разделе.
Чтобы добиться денормализации, мы просто должны подключиться к потоку изменений, который мы ранее представили для размещения записей в этом новом контейнере. Важно помнить, что необходимо убедиться, что мы храним только последние 100 записей; в противном случае содержимое контейнера может превышать максимальный размер раздела. Это ограничение можно реализовать путем вызова post-trigger при каждом добавлении документа в контейнер:
Ниже приведен текст триггера, который усекает коллекцию:
function truncateFeed() {
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);
});
function processCountResults(results) {
// + 1 because the query didn't count the newly inserted doc
if ((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);
});
}
}
function processDocsToRemove(results, index) {
var doc = results[index];
if (doc) {
collection.deleteDocument(
doc._self,
function (err) {
if (err) throw err;
processDocsToRemove(results, index + 1);
});
}
}
}
Последний шаг — перенаправить запрос в новый feed контейнер:
| Latency | Единицы запросов | Производительность |
|---|---|---|
9 мс |
16.97 RU |
✅ |
Conclusion
Давайте рассмотрим общие улучшения производительности и масштабируемости, которые мы представили в различных версиях нашего дизайна.
| V1 | V2 | Версия 3 | |
|---|---|---|---|
| [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 |
Мы оптимизировали тяжелый для чтения сценарий
Вы можете заметить, что мы сосредоточили наши усилия на улучшении производительности запросов на чтение за счет запросов на запись. Во многих случаях операции записи теперь активируют последующую денормализацию через потоки изменений, что делает их более затратными с вычислительной точки зрения и более длительными для материализации.
Мы оправдываем акцент на производительность чтения тем, что платформа блогов, как и большинство социальных приложений, ориентирована на чтение. Рабочая нагрузка с большим объемом чтения указывает, что объем запросов на чтение, которые он должен обслуживать, обычно является порядком больше, чем количество запросов на запись. Поэтому имеет смысл сделать выполнение запросов на запись более дорогим, чтобы запросы на чтение были дешевле и работали лучше.
Если мы рассмотрим самую экстремальную оптимизацию, которую мы сделали, [Q6] снизился с 2000+ ЕЗ до всего лишь 17 ЕЗ; мы достигли этого путем денормализации записей, стоимостью около 10 ЕЗ на каждую единицу. Так как мы будем обслуживать гораздо больше запросов на ленту, чем созданий или обновлений публикаций, стоимость этой денормализации незначительна, учитывая общую экономию.
Денормализация может применяться добавочно
Улучшения масштабируемости, которые мы изучили в этой статье, включают денормализацию и дублирование данных в наборе данных. Следует отметить, что эти оптимизации не обязательно должны быть внедрены с первого дня. Запросы, которые фильтруют ключи разделов, выполняются лучше на масштабе, но запросы между разделами могут быть приемлемыми, если они вызываются редко или применяются к ограниченному набору данных. Если вы просто создаете прототип или запускаете продукт с небольшой и контролируемой пользовательской базой, вы можете, вероятно, поэкономить эти улучшения для дальнейшего использования. Важно следить за производительностью вашей модели , чтобы решить, следует ли и когда пришло время их привлечь.
Канал изменений, который мы используем для распространения обновлений на другие контейнеры, сохраняет все эти обновления постоянно. Это постоянство позволяет запрашивать все обновления со времени создания контейнера и выполнять начальную загрузку денормализованных представлений как однократную операцию синхронизации, даже если в вашей системе уже имеется много данных.
Дальнейшие шаги
После этого введения в практическое моделирование и секционирование данных вы, возможно, захотите ознакомиться со следующими статьями, чтобы пересмотреть концепции: