DynamoDB — это управляемая база данных типа NoSQL от AWS. «Управляемая» значит, что вам не нужно ставить сервер, обновлять его и думать про репликацию: AWS делает это за вас. «NoSQL» значит, что это не привычная таблица со строками и связями, где можно писать произвольные SQL-запросы, а хранилище типа «ключ-значение»: вы кладёте элемент по ключу и забираете его по тому же ключу. Главная особенность DynamoDB — он держит стабильно низкую задержку (единицы миллисекунд) хоть на тысяче записей, хоть на миллиардах. И один жёсткий предел, который определяет всю модель данных: один элемент не может быть больше 400 КБ. Всё крупное — файлы, изображения, длинные документы — уезжает в объектное хранилище, а в таблице остаётся ссылка.
Но за это приходится платить сменой привычки. В реляционной базе вы сначала проектируете таблицы «правильно» (нормализуете), а запросы пишете потом — какие угодно. В DynamoDB наоборот: сначала вы должны точно знать, какие запросы будете делать, и уже под них выбираете ключи. Если спроектировать DynamoDB «как обычные таблицы», получите либо медленные дорогие переборы всей базы, либо вообще не сможете достать нужные данные. Поэтому всё начинается с модели доступа — со списка вопросов, которые приложение задаёт к данным.
Ключ решает, куда идёт запрос: с partition key база открывает одну партицию и читает диапазон по sort key, без ключа она перебирает всю таблицу, а фильтр применяется уже к прочитанному и оплаченному.
Ключи: partition key и sort key
У каждого элемента (так в DynamoDB называют запись, аналог строки) есть первичный ключ. Он состоит из одной или двух частей.
Partition key (ключ партиции) — обязательная часть. По нему DynamoDB решает, на каком физическом «куске» хранилища (партиции) будет лежать элемент. Представьте библиотеку с множеством стеллажей: partition key — это номер стеллажа. От него зависит, насколько ровно распределится нагрузка. Плохой ключ — например, у которого мало разных значений или один встречается чаще остальных — создаёт «горячую» партицию: один стеллаж осаждён читателями, остальные пустуют, и он становится бутылочным горлышком.
У «осаждённого стеллажа» есть и точные числа: одна партиция отдаёт примерно 3000 единиц чтения и 1000 единиц записи в секунду — в них всё и упирается. Часть перекоса DynamoDB сглаживает сам (это называют адаптивной ёмкостью: горячему ключу подкидывают пропускную способность за счёт спокойных соседей), но спасает это не всегда, поэтому ключ всё равно подбирают так, чтобы значений было много и нагрузка по ним ложилась ровно.
Sort key (ключ сортировки) — необязательная вторая часть. Если partition key — это номер стеллажа, то sort key — это порядок книг на полке. Он упорядочивает элементы внутри одной партиции и позволяет делать запросы по диапазону: «все заказы пользователя за март», «всё, что начинается с ORDER#». Для этого есть операторы вроде begins_with (начинается с) и between (в диапазоне).
Главная операция чтения — Query. Она работает быстро, но только по ключу: «дай элементы с таким partition key и sort key в таком диапазоне». Всё, что не по ключу, делается через Scan — полный перебор всей таблицы. Scan дорогой и медленный, его избегают. Отсюда правило новичка: сперва выпиши все запросы приложения, потом подбери ключи так, чтобы каждый запрос попадал в Query.
Отдельно стоит сказать про FilterExpression — он есть и у Query, и у Scan, и он обманчив. Фильтр применяется уже после того, как данные прочитаны, поэтому платите вы за всё прочитанное, а не за то, что осталось после фильтра, и время тратится тоже на всё. «Я же отфильтровал» — ошибка номер один у начинающих.
aws dynamodb query \
--table-name Orders \
--key-condition-expression "pk = :u AND begins_with(sk, :prefix)" \
--expression-attribute-values '{":u":{"S":"USER#42"},":prefix":{"S":"ORDER#"}}'
Индексы: GSI и LSI
Часто нужно искать данные не только по основному ключу. Например, заказы хранятся по USER#42, но иногда надо найти заказ по его статусу. Для таких дополнительных способов поиска заводят вторичные индексы — это автоматически поддерживаемые копии данных с другим ключом.
GSI (Global Secondary Index, глобальный вторичный индекс) — отдельный индекс со своим partition key и sort key. У него своя пропускная способность, не зависящая от таблицы. GSI можно добавить в любой момент жизни таблицы — это удобно, когда появился новый сценарий поиска. Важное ограничение: чтение из GSI всегда eventually consistent (про это ниже), и это закладывают в дизайн.
LSI (Local Secondary Index, локальный вторичный индекс) — индекс с тем же partition key, что у таблицы, но другим sort key. У него три отличия от GSI, и у каждого есть последствие:
- создаётся только в момент создания таблицы, потом не добавить и не удалить — значит, решение принимается один раз, при проектировании;
- делит пропускную способность с таблицей — тяжёлые запросы по индексу замедляют саму таблицу;
- для одного значения partition key суммарный объём данных вместе с LSI ограничен 10 ГБ — «горячий» ключ упрётся в потолок.
Зато LSI умеет отдавать strongly consistent чтения.
aws dynamodb create-table \
--table-name Orders \
--attribute-definitions AttributeName=pk,AttributeType=S AttributeName=status,AttributeType=S \
--key-schema AttributeName=pk,KeyType=HASH \
--global-secondary-indexes '[{"IndexName":"by-status","KeySchema":[{"AttributeName":"status","KeyType":"HASH"}],"Projection":{"ProjectionType":"ALL"}}]' \
--billing-mode PAY_PER_REQUEST
На практике GSI — основной инструмент для дополнительных способов доступа, потому что его гибче добавить и масштабировать. Каждый индекс — это копия данных (можно выбрать, какие атрибуты в неё проецировать), за которую вы платите местом и записями. Поэтому индексы заводят строго под конкретный запрос, а не «вдруг пригодится».
Режимы оплаты: on-demand и provisioned
DynamoDB берёт деньги за пропускную способность — за чтения и записи. Есть два режима.
On-demand (по требованию, PAY_PER_REQUEST) — вы платите за каждый запрос по факту, а ёмкость подстраивается под нагрузку сама. Это самый простой старт: ничего не настраиваешь, не угадываешь объём. Подходит для нового проекта и для неровной, скачущей нагрузки. Одна оговорка про «сама»: без запинки таблица примет вдвое больше вашего прежнего пика, а рост сверх этого стоит растянуть примерно на полчаса — иначе внезапный десятикратный всплеск упрётся в отказы по превышению (throttling).
Provisioned (зарезервированный) — вы заранее задаёте количество единиц чтения и записи в секунду (можно включить авто-масштабирование, чтобы они менялись по графику). При стабильной предсказуемой нагрузке это может выйти дешевле, потому что вы платите за зарезервированное, а не за каждый запрос.
Совет «начните с on-demand, а когда нагрузка устаканится — переезжайте на provisioned ради экономии» долгие годы был стандартным, но в конце 2024 года AWS вдвое снизил цену on-demand, и разрыв сильно сократился. Сейчас on-demand — режим по умолчанию и рекомендованный, так что считайте на своих числах: экономия от provisioned стала меньше, а возни с ней больше. Подробнее про подсчёт денег в облаке — в материале про оптимизацию затрат.
Консистентность: насколько свежие данные вы видите
Консистентность — это про то, насколько свежие данные вы видите сразу после записи. В DynamoDB чтение по умолчанию eventually consistent (согласованность в конечном счёте): оно дешевле, но сразу после записи может на доли секунды вернуть чуть устаревшее значение, пока изменение разойдётся по копиям. Если нужна гарантия самых свежих данных, явно просят strongly consistent чтение — оно дороже и чуть медленнее. Запомните: чтение из GSI всегда только eventually consistent, выбора там нет.
Условная запись: как не затереть чужое
В DynamoDB нет транзакций по умолчанию, и «прочитал — изменил — записал» в двух одновременных запросах заканчивается потерей одного изменения. Механизм, который это решает, называется условной записью: операция выполняется, только если условие выполнено, и иначе отказывает.
Самый частый вид — создать, только если нет:
PutItem(item, ConditionExpression = "attribute_not_exists(pk)")
Это и есть способ получить уникальность и идемпотентность: повторная запись с тем же ключом отказывает с ошибкой условия, а не создаёт второй элемент.
Второй вид — оптимистическая блокировка по версии. В элементе держат числовой атрибут version, читают его вместе с данными и записывают с условием «версия та же, что была»:
UpdateItem(
key = {pk, sk},
UpdateExpression = "SET amount = :a, version = :new",
ConditionExpression = "version = :old"
)
Если между чтением и записью кто-то успел изменить элемент, версия не совпадёт, запись отказывает, и приложение читает свежие данные и пробует снова. Это тот же приём, что и в реляционных базах, и в библиотеках доступа к DynamoDB он обычно поддержан аннотацией на поле версии, то есть писать условие руками не надо.
Третий вид — условие по бизнес-правилу: «списать со счёта, только если остаток не меньше суммы» (ConditionExpression = "balance >= :amount"). Проверка и изменение происходят атомарно на стороне базы, и это единственный надёжный способ: та же проверка в коде приложения перед записью не защищает ни от чего.
Две детали к этому. Отказ по условию не стоит полной единицы записи, но и не бесплатен — при высокой конкуренции за один элемент повторы начинают заметно платиться, и это признак того, что ключ выбран неудачно (все запросы бьют в один раздел). И есть отдельный механизм настоящих транзакций (TransactWriteItems) для нескольких элементов сразу — он дороже вдвое и ограничен по числу элементов, поэтому его берут только там, где нескольких условных записей недостаточно.
Single-table design: приём не для старта
Single-table design — продвинутый приём, когда разные типы сущностей (например, заказы и их позиции) кладут в одну таблицу, чтобы одним запросом достать связанные данные сразу. Это мощно и экономит обращения к базе, но проектировать такое сложно. Для многих сервисов проще и понятнее держать несколько отдельных таблиц. Это осознанный выбор под нагрузку, а не обязательная практика — начинающему почти всегда стоит начать с нескольких простых таблиц.
Когда DynamoDB, а когда реляционная база
Главная развилка, которую важно проговорить — DynamoDB и реляционная база (RDS) решают разные классы задач, ни одна не «лучше».
DynamoDB хорош, когда способы доступа известны заранее и стабильны, запросы простые и идут по ключу, а масштаб большой при требовании предсказуемой задержки. Типичные примеры: пользовательские сессии, профили, история событий, корзины покупок, лента уведомлений.
RDS (PostgreSQL, MySQL и подобные) нужен, когда запросы сложные: объединения нескольких таблиц (join), агрегаты и отчёты, и ситуации, где способ доступа к данным со временем меняется и хочется гибкости SQL. Если вы пока не знаете, какие запросы понадобятся, реляционная база прощает это лучше.
Частое заблуждение — что транзакций в DynamoDB нет. Они есть: TransactWriteItems и TransactGetItems проводят до 100 элементов по принципу «всё или ничего», в том числе в разных таблицах одного аккаунта и региона. Граница проходит не по их наличию, а по рамкам и цене: сотня элементов, суммарно 4 МБ, и каждая операция внутри транзакции стоит вдвое дороже обычной. Если бизнес-операция регулярно трогает десятки сущностей сразу, в реляционной базе это будет проще и дешевле.
Простое правило: знаете запросы наперёд и их немного, нужен масштаб — берите DynamoDB и проектируйте ключи под запросы; нужна гибкость и сложные связи — берите реляционную базу.
Где это применяется
DynamoDB встречается почти в любом приложении на AWS, где есть «горячие» данные с понятным доступом по ключу и высокой нагрузкой: хранение сессий, профилей, флагов функций, корзин, лент событий. Особенно естественно он ложится в serverless-архитектуры на Lambda, где сервер не держат постоянно, а база должна масштабироваться сама и оплачиваться по факту.
Типичные ошибки начинающих:
- спроектировать таблицу «как в SQL» и потом упереться в
Scanна каждом экране; - выбрать partition key с малым числом значений и получить горячую партицию;
- добавить кучу GSI «на всякий случай» и платить за них;
- забыть, что LSI нельзя добавить после создания таблицы;
- ждать от GSI strongly consistent чтения, которого там нет;
- сразу полезть в single-table design без реальной потребности.
Глубже: ловушка проекции индексарасширенное
У индекса выбирают, какие атрибуты в него попадут — это и есть проекция, и вариантов три: только ключи (KEYS_ONLY), ключи плюс перечисленные атрибуты (INCLUDE), всё (ALL). Экономия выглядит очевидной: зачем дублировать данные, если можно проецировать ключи.
А теперь то, что обычно узнают по счёту.
В локальном индексе (LSI) запрос может попросить непроецированный атрибут — и DynamoDB сам сходит за ним в основную таблицу, по одному обращению на каждый найденный элемент. Молча, без ошибки, просто израсходовав дополнительную ёмкость чтения. Запрос на сто элементов превращается в сто дополнительных чтений; выборка, которая по расчёту стоила 10 единиц, стоит в разы больше.
В глобальном индексе (GSI) такого автоматического похода нет — и это, пожалуй, честнее: запрос просто не вернёт непроецированные атрибуты. Дальше приходится читать элементы из основной таблицы самому, и цена та же, только теперь она видна в коде.
Отсюда правило выбора проекции: в индекс проецируют ровно те атрибуты, которые нужны запросам по этому индексу. Если запросу нужны почти все — проще проецировать всё и платить за хранение (оно дешёвое), чем за дополнительные чтения (они дорогие). KEYS_ONLY оправдан, когда индекс нужен лишь для того, чтобы найти ключи, а дальше элементы всё равно читаются по одному и редко.
Проверить, не попали ли вы в эту ловушку, можно по возвращаемой ёмкости: в запросе просят вернуть потреблённую ёмкость (ReturnConsumedCapacity: INDEXES), и в ответе видно, сколько списано с индекса и сколько с таблицы. Строка с таблицей в запросе по индексу — это и есть поход за непроецированным атрибутом.
Глубже: RCU и WCU: в чём считают ёмкостьрасширенное
Слова «дороже» и «быстрее» в DynamoDB имеют точную цену, и она измеряется в двух единицах. Знать их нужно, чтобы понимать счёт и режимы оплаты.
WCU (единица записи) — одна запись элемента размером до 1 КБ в секунду. Элемент на 3 КБ стоит три единицы, элемент на 0,5 КБ — одну (меньше единицы не бывает).
RCU (единица чтения) — одно сильносогласованное чтение элемента размером до 4 КБ в секунду. И вот главная арифметика: то же чтение в обычном (согласованном в конечном счёте) режиме стоит половину единицы, то есть сильносогласованное чтение ровно вдвое дороже. Транзакционные операции дороже ещё вдвое.
Пример на ощущение масштаба: список из двадцати элементов по 2 КБ за один запрос — это 40 КБ, то есть 10 единиц при сильносогласованном чтении и 5 при обычном. Тысяча таких запросов в секунду — 10 000 или 5 000 единиц соответственно, и разница между двумя режимами становится разницей в счёте вдвое.
Отсюда практические выводы. Сильносогласованное чтение включают там, где оно действительно нужно (прочитать только что записанное), а не по умолчанию. Размер элемента влияет на цену линейно, поэтому большие атрибуты (изображения, длинные тексты) кладут в хранилище объектов, а в элементе держат ссылку. И запросы, которые читают лишние атрибуты, платят за их размер — отсюда польза проекций и выборки только нужных полей.
В режиме оплаты по факту (on-demand) те же единицы просто считаются иначе — платите за миллион запросов записи и чтения, — но соотношения сохраняются: сильносогласованное чтение вдвое дороже обычного, а размер элемента определяет число единиц.
Глубже: эксплуатационная сторонарасширенное
Кроме модели данных у DynamoDB есть набор возможностей, про которые узнают позже, чем стоило бы.
Срок жизни элемента (TTL). У таблицы указывают атрибут, в котором лежит время в секундах эпохи; когда оно наступает, DynamoDB удаляет элемент сам и бесплатно. Именно это и нужно для сессий, корзин, временных токенов и кэшей — тех сценариев, которые для DynamoDB главные. Две оговорки: удаление не мгновенное (обычно в пределах пары суток после срока), поэтому запросы всё равно должны проверять время; и удаление через TTL попадает в поток изменений (см. ниже), так что на него можно повесить обработку.
Поток изменений (Streams). Включается на таблице и отдаёт упорядоченный по разделу поток событий: что изменилось, с прежним и новым состоянием элемента (настраивается). Хранится 24 часа. Это штатный способ связать DynamoDB с остальной системой: функция подписывается на поток и обновляет поисковый индекс, отправляет уведомление, наполняет модель для чтения. Обработчик обязан быть идемпотентным: доставка «хотя бы один раз».
Восстановление на точку во времени (PITR). Включается одной настройкой и позволяет вернуться на любую секунду за последние 35 дней. Как и в управляемых реляционных базах, восстановление создаёт новую таблицу — «откатить существующую на месте» нельзя, и процедура восстановления это учитывает. Рядом есть копии по запросу (on-demand backup), которые живут, пока их не удалят.
Глобальные таблицы. Одна таблица, реплицированная в несколько регионов, с записью в любом из них. Разрешение конфликтов простое и грубое: побеждает последняя запись по времени. Это удобно для данных, где конфликты редки (профили, настройки), и опасно для счётчиков и балансов — там придётся или писать в один регион, или строить разрешение конфликтов самому.
DAX — кэш в памяти перед DynamoDB, отвечающий за микросекунды вместо единиц миллисекунд и снимающий нагрузку чтения. Берут его, когда чтений очень много и они повторяются; платят за узлы кэша круглосуточно. Для большинства сервисов обычный кэш приложения даёт то же самое дешевле.
Глубже: модель запросов изменилась: что делатьрасширенное
Статья дважды предупреждает, что переделка обходится дорого, и это правда — но выход есть, и знать его надо заранее.
Новый запрос — новый глобальный индекс. Это основной путь, и он не требует остановки: индекс создаётся на живой таблице, DynamoDB заполняет его в фоне (это называется backfill), и до окончания заполнения запросы к нему могут возвращать неполные данные. Ограничения: индексов ограниченное число на таблицу, каждый стоит места и ёмкости записи (запись в таблицу становится записью и в индекс), а состав ключей индекса потом не изменить — только удалить и создать заново.
Изменение формы элементов — версия в элементе. Добавляют атрибут schemaVersion, новые элементы пишут в новой форме, а код умеет читать обе. Дальше старые элементы переводят в новую форму постепенно: либо при следующем обращении (ленивая миграция — читаем старый, пишем новый), либо фоновым проходом по таблице. Это дешевле и безопаснее единовременной переливки.
Полная переливка — в новую таблицу. Когда меняется сам ключ раздела (а это самое частое при «модель не подошла»), индексом не спастись: нужна новая таблица с правильными ключами и перенос данных. Делают это так: включают поток изменений на старой таблице, функция пишет каждое изменение в новую таблицу, параллельно фоновым проходом переносят историю, затем переключают чтение, затем запись. Ровно та же схема, что при переезде с монолита, и работает она без простоя.
Инструменты. Массовый проход по таблице — это либо ваш код с постраничным чтением и ограничением скорости (чтобы не выесть ёмкость), либо экспорт таблицы в хранилище объектов и обработка там (экспорт не расходует ёмкость чтения — это важное свойство), либо готовый перенос между таблицами.
Практический вывод, который стоит сделать до первой таблицы: заранее решите, какие атрибуты у вас ключевые, а не только какие запросы нужны сегодня. Всё остальное потом добавляется индексами и версиями элементов, а ключ раздела — это то, что меняется только переливкой.
Коротко
- Партиция это стеллаж: partition key выбирает его, sort key упорядочивает элементы внутри; Query работает только по ключу, Scan перебирает всю таблицу, а FilterExpression не экономит ни единицы, потому что фильтрует уже прочитанное.
- Одна партиция отдаёт около 3000 единиц чтения и 1000 записи в секунду: ключ с малым числом значений даёт горячую партицию, и адаптивная ёмкость лечит это лишь частично.
- GSI это отдельный индекс со своим ключом и ёмкостью, его добавляют на живой таблице; LSI создаётся только вместе с таблицей, делит ёмкость с ней и ограничен 10 ГБ на значение ключа, зато читает сильносогласованно.
- Проекция индекса: LSI сам ходит в таблицу за непроецированным атрибутом и списывает ёмкость молча, GSI просто его не вернёт; проецируют ровно то, что нужно запросам, и проверяют по ReturnConsumedCapacity.
- WCU это запись до 1 КБ в секунду, RCU сильносогласованное чтение до 4 КБ, обычное чтение вдвое дешевле; on-demand после снижения цены стал разумным умолчанием, provisioned берут при ровной предсказуемой нагрузке.
- Чтение по умолчанию согласовано в конечном счёте; условная запись даёт уникальность, оптимистическую блокировку по версии и проверку бизнес-правила атомарно на стороне базы.
- TTL удаляет элементы сам и бесплатно, Streams хранит поток изменений 24 часа, PITR возвращает на любую секунду за 35 дней новой таблицей, глобальные таблицы решают конфликты последней записью, DAX это кэш за узлы круглосуточно.
- Новый запрос закрывают новым GSI с фоновым заполнением, новую форму элементов версией в элементе, а смену ключа раздела только переливкой в новую таблицу через поток изменений.
- DynamoDB берут, когда запросы известны наперёд, простые и по ключу, а масштаб большой; реляционную базу, когда нужны соединения, отчёты и гибкость. Транзакции есть: до 100 элементов «всё или ничего».
Что почитать дальше
- Управляемые данные в AWS — вся палитра хранилищ рядом с DynamoDB.
- Serverless в AWS — где DynamoDB используется чаще всего.
- Другие хранилища — общая теория секционирования и горячих ключей.
- Основы AWS — аккаунты, регионы и права, без которых таблицу не создать.