Cosmos DB: NoSQL с глобальной репликацией

База, которая живёт сразу в нескольких точках планеты, отвечает за миллисекунды — и жестоко наказывает за неверный ключ раздела.

Azure Cosmos DB — распределённая NoSQL-база данных с гарантиями задержки в SLA, эластичной пропускной способностью и репликацией в любое число регионов одним щелчком.

Зачем нужна ещё одна база

Azure SQL прекрасна, пока данные помещаются на один узел, а пользователи сидят в одном полушарии. Как только приложение становится глобальным, начинаются вопросы: как отдавать данные японцу и бразильцу одинаково быстро? Как пережить чёрную пятницу, когда нагрузка вырастет в 50 раз на четыре часа? Как хранить документы, у которых у каждого свой набор полей?

Cosmos DB отвечает на все три. Она:

  • реплицируется в любые регионы Azure — добавление региона это одна команда, данные приезжают сами;
  • даёт SLA на задержку: чтение точки по ключу — единицы миллисекунд в 99-м перцентиле;
  • хранит JSON-документы без жёсткой схемы;
  • масштабируется горизонтально практически без потолка.

За это она требует от вас понимания двух вещей: ключа раздела и RU/s. Не разберётесь — получите либо тормозящую базу, либо счёт, от которого испортится настроение. Разберётесь — получите инструмент, у которого мало конкурентов.

Пять API — но берите NoSQL

Cosmos DB умеет притворяться разными базами: API for NoSQL (родной, документный), MongoDB, Cassandra, Gremlin (графы) и Table. Совместимость нужна для миграций: у вас есть приложение на Mongo — переключаете строку подключения и живёте дальше. Для нового проекта всегда берите API for NoSQL: только у него полный набор возможностей и первоклассная поддержка новых фич.

Иерархия: аккаунт → база → контейнер → item

Контейнер (container) внешне похож на таблицу, но это принципиально другая сущность. Таблица живёт на одном узле. Контейнер — распределённая структура, которую Azure автоматически режет на физические разделы и раскладывает по разным машинам. Как именно резать — решаете вы, задавая ключ раздела.

Partition key: решение, которое определяет всё

Partition key (ключ раздела) — поле документа, по значению которого Cosmos DB группирует данные. Все документы с одинаковым значением ключа образуют логический раздел и всегда лежат вместе.

Три факта, из которых следует всё остальное:

  1. Логический раздел не может превышать 20 ГБ. Уперлись — запись останавливается, и починить это без пересоздания контейнера нельзя.
  2. Запрос, в котором указано значение ключа раздела, идёт в одну партицию и стоит копейки. Запрос без ключа раздела рассылается во все партиции (fan-out) — цена растёт линейно с их числом.
  3. Ключ раздела нельзя изменить после создания контейнера. Только сделать новый контейнер и перелить данные.

Хороший ключ обладает тремя свойствами: высокая кардинальность (много разных значений), равномерность (нагрузка размазана, нет одного «толстого» значения) и присутствие в большинстве запросов.

СценарийПлохой ключХороший ключ
Заказы интернет-магазина/status — значений пять, все заказы со статусом new лягут в один раздел/customerId — миллионы значений, и почти все запросы всё равно про конкретного покупателя
Телеметрия устройств/date — весь сегодняшний поток бьёт в одну партицию (hot partition)/deviceId — нагрузка равномерна по устройствам
Мультитенантный SaaS/region — тенантов тысячи, регионов три/tenantId, а для гигантских клиентов — синтетический /tenantId_month

Hot partition — главный симптом плохого ключа. Пропускная способность делится между физическими разделами поровну: если у контейнера 10 000 RU/s и 10 партиций, каждая получает по 1000 RU/s. Когда весь трафик идёт в одну — вы упираетесь в 1000 и получаете ошибки 429, хотя формально «оплатили десять тысяч». База при этом выглядит «медленной», а виноват дизайн.

Синтетический ключ — приём против переполнения 20 ГБ: вместо /tenantId пишем в документ поле tenantId_month со значением tenant-42_2026-07. Логические разделы становятся мельче, но запросы за месяц по-прежнему бьют в один.

RU/s: валюта Cosmos DB

RU (Request Unit) — универсальная единица стоимости операции, в которой смешаны CPU, память и IO. Одно чтение документа размером 1 КБ по его id и ключу раздела = 1 RU. Всё остальное измеряется относительно этого эталона.

ОперацияПорядок цены
Point read (по id + partition key), 1 КБ1 RU
Запись документа 1 КБ≈ 5 RU (и больше, если много индексов)
Запрос внутри одного разделаот единиц до десятков RU
Кросс-партишн запросдесятки–сотни RU и растёт с числом партиций

Вы покупаете пропускную способность в секунду: 400 RU/s, 4000 RU/s и так далее. Превысили — Cosmos возвращает 429 Too Many Requests и заголовок x-ms-retry-after-ms: «приходи через столько-то миллисекунд». Официальные SDK ретраят такие ответы сами, но если 429 идут потоком, приложение начинает тормозить.

Прикинем нагрузку и счёт. Цены ниже — порядок величин для одного региона записи.

reads_per_sec = 500      # чтений по ключу
writes_per_sec = 50      # записей
queries_per_sec = 10     # запросов без ключа раздела

RU_READ, RU_WRITE, RU_QUERY = 1, 5, 15

need = reads_per_sec * RU_READ + writes_per_sec * RU_WRITE + queries_per_sec * RU_QUERY
print("Нужно RU/s:", need)

provisioned = 1000                 # округляем вверх с запасом
price_per_100_ru_hour = 0.008      # доллары за 100 RU/s в час
hours_per_month = 730

cost = provisioned / 100 * price_per_100_ru_hour * hours_per_month
print(f"Provisioned {provisioned} RU/s ≈ ${cost:.2f} в месяц")

Результат:

Нужно RU/s: 900
Provisioned 1000 RU/s ≈ $58.40 в месяц

Обратите внимание: десять кросс-партишн запросов в секунду съедают столько же, сколько 150 обычных чтений. Один неудачный отчёт способен удвоить счёт.

Три режима оплаты

  • Provisioned — фиксированные RU/s, платите круглосуточно, даже если запросов ноль. Предсказуемо и дёшево при ровной нагрузке.
  • Autoscale — задаёте максимум (скажем, 4000 RU/s), Cosmos сам гуляет между 10% и 100% от него. Ставка за RU примерно в полтора раза выше, зато вы не платите за пиковый потолок в три часа ночи.
  • Serverless — платите за фактически потреблённые RU (порядка четверти доллара за миллион). Идеально для dev-стендов и редких всплесков, но есть лимиты и нет мультирегиона.

И приятная мелочь: free tier даёт первые 1000 RU/s и 25 ГБ бесплатно — одному аккаунту на подписку, навсегда. Учебный проект в него укладывается целиком.

Практика: создаём аккаунт и контейнер

az cosmosdb create \
  --name ccdemo-cosmos7 \
  --resource-group rg-cosmos-demo \
  --locations regionName=westeurope failoverPriority=0 \
  --default-consistency-level Session \
  --enable-free-tier true

# пропускная способность на уровне БАЗЫ — её делят все контейнеры
az cosmosdb sql database create \
  --account-name ccdemo-cosmos7 \
  --resource-group rg-cosmos-demo \
  --name shop \
  --throughput 400

az cosmosdb sql container create \
  --account-name ccdemo-cosmos7 \
  --resource-group rg-cosmos-demo \
  --database-name shop \
  --name orders \
  --partition-key-path "/customerId" \
  --ttl 2592000

# добавить второй регион — данные приедут сами
az cosmosdb update \
  --name ccdemo-cosmos7 \
  --resource-group rg-cosmos-demo \
  --locations regionName=westeurope failoverPriority=0 \
  --locations regionName=southeastasia failoverPriority=1

Здесь --throughput задан у базы: 400 RU/s делят между собой все её контейнеры. Альтернатива — dedicated throughput у каждого контейнера, и вот тут кроется классическая денежная ловушка (см. «Частые ошибки»). А --ttl 2592000 включает автоудаление документов через 30 дней — бесплатный способ не копить мусор.

Работа из кода

from azure.cosmos import CosmosClient
from azure.identity import DefaultAzureCredential

client = CosmosClient(
    url="https://ccdemo-cosmos7.documents.azure.com:443/",
    credential=DefaultAzureCredential(),
)
container = client.get_database_client("shop").get_container_client("orders")

# 1 RU: знаем и id, и ключ раздела — идём прямо в нужную партицию
order = container.read_item(item="order-1001", partition_key="cust-42")
print(order["total"])

# дёшево: запрос ограничен одним разделом
items = container.query_items(
    query="SELECT * FROM c WHERE c.customerId = @cid AND c.status = 'new'",
    parameters=[{"name": "@cid", "value": "cust-42"}],
    partition_key="cust-42",
)
for item in items:
    print(item["id"], item["total"])

# сколько это стоило на самом деле
charge = container.client_connection.last_response_headers["x-ms-request-charge"]
print("Потрачено RU:", charge)

# ДОРОГО: ключ раздела не указан — fan-out по всем физическим партициям
all_new = container.query_items(
    query="SELECT * FROM c WHERE c.status = 'new'",
    enable_cross_partition_query=True,
)

Возьмите за правило смотреть на заголовок x-ms-request-charge в разработке. Он превращает абстрактную «оптимизацию запросов» в конкретное число, которое можно уменьшать.

Уровни согласованности

Распределённая база всегда идёт на компромисс между свежестью данных, задержкой и стоимостью. Cosmos DB не выбирает за вас, а даёт пять уровней — это её фирменная черта.

УровеньЧто гарантируетЦена
StrongЧитатель всегда видит последнюю подтверждённую запись. Как в обычной СУБД.Чтение стоит вдвое дороже в RU; при мультирегионе растёт задержка записи.
Bounded stalenessОтставание не больше K версий или T секунд — вы сами задаёте границу.Тоже 2× RU на чтение.
Session (по умолчанию)В рамках одной сессии клиент всегда видит свои записи («читай свои записи»).1× RU. Разумный компромисс для 90% приложений.
Consistent prefixДанные могут отставать, но порядок никогда не нарушится: не увидите третью версию раньше второй.1× RU.
EventualНикаких гарантий порядка, только обещание «когда-нибудь сойдётся».Самый дешёвый и быстрый.

Практическое правило: Session — дефолт, который стоит менять осознанно. Пользователь опубликовал комментарий и сразу увидел его — этого достаточно почти всегда. Eventual берут для счётчиков лайков и лент рекомендаций, где рассинхрон на секунду никому не мешает, а экономия реальна. Strong — для остатков товара и балансов; помните, что он удваивает RU на чтение и плохо дружит с записью в несколько регионов сразу.

Как это работает

Логические разделы (все документы с одним значением ключа) Cosmos DB раскладывает по физическим разделам — это уже реальные машины с диском, примерно по 50 ГБ и 10 000 RU/s каждая. Растут данные или пропускная способность — Azure молча добавляет физические разделы и перераспределяет логические. Вы этого не видите, но именно поэтому важна равномерность ключа.

Каждый физический раздел — это набор из четырёх реплик внутри региона. Запись подтверждается, когда её приняло большинство: отсюда и живучесть, и гарантия, что чтение из кворума видит свежие данные.

Индексируется по умолчанию каждое поле каждого документа. Это удобно (любой запрос будет работать) и дорого (каждая запись обновляет все индексы). На нагрузке «много пишем, редко ищем» первым делом урезают политику индексирования:

{
  "indexingMode": "consistent",
  "includedPaths": [
    { "path": "/customerId/?" },
    { "path": "/status/?" }
  ],
  "excludedPaths": [
    { "path": "/*" }
  ]
}

Так индексируются только два поля, по которым вы реально фильтруете, а стоимость записи падает в разы.

Ещё одна встроенная суперсила — change feed: упорядоченный поток всех изменений контейнера. Его читает Azure Functions и превращает в аналитику, поисковый индекс или уведомления, не нагружая основную базу.

Частые ошибки

  • Ключ раздела «на потом». Взяли /id, потому что «уникально». Формально верно — но тогда каждый документ в своём разделе, а любой запрос по любому другому полю становится кросс-партишн. Исправить можно только пересозданием контейнера.
  • Hot partition. Ключ вроде /date или /country для России-центричного сервиса: 90% трафика в одну партицию, ошибки 429 при formально огромной пропускной способности.
  • Dedicated throughput на каждом контейнере. Минимум для контейнера — 400 RU/s. Завели 10 микроконтейнеров — платите за 4000 RU/s, хотя нагрузки почти нет. Лечится общей пропускной способностью на уровне базы.
  • «Мы же ничего не запрашивали». Provisioned-режим списывает деньги каждый час независимо от активности. Забытый прототип с 10 000 RU/s тихо съедает сотни долларов в месяц. Для песочниц — Serverless или free tier.
  • Мультирегион «на всякий случай». Каждый добавленный регион умножает стоимость: два региона — примерно двойной счёт. Multi-region writes ещё дороже. Включайте, когда есть пользователи, а не когда красиво выглядит на схеме.
  • Strong-консистентность по умолчанию. «Пусть будет надёжнее» удваивает RU на каждом чтении и убивает выигрыш от глобальной репликации.
  • Кросс-партишн отчёты на боевом контейнере. Один аналитический SELECT по всей базе способен выесть всю пропускную способность и уронить пользовательский трафик в 429. Для аналитики есть change feed и Synapse Link.

Итоги

  • Cosmos DB — документная NoSQL-база с глобальной репликацией и SLA на задержку; для новых проектов берите API for NoSQL.
  • Partition key — самое важное решение: высокая кардинальность, равномерность, участие в большинстве запросов. Изменить его после создания контейнера нельзя.
  • Логический раздел ограничен 20 ГБ; неравномерный ключ создаёт hot partition и ошибки 429.
  • RU/s — валюта: point read 1 КБ = 1 RU, запись ≈ 5 RU, кросс-партишн запрос — десятки и сотни.
  • Режимы оплаты: Provisioned (ровно), Autoscale (пики), Serverless (редко и мало). Free tier — 1000 RU/s и 25 ГБ.
  • Пять уровней согласованности; Session — разумный дефолт, Strong удваивает стоимость чтения.
  • Индексируется всё подряд — на write-heavy нагрузке урезайте indexing policy.
Проверьте себя
1. Контейнер с телеметрией использует partition key /date. Устройства пишут круглосуточно. Что произойдёт?
AНичего плохого: дат много, значит кардинальность высокая
BВесь сегодняшний трафик пойдёт в один логический раздел — возникнет hot partition, появятся ошибки 429, а раздел упрётся в лимит 20 ГБ
CCosmos DB автоматически перераспределит нагрузку и сама сменит ключ раздела
DЗапись будет работать нормально, но чтение станет невозможным
2. Что означает точечное чтение документа размером 1 КБ по его id и ключу раздела?
AОно бесплатно и не тарифицируется
BОно стоит примерно 5 RU, как и запись
CОно стоит 1 RU — это эталонная операция, относительно которой измеряются остальные
DСтоимость зависит от выбранного уровня согласованности и всегда равна 2 RU