Перейти до вмісту

Моделювання від патернів доступу: DynamoDB

Маркетингу «Крамниці» потрібен звіт: усі замовлення з промокодом NY20 за грудень. У PostgreSQL це один запит, а якщо він повільний, то ще один CREATE INDEX. У DynamoDB Local на тих самих даних (розмір small, див. Датасет) відповідь теж є, але база читає всю таблицю, усі 76 617 items, щоб повернути 502. Запит «замовлення покупця», під який таблицю проєктували, обходиться майже в чотири тисячі разів дешевше. На справжньому сервісі за кожне прочитане платять, а таблиця з мільярдом items читалася б годинами.

Системи не гірші чи кращі, вони проєктуються з різного кінця. PostgreSQL проєктують від даних, і він відповідає на будь-яке питання про них, дешево чи дорого залежно від індексів. DynamoDB проєктують від запитів: перелік патернів доступу (access pattern) визначає ключі, і саме ключі гарантують швидкість. Далі видно, як з переліку виходить модель, чим вона платить і що робити з шостим запитом, якого в переліку не було.

Передумови. Проєктування від даних і unit_price як історичний факт: модуль 5. Гарячий ключ: модуль 13. Транзакції й ізоляція в PostgreSQL: модуль 10. Тарифікація DynamoDB й інших керованих баз: модуль 7 хмарного курсу.

Таблиця 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.

Реляційна звичка кладе кожну сутність у свою таблицю й з’єднує їх. У DynamoDB з’єднань немає: сторінка замовлення з позиціями була б двома запитами до двох таблиць. Single-table design кладе різні сутності в одну таблицю й вибирає ключі так, щоб те, що читається разом, лежало в одній item collection.

Таблиця shop: items різних типів під одним ключем партиції. Одним запитом за PK виходять картка товару, залишок і відгуки, або замовлення з позиціямипартиція PROD#2410: один Query за PK читає все, що про товар відомоPKSKінші атрибутитоварPROD#2410METAtitle «Настільна лампа Pryhoda Urban,синя», price 499.00, seller_id 34залишокPROD#2410STOCKquantity 181, reserved 9відгукPROD#2410REVIEW#2025-12-31…#3037rating 5, author_name, titleвідгукPROD#2410REVIEW#2025-12-31…#3038rating 1, author_name, titleпартиція ORDER#57: замовлення й позиції, теж один QueryPKSKінші атрибутизамовленняORDER#57METAcustomer_id 1458, status cancelled,total 467.10, promo_code WELCOME10,GSI1PK, GSI1SK, GSI2PK, GSI2SKпозиціяORDER#57ITEM#01product_id 1345, product_title «ПіжамаPro 23, чорна», unit_price 230.00позиціяORDER#57ITEM#02product_id 729, product_title «Настільналампа Zatyshok…», unit_price 289.00Items впорядковано за SK. ScanIndexForward=false дає відгуки від новіших до старіших.
Дані з dataset/small. Під PROD#2410 товар, залишок і відгуки, під ORDER#57 замовлення й позиції. Префікс каже, що це за item, а сортування за SK розкладає колекцію в корисному порядку.

Ключові атрибути називають загально, 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 у відгуку: копія імені з профілю, взята, щоб не читати профіль на кожен відгук. Покупець змінив ім’я, відгуки показують старе: прийняти це чи оновлювати всі відгуки автора, вирішує застосунок, бо база за копіями не стежить.

Патерни 3 і 5 не збігаються з основним ключем: замовлення лежать під ORDER#id, а питають про них за покупцем і за пунктом видачі. Для цього є глобальний вторинний індекс (GSI, global secondary index): копія items, організована за іншим ключем. Ключові атрибути індексу, GSI1PK і GSI1SK, є звичайними атрибутами item, і застосунок заповнює їх сам. Item без них в індекс не потрапляє.

Індекс GSI1 перевертає ключ: items з ключем партиції ORDER# групуються за покупцем і впорядковуються за датою; той самий індекс обслуговує й товари продавцяТаблиця shop: ключ (PK, SK)порядок за хешем PK: замовлення покупця розкиданіGSI1: ключ (GSI1PK, GSI1SK)items CUST#1458 лежать поруч, уже за датоюORDER#583 · METAGSI1PK CUST#1458GSI1SK ORDER#2025-01-17T09:43…#583PROD#2410 · METAGSI1PK SELLER#34GSI1SK PROD#002410ORDER#57 · METAGSI1PK CUST#1458GSI1SK ORDER#2025-01-03T08:40…#57ORDER#82 · METAGSI1PK CUST#1458GSI1SK ORDER#2025-01-03T18:25…#82CUST#1458ORDER#2025-01-03T08:40…#57CUST#1458ORDER#2025-01-03T18:25…#82CUST#1458ORDER#2025-01-17T09:43…#583SELLER#34PROD#002410ключі таблиці (PK, SK) копіюються в індексQuery GSI1: GSI1PK = CUST#1458, ScanIndexForward = falseдає 583, 82, 57: замовлення від новішого до старішого, без Scan
GSI1 на даних покупця 1458. Items, що в таблиці лежать за хешем PK, в індексі згруповано за покупцем і впорядковано за часом. Нижній 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

Для сторінки «мої замовлення» це припустимо, а екран «перевірте, чи збереглось» читає основну таблицю за ключем.

До цього кожен запис змінював один item. Оформлення замовлення змінює три: створює ORDER#…/META і ORDER#…/ITEM#01, зменшує PROD#…/STOCK, і залишок не повинен піти нижче нуля. PutItem й UpdateItem атомарні лише над одним item, тож для трьох разом є TransactWriteItems: до 100 дій, сукупно до 4 МБ, усі або жодна, в одному регіоні й обліковому записі. Дві дії над одним item заборонені. Кожна дія має ConditionExpression: на залишку це «є що продавати» (quantity >= :q), на замовленні захист від повтору (attribute_not_exists(PK)).

Terminal window
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 скасовує одну, і застосунок повторює.

Партиція має скінченну пропускну здатність: за документацією, до 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, ні тротлінгу на ньому не відтворити.

Таблиця працює в режимі за запит (on-demand), де платять за кожну операцію, або замовленої ємності (provisioned), де задають одиниці на секунду, платять за них постійно й можуть налаштувати автомасштабування. Ціни, пороги окупності й правила перемикання розібрано в модулі 7 хмарного курсу, тут їх не повторюємо. Для моделювання важливо інше: режим не скасовує стелі партиції, а індекс споживає ємність окремо. У замовленому режимі кожен GSI має власні одиниці, і якщо їх замало, тротляться записи в таблицю.

Середовище: DynamoDB Local (amazon/dynamodb-local:3.3.1) і AWS CLI (amazon/aws-cli:2.37.7) у контейнерах, без облікового запису AWS. Окремий compose-файл лежить у labs/m15-dynamodb/: клієнт AWS CLI нікому більше не потрібен.

Terminal window
cd labs/m15-dynamodb
docker 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=delivered
placed_at=2025-12-20T10:35:24Z total_amount=149 order_id=20897 status=returned
placed_at=2025-12-19T11:19:36Z total_amount=1547.8 order_id=20768 status=delivered
ConsumedCapacity: 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 сумує по всіх:

Terminal window
./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.

Terminal window
docker compose run --rm aws dynamodb update-table --cli-input-json file:///work/add-gsi-promo.json
docker 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 порівнянного кроку немає:

СпробуйPostgreSQLCtrl+Enter — виконати

Без індексу 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=272
Execution Time: 1.052 ms
CREATE INDEX orders_promo_placed_idx ON orders (promo_code, placed_at);
SELECT order_id, customer_id, placed_at, total_amount
FROM orders
WHERE 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=4
Execution Time: 0.142 ms

CREATE INDEX на 22 030 рядків зайняв 14 мс і не змінив жодного запиту застосунку. На таких обсягах 1 мс проти 0.1 мс непомітні, на full різниця росте разом із таблицею, як у модулі 8. Відповідь на непередбачене питання в PostgreSQL є завжди, дешева чи дорога залежно від індексу. У DynamoDB без підготовленого індексу вона коштує пропорційно розміру всієї таблиці, а готувати індекс наперед можна лише тоді, коли про запит знаєш. Тому модель DynamoDB живе разом із процесом: нове питання бізнесу означає нову ітерацію переліку патернів, іноді міграцію items і завжди вибір між окремим індексом і Scan раз на тиждень.

Документація 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 завжди краще». Він виграє, коли запити читають кілька сутностей разом. Якщо таких запитів немає, таблиця на сутність простіша й дає окремі резервні копії та шифрування.

Перевір себе

1. Патерн «замовлення покупця за датою» у «Крамниці» читається з GSI1. Що робить його одним запитом `Query`, а не `Scan`?
2. `Scan` із `FilterExpression` повернув 502 items з 76 617. Скільки одиниць читання він спожив порівняно зі `Scan` без фільтра?
3. У таблиці з LSI під одним ключем партиції вже 9.8 ГБ. Що буде, коли item collection перетне 10 ГБ?
4. Лічильник переглядів товару 2410 отримує 5.99 % усіх подій сайту, межа запису партиції 1000 одиниць на секунду. При якому загальному потоці подій item почне тротлитись, якщо кожен перегляд пише рівно одну одиницю?
5. Транзакція з трьох дій: `Put` замовлення, `Put` позиції, `Update` залишку з умовою `quantity >= :q`. Умова не виконалась. Що з items замовлення?
6. Потрібен частий звіт «замовлення з промокодом NY20 за грудень». `promo_code` і `placed_at` уже є в items замовлень. Що найдешевше?

L9. Одна задача в трьох моделях: перелік патернів доступу, змодельований у PostgreSQL, MongoDB і Scylla, з новою вимогою посеред роботи. Логіка «від запитів» із цього модуля стає там таблицею на запит у Scylla (модуль 17) і вибором між вкладенням і посиланням у MongoDB (модуль 16).