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

Документні БД

Сторінка замовлення 198 у «Крамниці» збирається в PostgreSQL з п’яти таблиць: orders, order_items, payments, shipments і pickup_points. У документній моделі це один документ на 806 байтів, який читається однією операцією за ключем. Звідси спокуса перенести все в MongoDB. Але звіт «виручка за категоріями», який PostgreSQL рахує за 0.2 с, MongoDB на тих самих даних рахує за 0.7 с, а якщо модель вибрано трохи інакше, то за 7 с. А відгуки товару 18893 у документ товару вкласти не можна: їх 1557, і документ виріс би з 757 байтів до півмегабайта.

Документна модель виграє, коли дані читаються цілими агрегатами, і програє, коли їх треба з’єднувати чи рахувати наскрізь. Межу виміряно на «Крамниці» в MongoDB 8.0; числа з розміру medium (див. Датасет).

Передумови. Проєктування від даних і jsonb у products.attributes: модуль 5. Індекси, правило лівого префікса, GIN: модуль 8. Знімки й 40001: модуль 10. Failover: модуль 12; шардинг: модуль 13. Моделювання від запитів: модуль 15.

Дані завантажує скрипт із dataset/loaders/mongo/; він працює в контейнері, Node.js на машині не потрібен:

Terminal window
docker compose --profile mongo up -d --wait
./dataset/loaders/mongo/load.sh medium
docker compose exec mongo mongosh shop

Документ у MongoDB — впорядкований набір пар «поле: значення», які можна вкладати одна в одну; значенням може бути й масив. Документи лежать у колекціях, що не вимагають однакових полів. Зберігає їх BSON: двійковий формат із типами, яких немає в JSON, як Decimal128, date і ObjectId.

Гроші не зберігають як double (модуль 4): у mongosh 0.1 + 0.2 дає 0.30000000000000004, тому завантажувач пише ціни в Decimal128. NULL з таблиці стає відсутнім полем: у 11 945 покупців немає birth_date, і ключа в їхніх документах просто немає.

Кожен документ має _id, унікальний у колекції. Якщо його не задати, клієнт згенерує ObjectId на 12 байтів: 4 байти часу в секундах, 5 байтів, випадкових для процесу клієнта, і лічильник на 3 байти. Завантажувач бере _id з PostgreSQL, тож ObjectId видно на окремій колекції:

const r = db.notes.insertOne({ text: "перша" })
r.insertedId.toString()
r.insertedId.getTimestamp()
6abd9d2ccf41a66a79ecb32a
ISODate('2026-09-30T23:37:16.000Z')

Перші чотири байти 6abd9d2c дають 1790811436 секунд від епохи. За _id документи впорядковуються за часом лише грубо: ключ видає клієнт, а годинники розходяться.

Документ не може перевищувати 16 МіБ, вкладеність обмежена 100 рівнями (документація, «Limits and Thresholds»). Замовлення на medium важить у середньому 923 байти (максимум 2345), товар 757. Завантажувач створює шість колекцій: orders, products, reviews, customers, sellers і categories (з масивом path предків).

Вкладення чи посилання

Section titled “Вкладення чи посилання”

Головне питання документної моделі: де лежать пов’язані дані. Їх можна покласти всередину документа (вкладення, embedded document) або зберігати окремо з посиланням на _id (reference). Посилання сервер не перевіряє: зовнішніх ключів немає, цілісність тримає застосунок.

Вкладають те, що читається разом із власником, живе стільки, скільки він, і обмежене за розміром. Посилаються на те, що росте без меж, читається саме по собі або має кількох власників. «Крамниця» дає три відповіді.

Позиції замовлення вкладені. Позицію без замовлення ніхто не читає, їх у замовленні від 1 до 8 (123 613 замовлень мають одну), і змінюються вони разом із ним. Так само payments[] (0–2 спроби) і shipment. Запит «замовлення 198 з усім» стає одним findOne.

Атрибути товару вкладені. У medium 11 різних ключів: brand є в 30 286 товарів, weight_g у 22 794, а author, pages, language і cover лише в 2154 книжок. У таблиці це були б порожні колонки або EAV (модуль 5), а документ несе рівно ті ключі, які має товар.

Відгуки окремо. Вони пишуться без кінця, читаються сторінками й потрібні також за customer_id. У середньому на товар їх три, але товар 18893 має 1557: вкладені, вони додали б до його 757 байтів ще 476 КіБ, і кожне читання товару тягло б усе це. До 16 МіБ далеко (приблизно 52 тисячі таких відгуків), але розмір документа, який читається на кожному показі, не має залежати від активності покупців. Тому в товар вкладено лише підсумок rating: { count, avg }.

Замовлення містить позиції, платежі й відправлення всередині; відгуки товару лежать в окремій колекції й посилаються на товарorders: читається разом, обмеженезамовлення _id: 198items[ ]: 1–8 позиційpayments[ ]: 0–2 спроби оплатиshipment { … }pickup_point { копія полів }reviews: необмежене зростаннятовар _id: 18893rating: { count: 1557, avg }відгук, product_id: 18893відгук, product_id: 18893відгук, product_id: 18893ще 1554 відгукиЛіворуч одне читання дає сторінку замовлення.Праворуч товар лишається малим (757 байтів),а відгуки читаються порціями за індексом.
Ліворуч усе, що читається з замовленням, лежить у ньому. Праворуч відгуки окремо, а товар тримає лише підсумок.

Вкладення має ціну: копії. У позиції лежать title і category_id товару на момент замовлення. Для історії це правильно, як unit_price у SQL-схемі: перейменування товару не переписує давні замовлення. Але копія, що має стежити за оригіналом (rating.avg), оновлюється кодом застосунку й може розійтися з відгуками. У модулі 5 це називалось денормалізацією; тут вона типова й обирається під запити, а не під сутності (модуль 15 доводить цю думку до кінця).

«Без схеми» означає, що сервер її не перевіряє, а не що її немає. Вона живе в коді застосунку. Коли хтось запише price рядком "99.00", помилки не буде, а sum по колекції мовчки пропустить такі документи.

Сервер може перевіряти документи за $jsonSchema. Правила чіпляють на колекцію (тут на копію products_v, щоб не чіпати завантажені дані), і вони діють на вставку й зміну:

db.runCommand({ collMod: "products_v", validationLevel: "strict", validationAction: "error",
validator: { $jsonSchema: { bsonType: "object", required: ["sku", "price"],
properties: {
sku: { bsonType: "string", pattern: "^[A-Z]{2}-[0-9]{6}$" },
price: { bsonType: "decimal", minimum: Decimal128("0.01") },
} } } })
db.products_v.insertOne({ _id: 99001, sku: "XX-1", price: -5 })
MongoServerError: Document failed validation
sku: regular expression did not match, 'XX-1'
price: comparison failed, -5; type did not match, 'int'

Сервер називає кожне порушене правило (вивід скорочено). Так само відхиляється price: 10.5: double не decimal. Правила не діють назад: документи, вставлені до collMod, лишаються як були, а validationAction: "warn" лише пише в журнал, що дозволяє вводити схему на живій колекції. Перевірки посилань між документами в $jsonSchema немає.

Індекс у MongoDB — B-дерево за значенням поля з посиланням на документ, як у PostgreSQL. explain("executionStats") показує, чи він використаний: COLLSCAN означає читання всіх документів, IXSCAN із FETCH читання за індексом.

db.orders.find({ customer_id: 4242 }).explain("executionStats")
db.orders.createIndex({ customer_id: 1 })
db.orders.find({ customer_id: 4242 }).explain("executionStats")
COLLSCAN nReturned: 8 totalKeysExamined: 0 totalDocsExamined: 220528 executionTimeMillis: 104
FETCH -> IXSCAN(customer_id_1) nReturned: 8 totalKeysExamined: 8 totalDocsExamined: 8 executionTimeMillis: 0

Коли totalDocsExamined набагато більший за nReturned, запит платить за чужі документи. Індекс за customer_id займає 1.3 МіБ. Той самий запит у PostgreSQL без індексу іде 12 мс, а колекцію MongoDB читає 104 мс: документ замовлення значно більший за рядок orders, а кеш WiredTiger у нашому контейнері лише 0.25 ГБ.

Складений індекс підкоряється правилу лівого префікса з модуля 8. Для { status: 1, placed_at: -1 }:

Запит План Ключів / документів
status: "shipped", placed_at від 1 грудня IXSCAN 1539 / 1539
status: "shipped", сортування за placed_at, limit(10) IXSCAN, без SORT 10 / 10
лише placed_at за останні два дні COLLSCAN 0 / 220 528

Третій запит не задає першого поля індексу, тож індекс йому не потрібен. Сортування теж береться з індексу: на { customer_id: 1, placed_at: -1 } запит із sort({ placed_at: -1 }) не має стадії SORT, а на індексі лише за customer_id має.

Індекс за масивом називають multikey: у ньому окремий запис на кожен елемент. Замовлення з товаром 3943 шукає find({ "items.product_id": 3943 }).

Без індексу це COLLSCAN по всіх 220 528 документах. Після createIndex({ "items.product_id": 1 }) план стає FETCH за IXSCAN(items.product_id_1): 16 ключів і 16 документів, а explain позначає індекс як isMultiKey: true. Індекс займає 2.2 МіБ проти 1.3 МіБ у customer_id: у ньому до 396 570 записів, по одному на позицію, а не 220 528. Так само працює find({ path: 2 }) за масивом предків у categories (7 документів): у модулі 3 для цього знадобився рекурсивний CTE.

Звіти в MongoDB будують методом aggregate. Це aggregation pipeline: масив стадій, кожна читає виведення попередньої. $match відбирає документи (як WHERE), $unwind розгортає масив в окремі документи (документ із трьома позиціями дає три), $group групує й рахує, $sort і $limit сортують та обрізають, $lookup з’єднує колекції (про нього нижче).

Конвеєр із шести блоків: колекція orders, $match, $unwind, $group, $sort з $limit і $lookup; під кожним числом документів і SQL-відповідникordersколекція220 528FROM orders$matchstatus =delivered190 642WHERE$unwinditems[ ] урядки342 808JOIN items$groupза category_id$sum виручки68 групGROUP BY$sort$limit: 5revenue ↓5 групORDER BY LIMIT$lookupcategoriesназва5 документівJOIN categoriesSQL-відповідникКожна стадія читає те, що віддала попередня.Після $group лишається 68 груп, тому $lookup у кінці дешевий.
Виручка за категоріями як pipeline. Числа документів на виході кожної стадії виміряно на medium.

Той самий звіт у двох мовах. SQL з’єднує чотири таблиці:

SELECT c.name AS category,
sum(round(i.quantity * i.unit_price * (1 - i.discount_pct / 100), 2)) AS revenue
FROM orders AS o
JOIN order_items AS i ON i.order_id = o.order_id
JOIN products AS p ON p.product_id = i.product_id
JOIN categories AS c ON c.category_id = p.category_id
WHERE o.status = 'delivered'
GROUP BY c.name
ORDER BY revenue DESC
LIMIT 5;

Pipeline MongoDB:

db.orders.aggregate([
{ $match: { status: "delivered" } },
{ $unwind: "$items" },
{ $group: { _id: "$items.category_id",
revenue: { $sum: { $round: [ { $multiply: [ "$items.quantity", "$items.unit_price",
{ $subtract: [ 1, { $divide: [ "$items.discount_pct", 100 ] } ] } ] }, 2 ] } } } },
{ $sort: { revenue: -1 } },
{ $limit: 5 },
{ $lookup: { from: "categories", localField: "_id", foreignField: "_id", as: "c" } },
{ $project: { _id: 0, category: { $first: "$c.name" }, revenue: 1 } }
])

Обидва дають на medium однакові п’ять рядків: «Ноутбуки для роботи» (78 405 540.68), «Ігрові ноутбуки» (76 798 395.95), «Ноутбуки для навчання» (42 782 350.21), «Велосипеди та аксесуари» (33 507 201.66) і «Кавоварки й чайники» (25 927 983.00).

PostgreSQL рахує це за 190–270 мс (паралельні Hash Join), pipeline за 670–830 мс; обидва читають усі доставлені замовлення. У pipeline немає з’єднання orders з products, бо category_id скопійовано в позицію при завантаженні, а $lookup стоїть після $group, коли лишилось 68 груп. Якщо цієї копії немає й категорію дістають $lookup по кожній позиції, той самий звіт іде 6.7 с, майже вдесятеро довше. Звіт, якому потрібне з’єднання по кожному рядку, у документній моделі коштує більше: її будують не під такі запити.

Стадія $lookup для кожного вхідного документа знаходить документи з іншої колекції за localField і foreignField і кладе їх у масив. Це аналог LEFT JOIN із тією самою ціною (модуль 9): без індексу з того боку, де шукають, виходить Nested Loop. Порахуємо відгуки для 31 товару продавця 5:

db.products.explain("executionStats").aggregate([
{ $match: { seller_id: 5 } },
{ $lookup: { from: "reviews", localField: "_id", foreignField: "product_id", as: "r" } },
{ $project: { title: 1, reviews: { $size: "$r" } } }
])

Стадія $lookup і лічильники: спершу без індексу, потім після db.reviews.createIndex({ product_id: 1 }):

EQ_LOOKUP NestedLoopJoin totalKeysExamined: 0 totalDocsExamined: 1009454 executionTimeMillis: 278
EQ_LOOKUP IndexedLoopJoin (product_id_1) totalKeysExamined: 16 totalDocsExamined: 35016 executionTimeMillis: 30

Без індексу кожен із 31 товарів сканує всі 31 434 відгуки: 31 × 31 434 плюс 35 000 документів products дають 1 009 454. З індексом читається 16 ключів. Індекс потрібен колекції, яку з’єднують; сервер сам його не створює.

jsonb чи окрема документна база

Section titled “jsonb чи окрема документна база”

У «Крамниці» products.attributes уже jsonb. Запит до атрибутів у PostgreSQL прискорює GIN (модуль 8):

CREATE INDEX products_attributes_gin ON products USING gin (attributes jsonb_path_ops);
СпробуйPostgreSQLCtrl+Enter — виконати

У MongoDB той самий запит прискорює складений індекс:

db.products.createIndex({ "attributes.brand": 1, "attributes.colour": 1 })
db.products.find({ "attributes.brand": "Kozak Sport", "attributes.colour": "рожевий" })
Запит PostgreSQL MongoDB
brand і colour, без індексу Seq Scan: 2760 буферів, 50 мс COLLSCAN: 35 000 документів, 25 мс
те саме з індексом GIN 640 кБ: 11 буферів, 0.09 мс складений 220 КіБ: 6 ключів, 6 документів
лише colour той самий GIN: 536 буферів, 588 рядків складений: COLLSCAN; wildcard-індекс attributes.$** (792 КіБ): 588 ключів

GIN відповідає на будь-яку комбінацію ключів, а складений індекс MongoDB підкоряється правилу префікса й потребує окремого індексу під кожну. Wildcard-індекс це знімає, але для двох ключів переглядає 187 записів одного з них, а GIN перетинає обидва й лишає 6. jsonb не зберігає порядок ключів (сортує за довжиною, потім за алфавітом), а BSON порядок тримає; усередині jsonb немає зовнішніх ключів, і CHECK можна написати лише виразом над усім документом. PostgreSQL складає й документ із рядків через jsonb_build_object та jsonb_agg, коли він потрібен.

Окрема документна база виправдана, коли головні операції читають і пишуть цілий агрегат за ключем, форма даних справді різна, обсяг вимагає шардингу з коробки, а з’єднань між агрегатами мало. Коли ж звіти з’єднують сутності (як «виручка за категоріями»), потрібна цілісність між агрегатами або дані вміщаються на одному сервері PostgreSQL, jsonb дає документну частину без другої системи, другого бекапу й транзакцій між двома базами. Вибір між ними розбирає модуль 21.

Сервіс mongo у нашому docker-compose.yml це replica set з одного вузла (--replSet rs0): без цього не працюють транзакції. Репліки читають журнал операцій мастера (oplog) і відтворюють його; коли мастер зникає, вузли голосують за нового (модуль 12).

Write concern визначає, коли клієнт дізнається про успіх запису. З w: 1 успіх приходить, коли запис зробив лише мастер: після failover він може зникнути. З w: "majority" відповідь приходить, коли записала більшість вузлів із голосом, і запис переживає вибори.

Типовим w: "majority" став у MongoDB 5.0 (за документацією); до того драйвери просили w: 1. Параметр j додає очікування запису в журнал, а wtimeout обмежує очікування; прострочений запис не відкочується, його лише не підтверджено.

Read concern вирішує, що бачить запит. Типовий "local" повертає те, що є на вузлі, навіть якщо воно може відкотитись. "majority" повертає лише підтверджене більшістю, "snapshot" знімок на початок транзакції. Щоб прочитати власний запис, пишуть з w: "majority" і читають з "majority".

Багатодокументні транзакції

Section titled “Багатодокументні транзакції”

Один документ змінюється атомарно завжди, скільки б полів і вкладених масивів не зачепило оновлення. Тому умовний updateOne розв’язує те саме, що атомарний UPDATE у модулі 10:

db.products.updateOne({ _id: 39, "stock.quantity": { $gte: 1 } }, { $inc: { "stock.quantity": -1 } })

Коли залишок 0, matchedCount дорівнює 0, і покупку відхилено без транзакції. Коли треба змінити кілька документів (залишок і нове замовлення), потрібна транзакція. У MongoDB вона з’явилась у версії 4.0 для replica set, а на шардованих кластерах працює з 4.2 (документація й прес-реліз MongoDB 4.2). Ізоляція в ній це snapshot isolation, і розклад із модуля 10 відтворюється дослівно. Сесія A у транзакції з readConcern: "snapshot" читає залишок 2, інший клієнт змінює його на 1 й комітить, A читає знову, а потім пише:

A reads 2
A reads again 2
WriteConflict [ 'TransientTransactionError' ] Caused by :: Write conflict during plan execution ...
outside 1

Запис документа, зміненого після знімка, завершується WriteConflict з міткою TransientTransactionError. Це відповідник 40001: транзакцію повторюють цілком, і withTransaction у драйверах робить це сам. За замовчуванням транзакція має вкластися в 60 с, а очікування блокувань у ній триває 5 мс (документація, «Production Considerations»).

Вставка замовлення з трьома позиціями одним документом займає 1.9 мс, а транзакція з окремими записами в orders і items 4.2 мс (2000 повторів, один вузол). Якщо кожна звичайна операція потребує транзакції, документи вибрано не так: позиції в окремій колекції перетворюють кожне замовлення на транзакцію, а вкладення робить його однією атомарною вставкою. Транзакції лишають для зв’язків між агрегатами, як «залишок і замовлення». Що обіцяють транзакції MongoDB і що виміряв Jepsen, описано в модулі 13.

Replica set дублює дані, але не розподіляє їх. Розподіляє шардинг: колекцію ділять між replica set за ключем шардування, а запити йдуть через маршрутизатор mongos. Для orders ключ placed_at складав би всі нові замовлення на один шард, а хеш customer_id розкидає їх порівну, і «замовлення покупця» лежать на одному шарді. Запит без ключа йде на всі шарди (модуль 13).

Як послугу з оплатою за запити це продають Azure Cosmos DB (є API для MongoDB) і Firestore: модуль 7 курсу «Хмарні технології».

У explain("executionStats") читають winningPlan і чотири лічильники з прикладів вище; для pipeline: db.orders.explain("executionStats").aggregate([...]). Час змінюється від запуску до запуску, кількості документів і ключів стабільні.

Що лишається з w: 1, краще побачити самому. Replica set із трьох вузлів піднімають через Docker, бо профіль mongo має один:

Terminal window
docker network create rsnet
for n in a b c; do docker run -d --name m$n --network rsnet mongo:8.0.32 mongod --replSet rsx --bind_ip_all; done
docker exec ma mongosh --quiet --eval 'rs.initiate({_id:"rsx",members:[{_id:0,host:"ma:27017"}]})'
docker exec ma mongosh --quiet --eval 'rs.add("mb:27017"); rs.add("mc:27017")'
docker network disconnect rsnet ma
docker exec -it ma mongosh

Відключений мастер ще до 10 с (типово) вважає себе мастером. Пишемо на нього двома способами:

db.d.insertOne({ _id: 2, note: "w1 on isolated primary" }, { writeConcern: { w: 1 } })
db.d.insertOne({ _id: 3, note: "majority" }, { writeConcern: { w: "majority", wtimeout: 2000 } })
{ acknowledged: true, insertedId: 2 }
64 WriteConcernFailed waiting for replication timed out

Запис із w: 1 клієнт вважає успішним. Другий завершується WriteConcernFailed за wtimeout: сервер записав його в себе, але не підтвердив. Через 20 с вузли mb і mc обрали мастером mb, де документ 4 записано з "majority". Після docker network connect rsnet ma старий мастер повертається реплікою й відкочує те, чого інші не мають:

node a, b, c: {"_id":1,"note":"before"} {"_id":4,"note":"on the new primary"}

Документів 2 і 3 не стало на жодному вузлі. Їх сервер лишив у rollback/<uuid>/removed.<час>.bson на старому мастері (bsondump показав обидва), але клієнт для документа 2 уже отримав acknowledged: true. З w: "majority" за таких самих умов клієнт отримав би помилку й знав би, що запису може не бути.

Розбір: відкриті інстанси MongoDB і хвиля викупу 2017

Section titled “Розбір: відкриті інстанси MongoDB і хвиля викупу 2017”

15 грудня 2015 року засновник Shodan Джон Матерлі підрахував щонайменше 35 000 загальнодоступних MongoDB без автентифікації і 684.8 ТБ даних у них. Причиною він називав конфігурацію, а не код: нові версії слухають лише локальну адресу, але серед відкритих часто трапляється MongoDB 3.0, тож адміністратори міняли типове налаштування або переносили старий конфігураційний файл.

27 грудня 2016 року дослідник Віктор Геверс (GDI Foundation) виявив, що хтось під ім’ям harak1r1 анонімно підключається до таких баз, експортує вміст, видаляє його й лишає записку з вимогою 0.2 біткойна (BleepingComputer). Матерлі тоді налічив близько 1800 уражених баз. Далі кількість зросла лавиною: за даними Найла Мерріґана, з 12 000 до 27 633 за приблизно 12 годин; The Register 9 січня писав про 15 різних нападників і про 99 000 відкритих інстансів загалом. До 10 січня, за Мерріґаном, стерто не менше 29 000 баз, а Брайан Кребс писав, що дані майже нікому з тих, хто заплатив, не повернули. Числа джерел різняться, бо датовані різними днями.

Уразливого коду тут не було: база слухала зовнішню адресу без пароля, і нападникові вистачило Shodan та клієнта. Відповідь MongoDB стосувалась типових налаштувань. Пакети RPM і DEB слухали лише 127.0.0.1 з версії 2.6. У самому сервері таку прив’язку ввели в розробницькій версії 3.5.7, і з версії 3.6 mongod і mongos слухають 127.0.0.1 за замовчуванням, а для віддаленого доступу треба явно задати net.bindIp. Автентифікацію ця зміна не вмикає, і контейнер у нашому docker-compose.yml її теж не має: він слухає --bind_ip_all (усередині мережі Docker це потрібно), але порт опубліковано лише на 127.0.0.1. На відкритому порту ця конфігурація повторила б чужу історію.

Порт бази не має бути видно з інтернету без автентифікації, хоч би яка версія: безпечні типові налаштування з’являються із запізненням. Про файрволи й закриті порти: модуль 16 курсу мереж; про ролі й шифрування: модуль 21.

Типові помилки розуміння

Section titled “Типові помилки розуміння”

«Без схеми» значить, що схеми немає. Вона є в коді застосунку, а сервер її не перевіряє, поки не задано $jsonSchema. Розбіжності (price рядком у частини документів) виявляються у звітах, а не під час запису.

Вкладати завжди швидше. Вкладений масив, що росте, обтяжує кожне читання: відгуки товару 18893 додали б 476 КіБ до документа на 757 байтів.

Транзакції в MongoDB роблять її реляційною. Вони є з 4.0, але коштують удвічі більше за одиночну вставку й вимагають повтору. Модель, якій вони потрібні на кожну операцію, обрана неправильно.

Є репліки, тож w: 1 безпечний. Підтвердження w: 1 дає мастер до реплікації. Якщо він зникне, новий мастер цього запису не матиме, а старий його відкотить.

Перевір себе

1. Товар у «Крамниці» має в середньому три відгуки, а один має 1557. Що найкраще зробити з відгуками?
2. Є індекс `{ status: 1, placed_at: -1 }`. Який запит він не прискорить?
3. Клієнт записав замовлення з `w: 1`, отримав `acknowledged: true`, а потім мастер став недоступним, і обрали нового. Що може статися із замовленням?
4. У транзакції зі знімком A прочитала залишок 2. Поза транзакцією інший клієнт змінив його на 1 й закомітив. Що побачить A і що буде, коли вона спробує змінити той самий документ?
5. Запити до `jsonb` фільтрують за `colour`, за `brand` або за обома. Який варіант відповідає на будь-яку з цих комбінацій без окремого індексу під кожну?

L9. Одна задача в трьох моделях спирається на модулі 15, 16 і 17: та сама предметна область у PostgreSQL, MongoDB і Scylla, з новою вимогою посеред роботи.