Тюнінг продуктивності MongoDB (WiredTiger, індексація)
MongoDB з WiredTiger як рушієм сховища — за замовчуванням виділяє 50% доступної ОЗУ під кеш. На сервері з 32 ГБ це 16 ГБ кеша. Звучить щедро, але деталі важливі: неефективні індекси, колекції без необхідних індексів, операції без hint взагалі не використовують кеш — читають з диска сторінка за сторінкою.
WiredTiger Cache: налаштування
# /etc/mongod.conf
storage:
wiredTiger:
engineConfig:
# Явне вказання розміру кеша (за замовчуванням: max(50% ОЗУ - 1GB, 256MB))
cacheSizeGB: 12 # Для сервера з 32 ГБ, залишаємо ~20 ГБ для ОС та інших процесів
# Журнал транзакцій
journalCompressor: snappy # snappy швидше, ніж zlib, трохи менше стиснення
collectionConfig:
# Алгоритм стиснення даних
blockCompressor: snappy # snappy — баланс швидкість/стиснення
indexConfig:
# Стиснення індексів
prefixCompression: true # економить місце в пам'яті та на диску
Моніторинг стану кеша:
const cacheStats = db.serverStatus().wiredTiger.cache
printjson({
"cache_size_MB": Math.round(cacheStats["maximum bytes configured"] / 1024 / 1024),
"currently_in_cache_MB": Math.round(cacheStats["bytes currently in the cache"] / 1024 / 1024),
"dirty_bytes_MB": Math.round(cacheStats["tracked dirty bytes in the cache"] / 1024 / 1024),
"pages_read_from_disk": cacheStats["pages read into cache"],
"pages_evicted": cacheStats["pages evicted by application threads"],
// Якщо evicted > 0 — кеш під тиском, можливо потрібно збільшити cacheSizeGB
})
Стратегія індексування
Індекси — головний інструмент продуктивності. Без індексу — COLLSCAN, повне сканування кожного документа.
Правило ESR (Equality, Sort, Range) для складених індексів:
// Запит: знайти активні замовлення користувача, відсортувати за датою
db.orders.find({
user_id: ObjectId("..."), // Equality
status: "active" // Equality (друге)
}).sort({ created_at: -1 }) // Sort
// Правильний індекс: поля equality спочатку, потім sort
db.orders.createIndex(
{ user_id: 1, status: 1, created_at: -1 },
{ name: "idx_user_status_date", background: true }
)
// НЕПРАВИЛЬНО: range перед sort
// { user_id: 1, created_at: -1, status: 1 } — sort не використовує індекс
Індекси для конкретних патернів
// Мультиключевий індекс для масивів тегів
db.articles.createIndex({ tags: 1 })
// Документ: { tags: ["nodejs", "mongodb", "backend"] }
// Запит: db.articles.find({ tags: "mongodb" }) — використовує індекс
// Text індекс для повнотекстового пошуку
db.articles.createIndex(
{ title: "text", body: "text" },
{ weights: { title: 10, body: 1 }, default_language: "ukrainian" }
)
db.articles.find({ $text: { $search: "індексування MongoDB" } })
// Sparse індекс — тільки документи з полем (економить місце)
db.users.createIndex(
{ phone: 1 },
{ sparse: true, unique: true }
)
// 2dsphere для геолокації
db.stores.createIndex({ location: "2dsphere" })
db.stores.find({
location: {
$near: {
$geometry: { type: "Point", coordinates: [37.618, 55.752] },
$maxDistance: 5000 // 5 км
}
}
})
Aggregation Pipeline: оптимізація
// Повільно: $match після $lookup
db.orders.aggregate([
{ $lookup: { from: "users", localField: "user_id", foreignField: "_id", as: "user" } },
{ $match: { "user.country": "UA" } } // ПОГАНО: lookup всієї колекції
])
// Швидко: $match якомога раніше в пайплайні
db.orders.aggregate([
{ $match: { status: "completed", created_at: { $gte: ISODate("2025-01-01") } } },
// ^ Використовує індекс, фільтрує ДО lookup
{ $lookup: {
from: "users",
localField: "user_id",
foreignField: "_id",
as: "user",
pipeline: [
{ $match: { country: "UA" } }, // Фільтруємо всередині lookup
{ $project: { name: 1, email: 1 } } // Тільки потрібні поля
]
}},
{ $project: { _id: 1, total: 1, "user.name": 1 } }
])
// allowDiskUse для важких aggregation (якщо перевищує 100 МБ ОЗУ)
db.orders.aggregate([...], { allowDiskUse: true })
Профайлер та аналіз запитів
// Включити профайлер для повільних операцій
db.setProfilingLevel(1, { slowms: 50 })
// Аналіз результатів
db.system.profile.aggregate([
{ $match: { millis: { $gt: 50 } } },
{ $group: {
_id: "$command.filter",
count: { $sum: 1 },
avg_ms: { $avg: "$millis" },
max_ms: { $max: "$millis" }
}},
{ $sort: { avg_ms: -1 } },
{ $limit: 10 }
])
// Примусити explain для підозрілого запиту
db.orders.find({ status: "pending" }).explain("allPlansExecution")
// "winningPlan" — вибраний план
// "rejectedPlans" — альтернативи, які розглядалися
// "executionStats.totalDocsExamined" vs "nReturned" — селективність індексу
Aggregation: $facet для паралельних пайплайнів
// Замість кількох окремих запитів — один $facet
db.products.aggregate([
{ $match: { category: "electronics", price: { $gte: 1000 } } },
{ $facet: {
"total_count": [{ $count: "count" }],
"by_brand": [{ $group: { _id: "$brand", count: { $sum: 1 } } }, { $sort: { count: -1 } }],
"price_stats": [{ $group: { _id: null, min: { $min: "$price" }, max: { $max: "$price" }, avg: { $avg: "$price" } } }],
"paginated": [{ $sort: { price: 1 } }, { $skip: 0 }, { $limit: 20 }]
}}
])
// Один round-trip замість чотирьох запитів
Read Preference на Replica Set
// Аналітичні запити — на secondary, без навантаження на primary
const client = new MongoClient(uri, {
readPreference: ReadPreference.SECONDARY_PREFERRED
})
// Або для конкретного запиту
db.orders.find({ status: "completed" })
.readPreference("secondaryPreferred")
.hint({ status: 1, created_at: -1 })
Шардування: коли та як
Шардування додає складність — потрібен mongos, config servers, вибір shard key. Має сенс при:
- Даних > 100–200 ГБ, які не вміщуються на один сервер
- Записах > 10 000 RPS, які не справляється обробити один primary
sh.enableSharding("mydb")
// Ключ шардування: висока кардинальність, монотонний ріст погано
// Добре: хеш від user_id (рівномірний розподіл)
sh.shardCollection("mydb.events", { user_id: "hashed" })
// Погано: монотонно зростаючий _id (всі записи йдуть в один шард)
// sh.shardCollection("mydb.events", { _id: 1 }) // НЕ РОБИТИ







