Die Aggregation-Pipeline entfaltet ihre Macht erst in realen
Szenarien. Theoretisches Wissen über $match,
$group und $lookup ist eine Sache – diese
Stages zu kombinieren, um Business-Fragen zu beantworten, ist eine
andere. Eine E-Commerce-Site will “Top-Selling-Products last quarter by
region”. Ein Analytics-Dashboard braucht “User-Engagement-Metrics with
cohort-analysis”. Ein Inventory-System benötigt “Low-Stock-Alerts with
supplier-lead-times”. Diese Requirements übersetzen sich in mehrstufige
Pipelines mit komplexer Logik.
Dieses Kapitel durchläuft praktische Aggregation-Patterns systematisch, von grundlegenden Filtern bis zu Multi-Collection-Joins mit transformierten Daten. Der Fokus ist nicht auf einzelne Stages (das wurde im vorherigen Kapitel behandelt), sondern auf deren Kombination für reale Use-Cases. Jedes Beispiel ist production-ähnlich – keine akademischen Toy-Datasets, sondern realistische Datenstrukturen und Business-Requirements.
Der einfachste Use-Case: “Show me the cheapest 10 products in Electronics category.” Dies ist eine straightforward Pipeline, aber sie illustriert fundamentale Principles.
db.products.aggregate([
// Stage 1: Filter nach Category
{ $match: {
category: "Electronics",
inStock: true
}},
// Stage 2: Sort nach Preis
{ $sort: { price: 1 } },
// Stage 3: Top 10
{ $limit: 10 },
// Stage 4: Projection für Clean Output
{ $project: {
_id: 0,
name: 1,
price: 1,
brand: 1
}}
])Warum diese Reihenfolge:
$match früh reduziert die Datenmenge. Statt alle
Millionen Produkte zu sortieren, sortieren wir nur die paar tausend
Electronics. $sort dann $limit ist effizienter
als umgekehrt – MongoDB optimiert dies intern (muss nicht alle
sortieren, nur genug für Top-10). $project am Ende für
clean Output.
Performance-Consideration:
Wenn ein Index auf { category: 1, price: 1 } existiert,
kann MongoDB diese Pipeline vollständig Index-backed ausführen – IXSCAN
statt COLLSCAN, keine In-Memory-Sort. Dies ist der Unterschied zwischen
Millisekunden und Sekunden bei großen Collections.
Ein häufiger Analytics-Use-Case: “Average rating per product category, sorted by rating.” Dies erfordert Gruppierung und Aggregation.
db.reviews.aggregate([
// Nur approved Reviews
{ $match: { status: "approved" } },
// Group by product category
{ $group: {
_id: "$product.category",
avgRating: { $avg: "$rating" },
reviewCount: { $sum: 1 },
minRating: { $min: "$rating" },
maxRating: { $max: "$rating" }
}},
// Filter: Nur Categories mit mindestens 10 Reviews
{ $match: { reviewCount: { $gte: 10 } } },
// Sort by average rating
{ $sort: { avgRating: -1 } },
// Clean naming
{ $project: {
_id: 0,
category: "$_id",
avgRating: { $round: ["$avgRating", 2] },
reviewCount: 1,
ratingRange: {
$concat: [
{ $toString: "$minRating" },
" - ",
{ $toString: "$maxRating" }
]
}
}}
])Nuancen:
Der zweite $match (nach $group) filtert auf
aggregiertem reviewCount. Dies ist nur möglich nach der
Aggregation – man kann nicht vor $group auf
reviewCount filtern, weil es noch nicht existiert. Die
$round-Expression gibt saubere 2-Decimal-Averages. Die
$concat baut ein Human-Readable-Range-String.
Real-World-Extension:
In Production würde man vermutlich auch Confidence-Intervals berechnen (Standard-Deviation) und vielleicht Recency gewichten (Recent-Reviews höher gewichtet). Dies würde komplexere Math-Operatoren erfordern.
Business-Metriken sind oft zeitbasiert: “Monthly revenue by region for last year.” Dies erfordert Date-Extraction und mehrdimensionale Gruppierung.
db.sales.aggregate([
// Filter: Last year
{ $match: {
saleDate: {
$gte: new Date("2023-01-01"),
$lt: new Date("2024-01-01")
}
}},
// Extract year, month, region
{ $project: {
year: { $year: "$saleDate" },
month: { $month: "$saleDate" },
region: "$customerRegion",
amount: 1
}},
// Group by year, month, region
{ $group: {
_id: {
year: "$year",
month: "$month",
region: "$region"
},
revenue: { $sum: "$amount" },
transactionCount: { $sum: 1 },
avgTransactionValue: { $avg: "$amount" }
}},
// Sort chronologically
{ $sort: {
"_id.year": 1,
"_id.month": 1,
"_id.region": 1
}},
// Reshape for readability
{ $project: {
_id: 0,
year: "$_id.year",
month: "$_id.month",
region: "$_id.region",
revenue: { $round: ["$revenue", 2] },
transactionCount: 1,
avgTransactionValue: { $round: ["$avgTransactionValue", 2] }
}}
])Date-Operators:
MongoDB’s Date-Operatoren ($year, $month,
$dayOfWeek, etc.) sind essentiell für
Time-Series-Aggregations. Sie extrahieren Components aus Dates ohne
komplexe Application-Logic. Für Quarterly-Reports würde man
$ceil({ $divide: [{ $month: "$date" }, 3] }) nutzen.
Performance-Optimization:
Ein Index auf { saleDate: 1, customerRegion: 1 } würde
das initiale $match dramatisch beschleunigen. Ohne Index
scannt MongoDB alle Sales-History.
Single-Collection-Aggregations sind limitiert. Real-World-Systems
sind normalized – Orders referenzieren Customers, Products referenzieren
Categories. $lookup performed Joins.
Scenario: Order-Report mit Customer-Details
db.orders.aggregate([
// Filter: Orders from last month
{ $match: {
orderDate: {
$gte: new Date(Date.now() - 30 * 24 * 60 * 60 * 1000)
},
status: "completed"
}},
// Join mit Customers
{ $lookup: {
from: "customers",
localField: "customerId",
foreignField: "_id",
as: "customer"
}},
// Unwind customer (single document)
{ $unwind: "$customer" },
// Join mit Products für item details
{ $unwind: "$items" },
{ $lookup: {
from: "products",
localField: "items.productId",
foreignField: "_id",
as: "items.productDetails"
}},
{ $unwind: "$items.productDetails" },
// Group zurück zu Orders (nach Unwind)
{ $group: {
_id: "$_id",
orderId: { $first: "$_id" },
customerName: { $first: "$customer.name" },
customerEmail: { $first: "$customer.email" },
orderDate: { $first: "$orderDate" },
items: {
$push: {
productName: "$items.productDetails.name",
quantity: "$items.quantity",
price: "$items.price"
}
},
totalAmount: { $sum: { $multiply: ["$items.quantity", "$items.price"] } }
}},
// Sort by amount
{ $sort: { totalAmount: -1 } },
// Clean output
{ $project: {
_id: 0,
orderId: 1,
customerName: 1,
customerEmail: 1,
orderDate: 1,
itemCount: { $size: "$items" },
items: 1,
totalAmount: { $round: ["$totalAmount", 2] }
}}
])Complexity-Breakdown:
Diese Pipeline ist non-trivial. Sie: 1. Filtert Orders 2. Joint mit Customers (1:1) 3. Unwinds items-Array 4. Joint jedes Item mit Products (1:N) 5. Groupt zurück zu Orders (reverting Unwind) 6. Berechnet totals
Der kritische Teil: $unwind + $lookup +
$group. Wir unwind Items, um jedes einzeln zu joinen, dann
group zurück zu Orders. Dies ist ein Common-Pattern für
Array-of-References.
Performance-Problem:
Diese Pipeline macht viele Lookups. Für 100 Orders mit durchschnittlich 5 Items = 500 Product-Lookups. Bei großen Datasets wird dies langsam. Die Alternative: Denormalisierung – Product-Name direkt in Order-Item embedden. Trade-off: Redundanz vs. Performance.
Das basic $lookup joint alle matching Dokumente.
Manchmal will man nur spezifische oder transformierte Daten.
Pipeline-$lookup erlaubt Sub-Pipelines im Join.
Scenario: Customers mit ihren Top-3-Orders
db.customers.aggregate([
// Filter: Active customers
{ $match: { status: "active" } },
// Join mit Orders - aber nur Top-3
{ $lookup: {
from: "orders",
let: { custId: "$_id" },
pipeline: [
// Match orders für diesen Customer
{ $match: {
$expr: { $eq: ["$customerId", "$$custId"] },
status: "completed"
}},
// Sort by amount
{ $sort: { amount: -1 } },
// Top 3
{ $limit: 3 },
// Project nur nötige Felder
{ $project: {
_id: 1,
amount: 1,
orderDate: 1
}}
],
as: "topOrders"
}},
// Calculate customer metrics
{ $project: {
customerName: "$name",
email: 1,
topOrders: 1,
topOrdersTotal: { $sum: "$topOrders.amount" },
topOrdersAvg: { $avg: "$topOrders.amount" }
}},
// Filter: Only customers mit mindestens 3 Orders
{ $match: {
"topOrders.2": { $exists: true }
}}
])Pipeline-$lookup-Advantages:
Die Sub-Pipeline kann alles – Filter, Sort, Limit, Transformations.
Dies ist mächtiger als basic $lookup. Der
let-Block definiert Variables (hier custId),
die in der Sub-Pipeline via $$custId zugreifbar sind.
Use-Case-Extension:
Für “Customers with no orders in last 90 days” würde man eine
Sub-Pipeline nutzen, die nach Recent-Orders filtert, und dann im Parent
ein $match: { recentOrders: { $size: 0 } }.
Real-World-Aggregations haben oft Business-Logic: “Classify customers as VIP, Regular, or Inactive based on spending.” Dies erfordert Conditional-Operators.
db.customers.aggregate([
// Join mit Orders für Spending-Calculation
{ $lookup: {
from: "orders",
localField: "_id",
foreignField: "customerId",
as: "orders"
}},
// Calculate total spent
{ $addFields: {
totalSpent: { $sum: "$orders.amount" },
orderCount: { $size: "$orders" },
lastOrderDate: { $max: "$orders.orderDate" }
}},
// Classify customer tier
{ $addFields: {
tier: {
$switch: {
branches: [
{
case: { $gte: ["$totalSpent", 10000] },
then: "VIP"
},
{
case: { $gte: ["$totalSpent", 1000] },
then: "Regular"
}
],
default: "Basic"
}
},
activityStatus: {
$cond: {
if: {
$gte: [
"$lastOrderDate",
new Date(Date.now() - 90 * 24 * 60 * 60 * 1000)
]
},
then: "Active",
else: "Inactive"
}
}
}},
// Group by tier für Summary
{ $group: {
_id: "$tier",
customerCount: { $sum: 1 },
avgSpent: { $avg: "$totalSpent" },
activeCount: {
$sum: {
$cond: [{ $eq: ["$activityStatus", "Active"] }, 1, 0]
}
}
}},
{ $sort: { avgSpent: -1 } }
])Conditional-Operators:
$cond: If-Then-Else (2-way)$switch: Multi-Way-Branch (wie switch-statement)$ifNull: Default-Value wenn Field null/missingDiese erlauben komplexe Business-Logic direkt in der Pipeline, ohne Application-Code.
MongoDB 5.0+ hat Window-Functions via $setWindowFields.
Use-Case: “Running total revenue per month” oder “Rank products by sales
within category”.
db.sales.aggregate([
// Filter auf ein Jahr
{ $match: {
saleDate: {
$gte: new Date("2024-01-01"),
$lt: new Date("2025-01-01")
}
}},
// Extract month
{ $addFields: {
year: { $year: "$saleDate" },
month: { $month: "$saleDate" }
}},
// Group by month
{ $group: {
_id: { year: "$year", month: "$month" },
monthlyRevenue: { $sum: "$amount" }
}},
// Sort chronologically
{ $sort: { "_id.year": 1, "_id.month": 1 } },
// Calculate running total
{ $setWindowFields: {
sortBy: { "_id.year": 1, "_id.month": 1 },
output: {
runningTotal: {
$sum: "$monthlyRevenue",
window: {
documents: ["unbounded", "current"]
}
},
movingAvg3Month: {
$avg: "$monthlyRevenue",
window: {
documents: [-2, 0] // Current + 2 previous
}
}
}
}},
// Clean output
{ $project: {
_id: 0,
year: "$_id.year",
month: "$_id.month",
monthlyRevenue: { $round: ["$monthlyRevenue", 2] },
runningTotal: { $round: ["$runningTotal", 2] },
movingAvg3Month: { $round: ["$movingAvg3Month", 2] }
}}
])Window-Functions sind mächtig:
Sie erlauben Calculations über “Windows” von Dokumenten – Running-Totals, Moving-Averages, Rankings, Lead/Lag. Vor 5.0 waren solche Calculations schwierig oder unmöglich in reinem MongoDB.
Manchmal will man Aggregation-Results persistent speichern – etwa für
Daily-Reports, die gecached werden sollen. $out oder
$merge schreibt Results in Collections.
db.orders.aggregate([
{ $match: {
orderDate: {
$gte: new Date("2024-01-01"),
$lt: new Date("2024-02-01")
}
}},
{ $group: {
_id: {
productId: "$items.productId",
category: "$items.category"
},
totalSold: { $sum: "$items.quantity" },
revenue: { $sum: {
$multiply: ["$items.quantity", "$items.price"]
}}
}},
{ $sort: { revenue: -1 } },
// Write results zu Collection
{ $out: "monthly_product_stats_2024_01" }
])$out ersetzt die gesamte Target-Collection. Für
Incremental-Updates nutzt man $merge:
{ $merge: {
into: "product_stats",
on: "_id", // Merge-Key
whenMatched: "replace", // Oder "merge", "keepExisting"
whenNotMatched: "insert"
}}Use-Case:
Materialized-Views für komplexe Aggregations, die oft queried aber selten updated werden. Statt die expensive Pipeline bei jedem Request zu laufen, läuft sie einmal täglich und schreibt Results in eine Collection, die dann schnell query-bar ist.
Ein komplexes Production-Pattern: “360-Degree-Customer-View” – alle Informationen über einen Customer in einem Report.
db.customers.aggregate([
{ $match: { _id: ObjectId("...") } }, // Specific customer
// Order history
{ $lookup: {
from: "orders",
let: { custId: "$_id" },
pipeline: [
{ $match: { $expr: { $eq: ["$customerId", "$$custId"] } }},
{ $sort: { orderDate: -1 } },
{ $limit: 10 }
],
as: "recentOrders"
}},
// Support tickets
{ $lookup: {
from: "support_tickets",
let: { custId: "$_id" },
pipeline: [
{ $match: { $expr: { $eq: ["$customerId", "$$custId"] } }},
{ $group: {
_id: "$status",
count: { $sum: 1 }
}}
],
as: "supportStats"
}},
// Reviews
{ $lookup: {
from: "reviews",
localField: "_id",
foreignField: "customerId",
as: "reviews"
}},
// Calculate derived metrics
{ $addFields: {
lifetimeValue: { $sum: "$recentOrders.amount" },
avgOrderValue: { $avg: "$recentOrders.amount" },
reviewCount: { $size: "$reviews" },
avgRating: { $avg: "$reviews.rating" },
supportTicketCount: { $sum: "$supportStats.count" }
}},
// Clean output
{ $project: {
customerName: "$name",
email: 1,
joinDate: "$createdAt",
lifetimeValue: { $round: ["$lifetimeValue", 2] },
avgOrderValue: { $round: ["$avgOrderValue", 2] },
recentOrders: {
$map: {
input: "$recentOrders",
as: "order",
in: {
orderId: "$$order._id",
date: "$$order.orderDate",
amount: "$$order.amount"
}
}
},
reviewCount: 1,
avgRating: { $round: ["$avgRating", 1] },
supportTicketCount: 1,
supportBreakdown: "$supportStats"
}}
])Diese Pipeline aggregiert Daten aus vier Collections – Customers, Orders, Tickets, Reviews – in einen comprehensive View. Dies ist typisch für CRM-Dashboards oder Customer-Service-Tools.
Die folgende Tabelle fasst Common-Patterns zusammen:
| Pattern | Stages | Use-Case | Performance-Tip |
|---|---|---|---|
| Filter-Sort-Limit | $match → $sort → $limit | Top-N-Queries | Index auf Match+Sort-Felder |
| Group-Aggregate | $group → $sort | Statistics, Reports | $match vor $group |
| Time-Series | $project (date extract) → $group | Temporal Analytics | Index auf Date-Field |
| Basic Join | $lookup → $unwind | Enrich mit Related-Data | Minimize Lookups |
| Filtered Join | $lookup (pipeline) → … | Conditional Joins | Filter in Sub-Pipeline |
| Conditional Logic | addFields(cond/$switch) | Business-Rules | - |
| Window Functions | $setWindowFields | Running-Totals, Rankings | 5.0+ only |
| Materialized View | … → out/merge | Cache Complex-Aggregations | Schedule periodic refresh |
Die Aggregation-Pipeline in der Praxis ist eine Kunst – Trade-offs
zwischen Complexity, Performance und Maintainability. Eine
20-Stage-Pipeline mag technisch correct sein, aber sie ist schwer zu
debuggen und zu optimieren. Die Best Practice: Break complex
Aggregations in Steps – Test jede Stage einzeln mit
.limit(5), prüfe Intermediate-Results, und optimize
Stage-by-Stage. Start mit klarem Business-Requirement, design die
Pipeline logisch, optimize mit Indexes und Explain, und dokumentiere
komplexe Logic für künftige Maintainer. Mit diesem Approach werden
Aggregations von verwirrend zu powerful – capable of Analytics, die
sonst External-Systems erfordern würden.