Моделювання від патернів доступу: DynamoDB
Навіщо це
Section titled “Навіщо це”Маркетингу «Крамниці» потрібен звіт: усі замовлення з промокодом NY20 за грудень. У PostgreSQL це один запит, а якщо він повільний, то ще один CREATE INDEX. У DynamoDB Local на тих самих даних (розмір small, див. Датасет) відповідь теж є, але база читає всю таблицю, усі 76 617 items, щоб повернути 502. Запит «замовлення покупця», під який таблицю проєктували, обходиться майже в чотири тисячі разів дешевше. На справжньому сервісі за кожне прочитане платять, а таблиця з мільярдом items читалася б годинами.
Системи не гірші чи кращі, вони проєктуються з різного кінця. PostgreSQL проєктують від даних, і він відповідає на будь-яке питання про них, дешево чи дорого залежно від індексів. DynamoDB проєктують від запитів: перелік патернів доступу (access pattern) визначає ключі, і саме ключі гарантують швидкість. Далі видно, як з переліку виходить модель, чим вона платить і що робити з шостим запитом, якого в переліку не було.
Передумови. Проєктування від даних і unit_price як історичний факт: модуль 5. Гарячий ключ: модуль 13. Транзакції й ізоляція в PostgreSQL: модуль 10. Тарифікація DynamoDB й інших керованих баз: модуль 7 хмарного курсу.
Item і ключ
Section titled “Item і ключ”Таблиця DynamoDB складається з items: кожен item це набір атрибутів до 400 КБ з первинним ключем. Items однієї таблиці можуть мати різні набори атрибутів: строгої схеми, крім типів ключів, немає. Це key-value сховище з модуля 14, де items ще й читають діапазоном.
Первинний ключ складається з ключа партиції (partition key) і, за бажанням, ключа сортування (sort key). Ключ партиції хешується, і за хешем база вибирає партицію з item. Ключ сортування впорядковує items усередині неї. Items з однаковим ключем партиції утворюють item collection.
Звідси два способи читати. GetItem бере один item за повним ключем. Query бере з однієї item collection діапазон за умовою на ключ сортування (=, <, BETWEEN, begins_with) у прямому чи зворотному порядку. Рівність на ключ партиції в Query обов’язкова, без неї база відмовляє:
An error occurred (ValidationException) when calling the Query operation: Query condition missed key schema elementУсе, що читає без ключа партиції, це Scan: він проходить усю таблицю, а FilterExpression відсіює вже прочитане. За документацією, Scan споживає однаково з фільтром і без, а одна сторінка результату не перевищує 1 МБ.
Роботу база міряє в одиницях. Одиниця читання (RCU) дорівнює одному strongly consistent читанню item до 4 КБ на секунду (воно бачить усі підтверджені записи) або двом eventually consistent (репліка може ще не знати про останній запис). Одиниця запису (WCU) дорівнює одному запису до 1 КБ на секунду. Типове читання eventually consistent, тому GetItem і Query коштують 0.5.
Від переліку запитів до ключів
Section titled “Від переліку запитів до ключів”У модулі 5 схема йшла від даних: які сутності, які залежності, що має лежати в одному місці. Запити були наслідком, і під повільні додавали індекси. Тут порядок зворотний: модель починають із переліку питань, які застосунок ставитиме базі. Для «Крамниці»:
| № | Патерн доступу | Як читається |
|---|---|---|
| 1 | картка товару | GetItem за товаром |
| 2 | відгуки товару, новіші першими | Query за товаром, сортування за часом у зворотному порядку |
| 3 | замовлення покупця за датою | Query за покупцем, сортування за датою |
| 4 | позиції замовлення | Query за замовленням |
| 5 | замовлення пункту видачі за статусом | Query за пунктом, умова на початок статусу |
Кожен рядок означає «і тільки так». Питання поза переліком модель обслуговує дорого або ніяк, тож перелік складають повним, із частотою й обсягом результату, до першого CreateTable.
Single-table design
Section titled “Single-table design”Реляційна звичка кладе кожну сутність у свою таблицю й з’єднує їх. У DynamoDB з’єднань немає: сторінка замовлення з позиціями була б двома запитами до двох таблиць. Single-table design кладе різні сутності в одну таблицю й вибирає ключі так, щоб те, що читається разом, лежало в одній item collection.
Ключові атрибути називають загально, PK і SK, бо в них лежать значення різних сутностей. Таблиця shop, яку завантажує labs/m15-dynamodb/load.mjs:
| Сутність | PK |
SK |
Патерн |
|---|---|---|---|
| товар | PROD#<id> |
META |
1 |
| залишок | PROD#<id> |
STOCK |
транзакція |
| відгук | PROD#<id> |
REVIEW#<час>#<id> |
2 |
| покупець | CUST#<id> |
META |
профіль |
| замовлення | ORDER#<id> |
META |
4, через індекси 3 і 5 |
| позиція | ORDER#<id> |
ITEM#<рядок> |
4 |
Патерн 2 читає колекцію PROD#2410 умовою begins_with(SK, 'REVIEW#') у зворотному порядку. Час у ключі має вигляд 2025-12-31T23:59:00Z: рядки в UTC одного формату сортуються лексикографічно так, як хронологічно. Номер відгуку в кінці розрізняє відгуки з однаковою хвилиною (у товару 2410 таких вісім). Патерн 4 це один Query за ORDER#57: item META і items ITEM#01, ITEM#02.
Single-table не догма. Документація AWS описує обидва підходи: single-table годиться, коли запити часто читають кілька сутностей разом, а таблиця на сутність цілком коректна, коли таких запитів немає. Ціна: різні типи ділять резервні копії, шифрування й потік змін, а код розрізняє типи items за префіксом.
Копії замість з’єднань
Section titled “Копії замість з’єднань”Item ITEM#01 містить product_title, копію назви товару. З модуля 5 для копій лишається той самий тест: якщо змінити джерело, чи мала б змінитись копія? Давнє замовлення показують із назвою на момент купівлі, як unit_price у order_items, тож це факт історії, а не надлишок. Інакше з author_name у відгуку: копія імені з профілю, взята, щоб не читати профіль на кожен відгук. Покупець змінив ім’я, відгуки показують старе: прийняти це чи оновлювати всі відгуки автора, вирішує застосунок, бо база за копіями не стежить.
Вторинні індекси
Section titled “Вторинні індекси”Патерни 3 і 5 не збігаються з основним ключем: замовлення лежать під ORDER#id, а питають про них за покупцем і за пунктом видачі. Для цього є глобальний вторинний індекс (GSI, global secondary index): копія items, організована за іншим ключем. Ключові атрибути індексу, GSI1PK і GSI1SK, є звичайними атрибутами item, і застосунок заповнює їх сам. Item без них в індекс не потрапляє.
Індекс перевертає ключ: поле item стає ключем групування. Один індекс може обслуговувати кілька сутностей, якщо їхні ключі розрізняються префіксом. Це index overloading. У shop GSI1 віддає замовлення покупця (CUST#… → ORDER#<час>) і товари продавця (SELLER#… → PROD#<id>). GSI2 віддає чергу пункту видачі: GSI2PK = PICKUP#<id>, GSI2SK = <статус>#<час>, і «відправлені» це begins_with(GSI2SK, 'shipped#').
Локальний вторинний індекс (LSI) має ключ партиції таблиці й інший ключ сортування. Він приймає strongly consistent читання, але створюється лише разом із таблицею, їх не більше п’яти, а item collection у таблиці з LSI не може перевищувати 10 ГБ. Для GSI й таблиць без LSI такої межі немає, тож на практиці беруть GSI.
За індекс платять записом і консистентністю. Згідно з документацією, зміна значення, яке є ключем індексу, коштує двох записів в індекс (видалити старий запис, додати новий): перехід замовлення з confirmed у shipped переписує його позицію в GSI2. А GSI оновлюється асинхронно, з eventual consistency (модуль 13): читання з нього може ще мить не бачити щойно записаного. Strongly consistent читання з GSI неможливе взагалі:
An error occurred (ValidationException) when calling the Query operation: Consistent reads are not supported on global secondary indexesДля сторінки «мої замовлення» це припустимо, а екран «перевірте, чи збереглось» читає основну таблицю за ключем.
Транзакції
Section titled “Транзакції”До цього кожен запис змінював один item. Оформлення замовлення змінює три: створює ORDER#…/META і ORDER#…/ITEM#01, зменшує PROD#…/STOCK, і залишок не повинен піти нижче нуля. PutItem й UpdateItem атомарні лише над одним item, тож для трьох разом є TransactWriteItems: до 100 дій, сукупно до 4 МБ, усі або жодна, в одному регіоні й обліковому записі. Дві дії над одним item заборонені. Кожна дія має ConditionExpression: на залишку це «є що продавати» (quantity >= :q), на замовленні захист від повтору (attribute_not_exists(PK)).
docker compose run --rm aws dynamodb transact-write-items --cli-input-json file:///work/transact-order.jsonПерший виклик проходить. Той самий удруге скасовано, бо замовлення вже є; помилка каже, яка дія скасувала:
Transaction cancelled, please refer cancellation reasons for specific reasons [ConditionalCheckFailed, None, None]Залишок товару 2410 після двох викликів зменшився на одиницю, а не на дві. Замовлення 500 одиниць при залишку 181 скасовує транзакцію третьою дією, [None, None, ConditionalCheckFailed], і items замовлення немає.
За документацією, база виконує дві операції на кожен item транзакції, підготовку й коміт, тож транзакція коштує вдвічі більше, навіть скасована. Ізоляція SERIALIZABLE діє між транзакцією та окремими GetItem, PutItem, UpdateItem, а Query, Scan і BatchGetItem бачать лише закомічені дані (read committed). Зміни з транзакції доходять до GSI поступово. BatchWriteItem (до 25 записів) атомарним не є: частина може повернутись у UnprocessedItems. Порівняно з модулем 10 модель вузька: немає довільних блокувань і SELECT … FOR UPDATE, конфлікт двох транзакцій на одному item скасовує одну, і застосунок повторює.
Гарячі партиції
Section titled “Гарячі партиції”Партиція має скінченну пропускну здатність: за документацією, до 3000 одиниць читання й 1000 одиниць запису в секунду. Закон Ципфа в view_events перетворює це на задачу: найгарячіший товар 2410 має 3635 із 60 718 подій (на small), 5.99 %. Якби кожен перегляд збільшував лічильник в item PROD#2410, цей item отримував би 5.99 % усіх записів сайту, і межа 1000 записів на секунду настала б при загальному потоці близько 1000 / 0.0599 ≈ 16 700 подій на секунду. На small це 0.002 події на секунду, тож датасет межі не показує: розрахунок потрібен, щоб знати, при якому потоці модель зламається.
Adaptive capacity вмикається автоматично: за документацією, DynamoDB перерозподіляє ємність до партицій із більшим трафіком і може винести часто читаний item на окрему партицію, давши йому повні 3000 / 1000. Стелі однієї партиції це не піднімає, а item collection вона не розщеплює, якщо в таблиці є LSI. Тому гарячий лічильник розщеплюють самі: пишуть у PROD#2410#0 … PROD#2410#k-1 за випадковим номером, а читаючи, сумують, як при розщепленні гарячого ключа в модулі 13. Читання гарячої картки знімає кеш (модуль 14).
DynamoDB Local партицій не має, тож ні гарячої партиції, ні adaptive capacity, ні тротлінгу на ньому не відтворити.
Режими ємності
Section titled “Режими ємності”Таблиця працює в режимі за запит (on-demand), де платять за кожну операцію, або замовленої ємності (provisioned), де задають одиниці на секунду, платять за них постійно й можуть налаштувати автомасштабування. Ціни, пороги окупності й правила перемикання розібрано в модулі 7 хмарного курсу, тут їх не повторюємо. Для моделювання важливо інше: режим не скасовує стелі партиції, а індекс споживає ємність окремо. У замовленому режимі кожен GSI має власні одиниці, і якщо їх замало, тротляться записи в таблицю.
Як це насправді
Section titled “Як це насправді”Середовище: DynamoDB Local (amazon/dynamodb-local:3.3.1) і AWS CLI (amazon/aws-cli:2.37.7) у контейнерах, без облікового запису AWS. Окремий compose-файл лежить у labs/m15-dynamodb/: клієнт AWS CLI нікому більше не потрібен.
cd labs/m15-dynamodbdocker compose up -d --wait./load.sh # таблиця shop із dataset/small, близько трьох хвилинdocker compose run --rm aws dynamodb list-tablesЗавантажувач вантажить товари, залишки, покупців, відгуки, замовлення й позиції: 76 617 items. Платежі, відправлення й події не потрібні. Він пише напряму по HTTP: запуск aws у контейнері коштує близько двох секунд, а BatchWriteItem бере лише 25 items. Запити лежать у labs/m15-dynamodb/queries/ як JSON і викликаються через --cli-input-json file:///work/<файл>; з ReturnConsumedCapacity відповідь показує витрату.
Патерн 3, три найновіші замовлення покупця 1458 (GSI1, зворотний порядок):
placed_at=2025-12-27T19:32:00Z total_amount=208 order_id=21746 status=deliveredplaced_at=2025-12-20T10:35:24Z total_amount=149 order_id=20897 status=returnedplaced_at=2025-12-19T11:19:36Z total_amount=1547.8 order_id=20768 status=deliveredConsumedCapacity: 0.5 (GSI1)Патерн 4, сторінка замовлення 57: один Query дає ITEM#01 («Піжама Pro 23, чорна», 230), ITEM#02 («Настільна лампа Zatyshok Family», 289) і META (статус cancelled, сума 467.1) за 0.5 одиниці. Патерн 5, черга пункту видачі 1308: три відправлені замовлення, 0.5 одиниці через GSI2.
Тепер питання, якого модель не передбачала. AWS CLI в ConsumedCapacity показує лише останню сторінку Scan, тож scan-cost.mjs сумує по всіх:
./scan-cost.sh --promo NY20./scan-cost.sh --promo NY20 --consistentсторінок 16, прочитано items 76617, повернуто 502, ConsumedCapacity 1976.5 RCU, 5.5 ссторінок 16, прочитано items 76617, повернуто 502, ConsumedCapacity 3953 RCU, 5.2 с| Операція | Прочитано items | RCU |
|---|---|---|
GetItem картки товару |
1 | 0.5 |
Query за ORDER#57 |
3 | 0.5 |
Query до GSI1, три замовлення покупця |
3 | 0.5 |
Scan із фільтром promo_code = NY20 |
76 617 | 1976.5 |
той самий Scan з ConsistentRead |
76 617 | 3953 |
Фільтр відсіяв 76 115 items, а за них заплачено. Local повертає ConsumedCapacity, тож порядки величин видно, але прогнозом рахунку ці числа вважати не слід.
Новий запит, якого не передбачили
Section titled “Новий запит, якого не передбачили”На «замовлення з NY20 за грудень» є три відповіді.
Scan. Прочитати все й відфільтрувати: 1976.5 одиниці, п’ять секунд на Local, і витрата росте з таблицею. Для разового звіту годиться, для сторінки застосунку ні.
GSI із backfill. Індекс додають до наявної таблиці без зупинки. Тут пощастило: promo_code і placed_at уже є атрибутами замовлень, тож ключем індексу вони стають без переписування items.
docker compose run --rm aws dynamodb update-table --cli-input-json file:///work/add-gsi-promo.jsondocker compose run --rm aws dynamodb query --cli-input-json file:///work/promo-month.jsonІндекс спершу CREATING, потім ACTIVE; на Local це секунди. База проходить таблицю й заповнює індекс (це і є backfill), і до ACTIVE запитів до нього немає. Індекс розріджений (sparse): в нього потрапляють лише items з обома ключовими атрибутами, тут 3695 замовлень із промокодом із 76 617 items. Запит за NY20 у грудні повертає 502 items за 16.5 одиниці проти 1976.5 у Scan. На справжньому сервісі, за документацією, поки індекс будується, записи в таблицю можуть тротлитись, на дуже великих таблицях може знадобитись попередній дозвіл від підтримки AWS, а замовлена ємність індексу час побудови не скорочує.
Переписати items. Якщо ключа нового індексу в items немає (скажімо, потрібен складений REGION#…, якого ніхто не зберігав), треба пройти таблицю Scanом і дописати атрибут UpdateItemом у кожне замовлення, 22 030 записів, і лише потім створювати індекс.
У PostgreSQL порівнянного кроку немає:
Без індексу PostgreSQL 18.6 у Docker (small) читає таблицю послідовно:
Seq Scan on orders (actual time=0.871..1.016 rows=502.00 loops=1) Rows Removed by Filter: 21528 Buffers: shared hit=272Execution Time: 1.052 msCREATE INDEX orders_promo_placed_idx ON orders (promo_code, placed_at);SELECT order_id, customer_id, placed_at, total_amountFROM ordersWHERE promo_code = 'NY20' AND placed_at >= '2025-12-01' AND placed_at < '2026-01-01';Bitmap Heap Scan on orders (actual time=0.051..0.119 rows=502.00 loops=1) Buffers: shared hit=28 read=4Execution Time: 0.142 msCREATE INDEX на 22 030 рядків зайняв 14 мс і не змінив жодного запиту застосунку. На таких обсягах 1 мс проти 0.1 мс непомітні, на full різниця росте разом із таблицею, як у модулі 8. Відповідь на непередбачене питання в PostgreSQL є завжди, дешева чи дорога залежно від індексу. У DynamoDB без підготовленого індексу вона коштує пропорційно розміру всієї таблиці, а готувати індекс наперед можна лише тоді, коли про запит знаєш. Тому модель DynamoDB живе разом із процесом: нове питання бізнесу означає нову ітерацію переліку патернів, іноді міграцію items і завжди вибір між окремим індексом і Scan раз на тиждень.
Що Local не відтворює
Section titled “Що Local не відтворює”Документація AWS називає DynamoDB Local засобом розробки й тестування. Важливі відмінності:
| Що | Local | Справжній сервіс |
|---|---|---|
| Замовлена пропускна здатність | ігнорується, приймає будь-які числа | обмежує й тротлить |
| Партиції | немає | гаряча партиція, 3000 / 1000 на партицію, adaptive capacity |
| Ціна | BillingModeSummary завжди null, нічого не нараховується |
за запити або за замовлену ємність |
| Консистентність | читання eventually consistent, але на швидкому Local майже завжди виглядають strongly consistent | реальна затримка в індексах |
| Створення індексу | CREATING → ACTIVE за секунди (-delayTransientStatuses уповільнює) |
залежить від розміру таблиці |
| Конфлікти транзакцій | TransactionConflictException не виникає |
виникає |
| Розмір item collection | не відстежується | повертається в ReturnItemCollectionMetrics |
| Ліміт 1 МБ при запиті до індексу | рахує весь item | лише проєктовані атрибути |
Тож на Local перевіряють коректність моделі: що запити повертають і чи відмовляє транзакція за умовою. Ємність, гарячі партиції й поведінку під навантаженням на ньому не перевірити.
Типові помилки розуміння
Section titled “Типові помилки розуміння”«Таблицю спершу, запити потім». Ключі таблиці визначають, що можна читати швидко, і змінити їх не можна: інший ключ означає нову таблицю й перенесення даних. Перелік запитів складають до першого CreateTable.
«Scan із фільтром майже як WHERE». Фільтр відсіює вже прочитане, і за прочитане платять: тут 76 617 items прочитано, 502 повернуто. У PostgreSQL WHERE без індексу теж читає таблицю, але індекс додається однією командою без підготовки ключів.
«Індекс гарантує те саме, що таблиця». GSI оновлюється асинхронно, і strongly consistent читання з нього неможливе. Екран, який мусить побачити щойно записане, читає таблицю за ключем.
«Adaptive capacity розв’яже гарячий ключ». Вона перерозподіляє трафік і може виділити окрему партицію гарячому item, але стеля партиції лишається: 1000 записів на секунду. Понад це ключ розщеплюють.
«Single-table design завжди краще». Він виграє, коли запити читають кілька сутностей разом. Якщо таких запитів немає, таблиця на сутність простіша й дає окремі резервні копії та шифрування.
Перевір себе
Лабораторна
Section titled “Лабораторна”L9. Одна задача в трьох моделях: перелік патернів доступу, змодельований у PostgreSQL, MongoDB і Scylla, з новою вимогою посеред роботи. Логіка «від запитів» із цього модуля стає там таблицею на запит у Scylla (модуль 17) і вибором між вкладенням і посиланням у MongoDB (модуль 16).
Джерела
Section titled “Джерела”- Amazon DynamoDB Developer Guide: Partition key design, Burst and adaptive capacity, Data modeling foundations, Global secondary indexes, Managing global secondary indexes, Local secondary indexes, Scanning tables, Transactions, DynamoDB local usage notes.
- Amazon DynamoDB API Reference: TransactWriteItems, BatchWriteItem.
- A. DeBrie, The DynamoDB Book, 2020.
- Курс хмар, модуль 7 «Керовані бази даних».