Aggregation Pipeline 1: $match, $group, $project, $unwind, $lookup ve $facet
MongoDB'de aggregation pipeline, document'leri sırayla stage'lerden geçirip yeni sonuçlar üretir; $match başta olursa index kullanılır, $unwind ise document sayısını array boyunca çoğaltır. Gerçek çıktılarla aylık ciroyu iki katına çıkaran $unwind hatası, SQL Temelleri'ndeki GROUP BY ve HAVING sonuçlarıyla birebir aynı $group çıktıları, optimizer'ın stage sırasını değiştirmesi, $lookup'ın join stratejisi ve arama sayfası için $facet.
Sürüm notu: Bu yazıdaki örnekler MongoDB 9.0.8 Community Server ve mongosh 2.6 ile çalıştırılmıştır. Bütün sonuçlar MongoDB 8.0.38'de de aynı çıktı; yalnız explain planları farklı, ilgili bölümde gösteriyorum.
Önce Tahmin Et
Bookstore'un (sitedeki serilerin ortak örnek uygulaması, bir online kitapçı) yönetim ekranı aylık ciroyu gösteriyor. SQL Temelleri serisinin window function'lar yazısında aynı veriden 2025 Ocak cirosu 8959.20 çıkmıştı. İki pipeline yazalım; ikisi de iptal edilmemiş siparişlerin total alanını ay bazında topluyor, birinde araya bir $unwind girmiş:
// 07 - Önce Tahmin Et: aylık ciro, 2025'in ilk çeyreği (SQL Temelleri 14: Ocak 8959.20)
const months = { $match: { status: { $ne: "Cancelled" },
orderedAt: { $gte: ISODate("2025-01-01"), $lt: ISODate("2025-04-01") } } };
const month = { $dateTrunc: { date: "$orderedAt", unit: "month" } };
const a = db.orders.aggregate([
months,
{ $unwind: "$items" },
{ $group: { _id: month, revenue: { $sum: "$total" } } },
{ $sort: { _id: 1 } }
]).toArray();
const b = db.orders.aggregate([
months,
{ $group: { _id: month, revenue: { $sum: "$total" } } },
{ $sort: { _id: 1 } }
]).toArray();
A'yı yazan geliştirici "kalemleri açayım, belki sonra kitap bazında da kırarım" diye düşünmüş. İki sütun aynı mı çıkar?
2025-01 A: 16664.70 B: 8959.20
2025-02 A: 27664.60 B: 13727.50
2025-03 A: 44839.10 B: 20367.60
B, SQL'deki sonuçla kuruşu kuruşuna aynı. A neredeyse iki katı, çünkü $unwind her siparişi kalemleri kadar document'e böldü ve her kopya siparişin toplamını taşıyordu: üç kalemli bir siparişin tutarı üç kez sayıldı. SQL'de orders ile order_items'ı join'leyip o.total'ı toplamakla aynı hata. Pipeline'da her stage bir öncekinin çıktısını görür ve $unwind'dan sonra "bir document = bir sipariş" varsayımı artık doğru değil. Bu yazıda aggregation pipeline'ın temel stage'lerini Bookstore verisi üzerinde, SQL Temelleri'ndeki GROUP BY sonuçlarıyla yan yana geçiyoruz. Script'ler uygulama kullanıcısıyla çalışıyor ve hiçbir şey değiştirmiyor; $lookup örnekleri 6. yazının 06-bookstore-indexes.js script'iyle kurulan index'leri varsayıyor.
Pipeline Düşüncesi ve SQL Karşılıkları
Bir SQL sorgusu tek bir bildirimdir; yazım sırası (SELECT önce) ile mantıksal işleme sırası (FROM, WHERE, GROUP BY, HAVING, SELECT, ORDER BY) farklıdır. Aggregation pipeline'da yazdığın sıra işleme sırasıdır: document'ler soldan sağa her stage'den geçer, her stage yeni bir document akışı üretir. Kabaca karşılıklar şöyle:
| SQL | Aggregation stage | Not |
|---|---|---|
WHERE | $match | Başta olursa index kullanır |
GROUP BY + aggregate | $group | _id grup anahtarı |
HAVING | $group'tan sonra $match | Grupları filtreler |
SELECT listesi | $project, $set, $unset | Alan seçer, hesaplar, çıkarır |
ORDER BY, TOP | $sort, $limit | $sort + $limit birlikte optimize edilir |
JOIN | $lookup (+ $unwind) | Sonuç bir array alanı |
CROSS APPLY ile satır açma | $unwind | Array element'i başına bir document |
$project alanları seçip yeni alanlar hesaplar ve listede olmayanları atar; $set (eski adıyla $addFields) mevcut document'e alan ekler ya da değiştirir; $unset alan çıkarır. Günlük işte $set ve $unset daha az sürpriz üretir, çünkü document'in geri kalanını korur.
Yazdığın sıra işleme sırası, ama optimizer bu sırayı sonucu değiştirmeyecek şekilde düzenleyebilir. $match'i bilerek $set'ten sonra yazalım:
// Önce $set, sonra $match yazılmış
const pipeline = [
{ $set: { year: { $year: "$orderedAt" } } },
{ $match: { "customer.customerId": 1 } },
{ $group: { _id: "$year", orders: { $sum: 1 }, spent: { $sum: "$total" } } },
{ $sort: { _id: 1 } }
];
print("plan:", plan(db.orders.explain().aggregate(pipeline)));
db.orders.aggregate(pipeline).forEach(r => print(" ", r._id, "orders", r.orders, "spent", r.spent));
// Pipeline'da hesaplanan bir alana bakan $match, o alanı hesaplayan stage'in önüne geçemez
print("plan:", plan(db.orders.explain().aggregate([
{ $set: { year: { $year: "$orderedAt" } } },
{ $match: { year: 2026 } },
{ $count: "orders" }
])));
plan: GROUP <- PROJECTION_DEFAULT <- PROJECTION_SIMPLE <- FETCH <- IXSCAN then $sort
2025 orders 25 spent Decimal128('21416.29')
2026 orders 14 spent Decimal128('13717.95')
plan: PROJECTION_DEFAULT <- GROUP <- MATCH <- PROJECTION_DEFAULT <- PROJECTION_SIMPLE <- COLLSCAN
plan yardımcısı (script'in başında) explain'deki stage zincirini yazdırıyor. İlk pipeline'da $match $set'in önüne alındı ve 6. yazıdaki { customerId, orderedAt } index'iyle IXSCAN oldu, çünkü customer.customerId $set'in hesapladığı bir alan değil. İkincide filtre $set'in ürettiği year alanına bakıyor; optimizer onu öne alamaz ve bütün collection taranır. Ders: filtreyi mümkünse ham alanlar üzerinde yaz; "2026 yılı" için $year yerine orderedAt üzerinde bir tarih aralığı index'e yarar. Doküman optimizer'ın yaptığı diğer düzenlemeleri de listeliyor: $sort ile $limit'i birleştirmek, ardışık $match'leri tek stage'e indirmek ve $lookup'tan hemen sonra gelen $unwind'ı $lookup'ın içine almak gibi.
MongoDB 8.0'da farkı: sonuçlar ve index kullanımı aynı, ama 8.0.38'in planı PROJECTION_SIMPLE <- FETCH <- IXSCAN then $set, $group, $sort oldu, yani $set ve $group klasik aggregation katmanında çalıştı. 9.0.8 bunları da query engine'in içine taşıdı. İkinci pipeline'da 8.0 yine COLLSCAN yaptı.
$group ve Accumulator'lar
$group document'leri _id ifadesine göre gruplar; her çıktı alanı bir accumulator'dır. SQL Temelleri'nin GROUP BY yazısındaki "kategori başına kitap sayısı, en ucuz ve en pahalı" sorgusunun karşılığı, SQL'de olmayan bir ekle:
// SQL Temelleri 10: COUNT, MIN, MAX ile GROUP BY category_id
db.books.aggregate([
{ $group: {
_id: "$category.categoryId",
name: { $first: "$category.name" },
books: { $sum: 1 },
cheapest: { $min: "$listPrice" },
priciest: { $max: "$listPrice" },
top: { $top: { sortBy: { listPrice: -1 }, output: "$title" } }
} },
{ $sort: { books: -1, _id: 1 } },
{ $limit: 4 }
]).forEach(c => print(String(c._id).padStart(4), (c.name ?? "-").padEnd(16), String(c.books).padStart(3),
c.cheapest.toString().padStart(8), c.priciest.toString().padStart(8), " ", c.top));
// HAVING: $group'tan sonraki $match grupları filtreler
print("customers with 20+ orders:");
db.orders.aggregate([
{ $group: { _id: "$customer.customerId", name: { $first: "$customer.name" }, orders: { $sum: 1 } } },
{ $match: { orders: { $gte: 20 } } },
{ $sort: { orders: -1 } }
]).forEach(c => print(" ", String(c._id).padStart(2), c.name.padEnd(18), c.orders));
// int değerlerin $avg'si double döner
printjson(db.reviews.aggregate([
{ $group: { _id: null, avg: { $avg: "$rating" }, total: { $sum: "$rating" }, reviews: { $count: {} } } },
{ $project: { _id: 0, avg: { $round: ["$avg", 6] }, total: 1, reviews: 1 } }
]).next());
2 Classics 29 200.00 680.50 Emma
3 Science Fiction 9 390.50 660.90 Foundation
11 Programming 9 890.00 1450.00 Design Patterns
5 Fantasy 6 200.50 600.00 The Fellowship of the Ring
customers with 20+ orders:
1 Ada Lovelace 39
3 Grace Hopper 33
32 Ivan Sutherland 30
21 Annie Easley 26
14 John McCarthy 24
2 Alan Turing 22
30 Jean Bartik 22
22 Joan Clarke 21
{
total: 745,
reviews: 181,
avg: 4.116022
}
Sayılar SQL tarafıyla aynı: Classics 29 kitap, 200.00 ile 680.50 arası. İki fark öğretici. SQL'de GROUP BY c.category_id yazıp SELECT'e c.name koymak hata veriyordu; burada kategori adı document'te kopya olarak durduğu için $first ile alınıyor ve join gerekmiyor. $top ise SQL'de ayrı bir window function ya da alt sorgu gerektiren "grubun en pahalı kitabının adı"nı tek accumulator'la veriyor; $topN, $bottom ve $firstN gibi kardeşleri de var. HAVING'in karşılığı $group'tan sonra gelen bir $match. Son çıktı yorum puanlarının ortalaması: SQL Server'da AVG(rating) tamsayı kolon üzerinde 4 dönüyor ve doğru sonuç için cast gerekiyordu; $avg int değerlerde de double döndürüyor, 4.116022 SQL'deki AVG(rating * 1.0) ile aynı.
$unwind: Array'i Satırlara Açmak
"Önce Tahmin Et"teki hatanın doğru kullanımı: kitap bazında satış. Burada $unwind gerekli, çünkü gruplama anahtarı kalemin içinde. Kalem başına tutarı kalemden hesaplıyoruz, siparişin toplamından değil:
// En çok satanlar: kitap başına satılan adet (iptal edilen siparişler hariç)
db.orders.aggregate([
{ $match: { status: { $ne: "Cancelled" } } },
{ $unwind: "$items" },
{ $group: { _id: "$items.bookId", title: { $first: "$items.title" }, units: { $sum: "$items.quantity" },
revenue: { $sum: { $multiply: ["$items.quantity", "$items.unitPrice"] } } } },
{ $sort: { units: -1, _id: 1 } },
{ $limit: 3 }
]).forEach(b => print(String(b._id).padStart(3), b.title.padEnd(22), "units", b.units, " revenue", b.revenue.toString()));
// authors array'i boş olan kitaplar, preserveNullAndEmptyArrays verilmezse $unwind'dan sonra kaybolur
for (const preserve of [false, true]) {
const n = db.books.aggregate([
{ $unwind: { path: "$authors", preserveNullAndEmptyArrays: preserve } },
{ $count: "n" }
]).next().n;
print(`preserveNullAndEmptyArrays: ${preserve} -> ${n} documents (books: ${db.books.countDocuments()})`);
}
26 Dune units 32 revenue 12222.65
35 Nineteen Eighty-Four units 30 revenue 15449.19
23 Animal Farm units 28 revenue 7644.00
preserveNullAndEmptyArrays: false -> 83 documents (books: 77)
preserveNullAndEmptyArrays: true -> 85 documents (books: 77)
Ciro quantity * unitPrice ile kalem içinden hesaplandı; 4. yazıda kalemdeki fiyatı sipariş anının snapshot'ı olarak saklamamızın karşılığı burada. İkinci çıktı $unwind'ın sessiz davranışı: 77 kitap, çok yazarlılar yüzünden 83 document'e çıktı, ama yazarı olmayan iki kitap (Beowulf ve Binbir Gece Masalları, authors: []) tamamen kayboldu. SQL'deki INNER JOIN ile LEFT JOIN farkı gibi: preserveNullAndEmptyArrays: true boş ya da eksik array'li document'i bir kez, alan olmadan geçirir ve 85 olur. "Yazar başına kitap sayısı" raporunda bu iki kitabı kaybetmek isteyip istemediğine karar vermek senin işin.
$lookup: Basit ve Pipeline'lı
4. yazıda modeli birlikte okunan veriyi gömecek şekilde kurduk ve $lookup'ın yerini raporlar ve seyrek okumalar olarak belirledik. Kategori bazında ciro tam böyle bir rapor: sipariş kaleminde kategori yok, kitaba bakmak gerekiyor.
// Sipariş kaleminde kategori yok: onun için kitaba bak
db.orders.aggregate([
{ $match: { status: { $ne: "Cancelled" }, orderedAt: { $gte: ISODate("2025-01-01"), $lt: ISODate("2026-01-01") } } },
{ $unwind: "$items" },
{ $lookup: { from: "books", localField: "items.bookId", foreignField: "_id", as: "book" } },
{ $unwind: "$book" },
{ $group: { _id: { $ifNull: ["$book.category.name", "(none)"] },
revenue: { $sum: { $multiply: ["$items.quantity", "$items.unitPrice"] } } } },
{ $sort: { revenue: -1 } },
{ $limit: 3 }
]).forEach(c => print(c._id.padEnd(16), c.revenue.toString().padStart(10)));
// Pipeline'lı $lookup: her kitabın yalnız son 2 yorumu, yalnız sayfanın ihtiyaç duyduğu alanlar
printjson(db.books.aggregate([
{ $match: { _id: 26 } },
{ $lookup: {
from: "reviews",
localField: "_id",
foreignField: "bookId",
pipeline: [
{ $sort: { reviewedAt: -1 } },
{ $limit: 2 },
{ $project: { _id: 0, rating: 1, by: "$customer.name" } }
],
as: "latestReviews"
} },
{ $project: { _id: 0, title: 1, latestReviews: 1 } }
]).next());
Classics 74029.97
Science Fiction 41803.66
Programming 38160.00
{
title: 'Dune',
latestReviews: [
{
rating: 5,
by: 'Adele Goldberg'
},
{
rating: 5,
by: 'Frances Allen'
}
]
}
$lookup'ın sonucu her zaman bir array; tek bir kitap beklediğimiz için hemen arkasından $unwind geliyor ve optimizer bu ikisini tek stage'de birleştiriyor. İkinci örnek 5.0'dan beri desteklenen kısa yazım: localField/foreignField eşitliğinin yanına bir alt pipeline. Yabancı collection'dan bütün yorumları çekip sonra kırpmak yerine sıralama, limit ve projection yorumların tarafında çalışıyor. Bu, 5. yazıdaki subset pattern'in alternatifi: subset kopyayı yazarken tutuyor, pipeline'lı $lookup her okumada hesaplıyor. Kitap sayfası gibi sık okunan bir ekran için subset, nadir açılan bir ekran için $lookup.
Join'in nasıl yürütüldüğü explain'de görünüyor. Bütün kitapları yorumlarıyla birleştiren $lookup'ı reviews.bookId index'iyle ve index'siz çalıştırdım:
with index : IndexedLoopJoin docsExamined 258 keysExamined 181
without index: HashJoin docsExamined 258 keysExamined 0
İki durumda da planda EQ_LOOKUP stage'i var. Index varken her kitap için index'te arama yapıldı (IndexedLoopJoin). Index yokken 181 yorum bir kez okunup eşleştirme bellekte yapıldı (HashJoin). Bu boyutta ikisi de ucuz, ama bellekteki tablo yabancı collection büyüdükçe büyür ve doküman, foreignField üzerinde index yoksa eşitlik $lookup'ının büyük olasılıkla kötü performans göstereceği konusunda açıkça uyarıyor. Pipeline'lı $lookup bu motorda çalışmıyor, onun için de foreignField index'i aynı ölçüde önemli.
$facet: Arama Sayfası Tek Sorguda
Arama sayfası bir sonuç listesi, toplam sayı ve kenar çubuğunda filtre sayıları (kategori, dil, fiyat aralığı) gösteriyor. Bunu beş ayrı sorguyla yapmak yerine $facet aynı girdi üzerinde birden çok alt pipeline çalıştırıyor:
// 07 - $facet: arama sayfası için tek sorgu: sonuçlar, toplam ve filtre sayıları
const result = db.books.aggregate([
{ $match: { isActive: true, listPrice: { $lte: Decimal128("600") } } },
{ $facet: {
results: [
{ $sort: { listPrice: 1, _id: 1 } },
{ $limit: 3 },
{ $project: { _id: 0, title: 1, listPrice: 1 } }
],
total: [{ $count: "books" }],
byCategory: [{ $sortByCount: { $ifNull: ["$category.name", "(none)"] } }, { $limit: 3 }],
byLanguage: [{ $sortByCount: "$language" }],
byPrice: [{ $bucket: { groupBy: "$listPrice", boundaries: [0, 300, 450, 601], default: "other",
output: { books: { $sum: 1 } } } }]
} }
]).next();
results: Crime and Punishment (200.00), Peter Pan (200.00), A Wizard of Earthsea (200.50)
total: 53
byCategory: Classics 25, Science Fiction 6, Science 5
byLanguage: en 50, tr 3
byPrice: [0..) 14, [300..) 17, [450..) 22
Tek istekte sayfanın bütün verisi geldi. $sortByCount bir $group ile $sort'un kısaltması, $bucket değerleri verilen sınırlara göre aralıklara dağıtıyor; sınırlara uymayanlar default bucket'ına düşüyor. İki kısıtı akılda tut. Birincisi, $facet'in içindeki alt pipeline'lar index kullanamaz; filtreyi $facet'ten önceki $match'e koy, index orada çalışsın. İkincisi, $facet'in çıktısı tek bir document ve 16 MB sınırına tabi; alt pipeline'larda $limit olmadan büyük sonuç listeleri döndürmek bu sınıra çarpmanın kısa yolu. Gerçek bir arama motorunun relevance ve yazım hatası toleransı için 6. yazıda bahsettiğim MongoDB Search'ün $searchMeta stage'i facet'leri kendi index'inden hesaplıyor.
Üretimde Dikkat
Bellek sınırlarını bil. Her stage için 100 MB sınırı var; allowDiskUseByDefault 6.0'dan beri varsayılan olarak açık olduğundan sınırı aşan $group ve $sort gibi stage'ler geçici dosyalara yazar, sorgu çalışır ama yavaşlar. Diske yazamayan istisnalar da var: $push accumulator'ı 100 MB'ı aşarsa hata verir. 9.0 bunlara sorgu başına bir toplam bellek sınırı ekledi; ayrıntısı ve denemesi 8. yazıda.
Ağır raporları ekranın isteğine bağlama. Aylık ciro ya da kategori raporu her sayfa açılışında bütün siparişleri tarıyorsa, sonucu önceden hesaplayıp bir collection'a yazmak (8. yazıdaki $merge ile materialized view) ya da raporu secondary'den okumak (12. yazı) daha doğru.
Pipeline'ı da explain ile oku. db.collection.explain("executionStats").aggregate(...) index kullanımını, $lookup stratejisini ve hangi stage'lerin query engine'de, hangilerinin klasik katmanda çalıştığını gösterir. Yavaş bir pipeline'da ilk bakılacak yer $match'in başta olup olmadığı ve ilk stage'lerden çıkan document sayısı.
Sık Düşülen Tuzaklar
$unwind + $group ile document sayısını patlatmak. "Önce Tahmin Et"teki gibi $unwind'dan sonra üst seviyedeki toplamları toplamak sonucu çoğaltır; büyük array'lerde ise bellek ve süre de çoğalır. Array'i yalnız gruplama anahtarı array'in içindeyse aç; sipariş seviyesinde bir toplam istiyorsan $sum: "$total" doğrudan ya da array üzerinde $sum, $reduce gibi array ifadeleriyle document'i açmadan hesapla.
$lookup'ı ilişkisel join gibi her istekte kullanmak. Kitap sayfasını her açılışta books, authors, categories ve reviews arasında dört $lookup'la kurmak, 4. yazıda ölçtüğümüz ilişkisel kopyanın aggregation'daki hâli. Sık okunan ekranlar için doğru araç model: gömme, extended reference ya da subset. $lookup raporlar ve seyrek okumalar için.
Filtreyi hesaplanmış alana yazmak. $set ile üretilen bir alana göre filtrelemek optimizer'ın $match'i öne almasını engeller ve index'i devre dışı bırakır; aynı koşulu ham alan üzerinde yazmanın bir yolu varsa onu kullan.
Küçük Bir Deneme
★☆☆ 2025 yılının her ayı için sipariş sayısını, iptal edilen siparişlerin sayısını ve iptal oranını tek bir $group ile hesapla (koşullu toplam için $cond). SQL Temelleri'ndeki conditional aggregation sorgusuyla karşılaştır.
★★☆ Her müşterinin en çok harcama yaptığı kategoriyi bul: kalemleri aç, kitaba $lookup yap, müşteri ve kategori bazında topla, sonra müşteri başına en yüksek olanı $top ile seç. Pipeline'ın hangi kısmının index kullandığını explain ile göster.
★★★ Arama sayfasına "yazar" facet'i ekle (authors array'i, boş array'li kitaplar "(anonim)" olarak sayılsın) ve sonuç listesine sayfalama ekle. Sayfa numarası büyüdükçe $skip'in maliyetini 3. yazıdaki gibi ölç ve $facet yerine iki ayrı sorguya bölmenin ne zaman daha iyi olacağını tartış.
Bir sonraki yazıda pipeline'ın ileri stage'lerine geçiyoruz: $setWindowFields ile running total ve hareketli ortalama (SQL Temelleri'nin window function sonuçlarıyla yan yana), $graphLookup ile kategori ağacı, $densify ve $fill ile boş günler, $merge ile materialized view ve 9.0'ın sorgu başına bellek sınırı.