Документні БД
Навіщо це
Section titled “Навіщо це”Сторінка замовлення 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 на машині не потрібен:
docker compose --profile mongo up -d --wait./dataset/loaders/mongo/load.sh mediumdocker compose exec mongo mongosh shopДокумент і BSON
Section titled “Документ і BSON”Документ у 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()6abd9d2ccf41a66a79ecb32aISODate('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 }.
Вкладення має ціну: копії. У позиції лежать title і category_id товару на момент замовлення. Для історії це правильно, як unit_price у SQL-схемі: перейменування товару не переписує давні замовлення. Але копія, що має стежити за оригіналом (rating.avg), оновлюється кодом застосунку й може розійтися з відгуками. У модулі 5 це називалось денормалізацією; тут вона типова й обирається під запити, а не під сутності (модуль 15 доводить цю думку до кінця).
Валідація схеми
Section titled “Валідація схеми”«Без схеми» означає, що сервер її не перевіряє, а не що її немає. Вона живе в коді застосунку. Коли хтось запише 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 немає.
Індекси
Section titled “Індекси”Індекс у 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: 104FETCH -> 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.
Aggregation pipeline
Section titled “Aggregation pipeline”Звіти в MongoDB будують методом aggregate. Це aggregation pipeline: масив стадій, кожна читає виведення попередньої. $match відбирає документи (як WHERE), $unwind розгортає масив в окремі документи (документ із трьома позиціями дає три), $group групує й рахує, $sort і $limit сортують та обрізають, $lookup з’єднує колекції (про нього нижче).
Той самий звіт у двох мовах. SQL з’єднує чотири таблиці:
SELECT c.name AS category, sum(round(i.quantity * i.unit_price * (1 - i.discount_pct / 100), 2)) AS revenueFROM orders AS oJOIN order_items AS i ON i.order_id = o.order_idJOIN products AS p ON p.product_id = i.product_idJOIN categories AS c ON c.category_id = p.category_idWHERE o.status = 'delivered'GROUP BY c.nameORDER BY revenue DESCLIMIT 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 і його ціна
Section titled “$lookup і його ціна”Стадія $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: 278EQ_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);У 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.
Replica set
Section titled “Replica set”Сервіс mongo у нашому docker-compose.yml це replica set з одного вузла (--replSet rs0): без цього не працюють транзакції. Репліки читають журнал операцій мастера (oplog) і відтворюють його; коли мастер зникає, вузли голосують за нового (модуль 12).
Write concern і read concern
Section titled “Write concern і read concern”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 2A reads again 2WriteConflict [ '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.
Шардинг
Section titled “Шардинг”Replica set дублює дані, але не розподіляє їх. Розподіляє шардинг: колекцію ділять між replica set за ключем шардування, а запити йдуть через маршрутизатор mongos. Для orders ключ placed_at складав би всі нові замовлення на один шард, а хеш customer_id розкидає їх порівну, і «замовлення покупця» лежать на одному шарді. Запит без ключа йде на всі шарди (модуль 13).
Як послугу з оплатою за запити це продають Azure Cosmos DB (є API для MongoDB) і Firestore: модуль 7 курсу «Хмарні технології».
Як це насправді
Section titled “Як це насправді”У explain("executionStats") читають winningPlan і чотири лічильники з прикладів вище; для pipeline: db.orders.explain("executionStats").aggregate([...]). Час змінюється від запуску до запуску, кількості документів і ключів стабільні.
Що лишається з w: 1, краще побачити самому. Replica set із трьох вузлів піднімають через Docker, бо профіль mongo має один:
docker network create rsnetfor n in a b c; do docker run -d --name m$n --network rsnet mongo:8.0.32 mongod --replSet rsx --bind_ip_all; donedocker 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 madocker 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 дає мастер до реплікації. Якщо він зникне, новий мастер цього запису не матиме, а старий його відкотить.
Перевір себе
Лабораторна
Section titled “Лабораторна”L9. Одна задача в трьох моделях спирається на модулі 15, 16 і 17: та сама предметна область у PostgreSQL, MongoDB і Scylla, з новою вимогою посеред роботи.
Джерела
Section titled “Джерела”- MongoDB Manual: Limits and Thresholds, ObjectId, Schema Validation, Write Concern, Read Concern, Transactions: Production Considerations.
- MongoDB 5.0 Manual, Write Concern; MongoDB, прес-реліз про 4.2.
- MongoDB 3.6 Manual, Compatibility Changes: прив’язка до localhost.
- J. Matherly, It’s Still the Data, Stupid!, Shodan, 15.12.2015.
- BleepingComputer, MongoDB Databases Held for Ransom by Mysterious Attacker, 2017.
- The Register, MongoDB ransom attacks soar, body count hits 27,000 in hours, 9.01.2017.
- B. Krebs, Extortionists Wipe Thousands of Databases, Victims Who Pay Up Get Stiffed, 10.01.2017.
- SecurityWeek, MongoDB Tightens Security Amid New Database Attacks.