Kas yra Aggregation Pipeline ir kodėl tai svarbu
Jei kada nors dirbote su MongoDB, tikriausiai susidūrėte su situacija, kai paprastas `find()` užklausos tiesiog nepakanka. Reikia grupuoti duomenis, skaičiuoti sumas, filtruoti sudėtingomis sąlygomis, o gal net sujungti duomenis iš skirtingų kolekcijų. Čia ir ateina į pagalbą MongoDB Aggregation Pipeline – galingas įrankis, leidžiantis atlikti sudėtingas duomenų transformacijas ir analizę.
Problema ta, kad Aggregation Pipeline gali tapti tikru našumo košmaru, jei nežinote, ką darote. Matau tai nuolat – kūrėjai sukuria pipeline’ą, kuris veikia puikiai su keliais šimtais įrašų, bet kai duomenų bazė išauga iki milijonų dokumentų, viskas pradeda lėtėti iki nepakenčiamo lygio. Užklausos, kurios turėtų užtrukti milisekundes, vyksta sekundes ar net minutes.
Šiame straipsnyje pasidalinsiu praktinėmis žiniomis apie tai, kaip optimizuoti MongoDB agregacijas, kad jos veiktų greitai net su dideliais duomenų kiekiais. Tai ne teoriniai patarimai iš dokumentacijos – tai realūs sprendimai, kuriuos teko taikyti darbe su produkcijos sistemomis.
Indeksų strategija – pagrindas greitam pipeline’ui
Pirmasis ir svarbiausias dalykas, kurį turite suprasti apie Aggregation Pipeline optimizaciją – indeksai yra viskas. Galite turėti tobulai sukonstruotą pipeline’ą, bet be tinkamų indeksų jis vis tiek veiks lėtai.
Svarbiausia taisyklė: filtravimo ir rūšiavimo operacijos turėtų būti pipeline’o pradžioje ir jos turėtų naudoti indeksus. MongoDB gali panaudoti indeksus tik tam tikrose situacijose, ir jūsų darbas – užtikrinti, kad tos situacijos įvyktų.
Pavyzdžiui, jei turite tokį pipeline’ą:
db.orders.aggregate([
{ $match: { status: "completed", createdAt: { $gte: new Date("2024-01-01") } } },
{ $sort: { createdAt: -1 } },
{ $group: { _id: "$customerId", totalSpent: { $sum: "$amount" } } }
])
Šiam pipeline’ui būtinai reikia indekso ant `{ status: 1, createdAt: -1 }`. Be šio indekso MongoDB turės nuskaityti visus dokumentus kolekcijoje, o tai su dideliais duomenų kiekiais yra katastrofa.
Kaip patikrinti, ar jūsų pipeline’as naudoja indeksus? Naudokite `.explain(„executionStats”)` metodą. Jis parodys detalią informaciją apie tai, kaip MongoDB vykdo jūsų užklausą:
db.orders.aggregate([...], { explain: true })
Ieškokite `IXSCAN` (index scan) vietoj `COLLSCAN` (collection scan). Jei matote `COLLSCAN` su didelėmis kolekcijomis – tai raudona vėliavėlė.
$match ir $sort pozicionavimas – kritinė svarba
Viena didžiausių klaidų, kurią matau, yra neteisingas `$match` ir `$sort` etapų išdėstymas pipeline’e. MongoDB optimizatorius bando automatiškai perkelti šiuos etapus į priekį, bet tai ne visada įmanoma.
Visada dėkite $match kuo anksčiau pipeline’e. Kuo anksčiau sumažinsite duomenų kiekį, su kuriuo dirba kiti etapai, tuo greičiau veiks visas pipeline’as. Tai atrodo akivaizdu, bet praktikoje dažnai matau tokius dalykus:
// Blogai
db.users.aggregate([
{ $lookup: { from: "orders", ... } },
{ $unwind: "$orders" },
{ $match: { "orders.status": "completed" } }
])
// Gerai
db.users.aggregate([
{ $match: { /* filtruoti users jei įmanoma */ } },
{ $lookup: {
from: "orders",
let: { userId: "$_id" },
pipeline: [
{ $match: { status: "completed" } } // Filtruoti čia!
],
as: "orders"
} },
{ $unwind: "$orders" }
])
Antrasis variantas yra daug greitesnis, nes filtruojame `orders` dar prieš juos sujungiant su `users`, o ne po to.
Panašiai su `$sort` – jei rūšiuojate prieš `$group` ar kitus transformacijos etapus, MongoDB gali panaudoti indeksus. Jei rūšiuojate po sudėtingų transformacijų, rūšiavimas vyksta atmintyje, o tai lėta ir ribojama `allowDiskUse` parametro.
$lookup optimizacija – kaip išvengti našumo duobių
`$lookup` yra vienas iš labiausiai resursų reikalaujančių operatorių. Tai iš esmės JOIN operacija, ir kaip ir SQL pasaulyje, ji gali būti labai lėta, jei naudojama neprotingai.
Pirmiausia, užtikrinkite, kad laukai, pagal kuriuos darote lookup, turi indeksus. Jei darote lookup iš `orders` į `users` pagal `userId`, `users` kolekcijoje turi būti indeksas ant `_id` (kuris egzistuoja automatiškai) arba kito lauko, kurį naudojate.
Antra, naudokite `pipeline` parametrą `$lookup` viduje, kad filtruotumėte ir transformuotumėte duomenis kuo anksčiau:
db.customers.aggregate([
{
$lookup: {
from: "orders",
let: { customerId: "$_id" },
pipeline: [
{ $match: {
$expr: { $eq: ["$customerId", "$$customerId"] },
status: "completed",
createdAt: { $gte: new Date("2024-01-01") }
}},
{ $project: { amount: 1, createdAt: 1 } }, // Imti tik reikalingus laukus
{ $limit: 100 } // Apriboti jei įmanoma
],
as: "recentOrders"
}
}
])
Trečia, jei jums reikia tik skaičiaus ar sumos iš susietos kolekcijos, galite panaudoti `$count` ar `$sum` tiesiog `$lookup` pipeline’e, o ne vėliau. Tai sumažina duomenų kiekį, kuris perkeliamas tarp etapų.
Dar vienas patarimas – jei darote kelis `$lookup` iš eilės, pagalvokite, ar tikrai jums reikia visų tų duomenų. Galbūt galite denormalizuoti kai kuriuos dažnai naudojamus laukus ir išvengti lookup’ų visiškai? Taip, tai prieštarauja MongoDB „normalios” schemos idėjai, bet kartais denormalizacija yra teisinga atsakymas našumui.
Atminties valdymas ir allowDiskUse
MongoDB agregacijos pagal nutylėjimą ribojamos 100MB atminties vienam pipeline’o etapui. Jei viršijate šį limitą, gaunate klaidą. Sprendimas – naudoti `allowDiskUse: true` parametrą, kuris leidžia MongoDB naudoti diską laikinoms operacijoms.
db.collection.aggregate([...], { allowDiskUse: true })
Bet čia yra problema – disko naudojimas yra daug lėtesnis nei atminties. Tai turėtų būti paskutinis pasirinkimas, o ne standartinė praktika. Jei jūsų pipeline’as nuolat viršija 100MB limitą, tai signalas, kad kažkas negerai su jūsų optimizacija.
Kaip sumažinti atminties naudojimą? Keletas būdų:
1. **Naudokite $project anksti** – pašalinkite nereikalingus laukus kuo anksčiau pipeline’e. Jei jums reikia tik 3 laukų iš 20, panaudokite `$project` iš karto po `$match`.
2. **Apribokite duomenų kiekį** – jei įmanoma, naudokite `$limit` anksti pipeline’e. Net jei galutinis rezultatas neturi būti ribotas, galite riboti tarpinių etapų duomenis.
3. **Venkite $unwind su dideliais masyvais** – `$unwind` gali eksponentiškai padidinti dokumentų skaičių. Jei turite dokumentą su 1000 elementų masyvu ir darote `$unwind`, gaunate 1000 dokumentų.
4. **Pagalvokite apie batch’avimą** – jei apdorojate labai didelius duomenų kiekius, galbūt geriau suskirstyti darbą į mažesnius batch’us, o ne bandyti viską apdoroti vienu pipeline’u.
Grupavimo ir akumuliavimo optimizacija
`$group` etapas dažnai yra vienas iš lėčiausių pipeline’o dalių, ypač kai grupuojate pagal laukus su dideliu kardinalumu (daug unikalių reikšmių).
Vienas svarbus dalykas, kurį reikia suprasti – $group operacijos negali naudoti indeksų tiesiogiai. Tačiau galite optimizuoti tai, kas vyksta prieš `$group`:
// Lėta versija
db.orders.aggregate([
{ $group: {
_id: "$customerId",
totalSpent: { $sum: "$amount" },
orders: { $push: "$$ROOT" } // Stumia visus dokumentus į masyvą
}}
])
// Greitesnė versija
db.orders.aggregate([
{ $project: { customerId: 1, amount: 1 } }, // Tik reikalingi laukai
{ $group: {
_id: "$customerId",
totalSpent: { $sum: "$amount" },
orderCount: { $sum: 1 }
}}
])
Jei naudojate `$push` ar `$addToSet` akumuliatoriaus operatoriuose, būkite atsargūs – jie kuria masyvus atmintyje, ir su dideliais duomenų kiekiais tai gali greitai suėsti visą leistiną atmintį.
Dar vienas triukas – jei jums reikia grupuoti pagal kelis laukus ir tada dar kartą grupuoti, MongoDB kartais gali optimizuoti tai geriau, jei naudojate tarpinį `$sort`:
db.sales.aggregate([
{ $match: { date: { $gte: new Date("2024-01-01") } } },
{ $sort: { region: 1, category: 1 } }, // Padeda $group
{ $group: {
_id: { region: "$region", category: "$category" },
total: { $sum: "$amount" }
}}
])
Jei turite indeksą ant `{ region: 1, category: 1 }`, šis rūšiavimas bus greitas ir padės `$group` operacijai.
Faceted search ir $facet naudojimas
`$facet` operatorius leidžia vykdyti kelis agregacijos pipeline’us lygiagrečiai ant tų pačių duomenų. Tai naudinga, kai reikia gauti kelis skirtingus rezultatų rinkinius vienu užklausimu, pavyzdžiui, ir duomenis, ir statistiką, ir puslapiavimo informaciją.
db.products.aggregate([
{ $match: { category: "electronics" } },
{ $facet: {
products: [
{ $skip: 0 },
{ $limit: 20 },
{ $project: { name: 1, price: 1 } }
],
stats: [
{ $group: {
_id: null,
avgPrice: { $avg: "$price" },
count: { $sum: 1 }
}}
],
priceRanges: [
{ $bucket: {
groupBy: "$price",
boundaries: [0, 100, 500, 1000, 5000],
default: "Other"
}}
]
}}
])
Tai efektyviau nei vykdyti tris atskiras užklausas, nes duomenys nuskaitomi tik vieną kartą. Tačiau atminkite – kiekvienas `$facet` šakos pipeline’as turi savo 100MB atminties limitą, ir `allowDiskUse` taikomas kiekvienai šakai atskirai.
Optimizuojant `$facet`, svarbu:
– Filtruoti duomenis prieš `$facet` etapą, ne kiekvienoje šakoje atskirai
– Naudoti `$project` prieš `$facet`, kad sumažintumėte duomenų kiekį
– Vengti per daug šakų – kiekviena šaka prideda papildomo darbo
Realaus pasaulio pavyzdys ir matavimas
Leiskite parodyti konkretų pavyzdį iš realaus projekto. Turėjome e-komercijos sistemą su užsakymų kolekcija, kurioje buvo apie 5 milijonai dokumentų. Reikėjo gauti klientų statistiką – kiek jie išleido, kiek užsakymų padarė, vidutinę užsakymo sumą ir panašiai.
Pradinis pipeline’as atrodė taip:
// Lėta versija (~45 sekundės)
db.orders.aggregate([
{ $lookup: {
from: "customers",
localField: "customerId",
foreignField: "_id",
as: "customer"
}},
{ $unwind: "$customer" },
{ $group: {
_id: "$customerId",
customerName: { $first: "$customer.name" },
totalSpent: { $sum: "$amount" },
orderCount: { $sum: 1 },
orders: { $push: "$$ROOT" }
}},
{ $sort: { totalSpent: -1 } },
{ $limit: 100 }
])
Po optimizacijos:
// Greita versija (~2 sekundės)
db.orders.aggregate([
{ $match: { status: { $ne: "cancelled" } } }, // Filtruoti anksti
{ $sort: { customerId: 1 } }, // Naudoja indeksą
{ $group: {
_id: "$customerId",
totalSpent: { $sum: "$amount" },
orderCount: { $sum: 1 },
lastOrderDate: { $max: "$createdAt" }
}},
{ $sort: { totalSpent: -1 } },
{ $limit: 100 },
{ $lookup: { // Lookup tik 100 klientų, ne visų
from: "customers",
localField: "_id",
foreignField: "_id",
as: "customer"
}},
{ $unwind: "$customer" },
{ $project: {
customerName: "$customer.name",
totalSpent: 1,
orderCount: 1,
lastOrderDate: 1
}}
], { allowDiskUse: true })
Kas pasikeitė:
1. Pridėtas `$match` pradžioje, kad išfiltruotume atšauktus užsakymus
2. Pašalintas `$push` – nebereikia viso dokumento masyvo
3. `$lookup` perkeltas į pabaigą – darome lookup tik 100 klientų, ne 5 milijonų užsakymų
4. Pridėtas indeksas ant `{ customerId: 1, status: 1, createdAt: 1 }`
Rezultatas – nuo 45 sekundžių iki 2 sekundžių. Tai 22 kartų greičiau!
Kaip matuoti našumą? Naudokite šiuos įrankius:
1. **explain()** – parodo execution plan ir statistiką
2. **MongoDB Profiler** – įrašo lėtas užklausas
3. **currentOp()** – rodo, kas vyksta realiu laiku
4. **serverStatus()** – bendros serverio metrikos
Ir visada testuokite su realistiškais duomenų kiekiais. Pipeline’as, kuris veikia puikiai su 1000 dokumentų, gali būti katastrofiškai lėtas su milijonu.
Kai optimizacija nebepakanka – alternatyvos ir architektūriniai sprendimai
Kartais, nesvarbu kaip gerai optimizuotumėte savo agregacijas, MongoDB tiesiog nėra tinkamiausias įrankis tam, ką bandote padaryti. Ir tai normalu – nėra vieno įrankio, kuris būtų geriausias viskam.
Jei jūsų agregacijos vis dar per lėtos po visų optimizacijų, pagalvokite apie šiuos sprendimus:
**Materialized views** – MongoDB neturi tikrų materialized views, bet galite jas simuliuoti. Sukurkite atskirą kolekciją, kurioje saugotumėte agregacijos rezultatus, ir atnaujinkite juos periodiškai arba per change streams:
// Periodinis atnaujinimas per cron job
db.orders.aggregate([
{ $group: { _id: "$customerId", totalSpent: { $sum: "$amount" } } },
{ $merge: { into: "customer_stats", whenMatched: "replace" } }
])
**Read replicas** – jei jūsų agregacijos yra skaitymo intensyvios ir neturi būti real-time, galite jas vykdyti ant read replica, kad nesumažintumėte primary node našumo.
**Duomenų denormalizacija** – kartais geriau saugoti skaičiuotus laukus tiesiog dokumente, net jei tai reiškia dubliavimą. Pavyzdžiui, vietoj to, kad skaičiuotumėte užsakymų skaičių kiekvieną kartą, galite saugoti `orderCount` lauke `customer` dokumente ir atnaujinti jį kiekvieną kartą, kai sukuriamas naujas užsakymas.
**Elasticsearch ar kiti specializuoti įrankiai** – jei darote daug full-text search ar sudėtingų analitinių užklausų, galbūt Elasticsearch ar ClickHouse būtų geresnis pasirinkimas. MongoDB yra puikus dokumentų duomenų bazė, bet ne viskas turi būti daroma joje.
**Batch processing** – jei jums nereikia real-time rezultatų, galite apdoroti duomenis batch’ais naudojant Apache Spark, AWS EMR ar panašius įrankius. Jie gali efektyviau apdoroti labai didelius duomenų kiekius.
Svarbu suprasti, kad optimizacija – tai ne tik kodas. Kartais reikia pakeisti architektūrą, duomenų modelį ar net pasirinkti kitą technologiją. Nebijokite pripažinti, kad MongoDB galbūt nėra tinkamas tam konkrečiam atvejui.
Ir paskutinis, bet ne mažiau svarbus patarimas – monitorinkite savo agregacijas produkcijoje. Nustatykite alertus lėtoms užklausoms, stebėkite tendencijas, žiūrėkite, kaip našumas keičiasi didėjant duomenų kiekiui. Geriau sužinoti apie problemą anksti, nei kai klientai jau skundžiasi lėtu sistemos veikimu.
Aggregation Pipeline optimizacija nėra vienkartinis darbas – tai nuolatinis procesas. Duomenys auga, reikalavimai keičiasi, ir tai, kas veikė gerai praėjusį mėnesį, gali nebetikti šiandien. Bet su teisingais įrankiais, žiniomis ir požiūriu, galite užtikrinti, kad jūsų MongoDB agregacijos veiktų greitai ir efektyviai, nepriklausomai nuo duomenų kiekio.
