MongoDB Aggregation Pipeline: Xử Lý, Nhóm và Biến Đổi Dữ Liệu Phức Tạp

Database tutorial - IT technology blog
Database tutorial - IT technology blog

Sáu tháng, hai lần triển khai production — một nền tảng e-commerce xử lý khoảng 50.000 đơn hàng mỗi tháng, một dashboard analytics phân tích dữ liệu sự kiện người dùng. Ngần đó thời gian chạy thực tế là đủ để biết chính xác khi nào aggregation pipeline xứng đáng với vị trí của nó và khi nào nó trở thành cơn ác mộng debug.

Tôi đã làm việc với MySQL, PostgreSQL, và MongoDB qua nhiều dự án khác nhau; cả ba đều có chỗ đứng riêng. Tuy nhiên, aggregation pipeline của MongoDB xử lý việc biến đổi dữ liệu ngay trong database theo cách không ánh xạ gọn gàng sang bất kỳ thứ gì trong thế giới relational. Đây là phiên bản thực tế, không phải phiên bản tài liệu.

Hai Cách Truy Vấn Dữ Liệu Phức Tạp trong MongoDB

Khi mới bắt đầu với MongoDB, find() có vẻ là đủ dùng. Nó nhanh, dễ đọc, và bao phủ 80% trường hợp sử dụng. Nhưng 20% còn lại — nhóm tổng số, tính trung bình, join các collection, định hình lại document — là lúc find() nhanh chóng bộc lộ giới hạn.

Trong thực tế, bạn có ba lựa chọn:

Lựa chọn 1: Kéo dữ liệu vào code ứng dụng rồi xử lý. Lấy document thô, lặp qua chúng trong Python hay Node.js, tính toán aggregation thủ công. Ổn với dataset nhỏ. Sụp đổ hoàn toàn ở quy mô lớn — bạn rốt cuộc phải truyền gigabyte dữ liệu qua mạng chỉ để tính một con số.

Lựa chọn 2: MapReduce. Phương pháp aggregation cũ hơn của MongoDB. Vẫn được hỗ trợ nhưng về bản chất đã bị deprecated — dài dòng, khó debug hơn, chậm hơn với dataset lớn. Tôi đã dùng cái này trong một dự án hồi năm 2021 và mất hai tuần ân hận vì điều đó.

Lựa chọn 3: Aggregation Pipeline. Một chuỗi các stage biến đổi document từng bước một. Mỗi stage nhận đầu vào, xử lý, rồi truyền đầu ra sang stage tiếp theo. Đây là tiêu chuẩn hiện tại và là thứ MongoDB tích cực tối ưu hóa trong mỗi bản phát hành.

Sự so sánh không hề gần nhau. Trên dashboard analytics của chúng tôi, một số báo cáo đã từ chỗ kéo ~800MB dữ liệu sự kiện thô qua mạng xuống còn trả về kết quả 2KB sau khi chuyển logic vào pipeline. Xử lý phía ứng dụng đơn giản không thể cạnh tranh ở quy mô đó.

Ưu và Nhược Điểm Sau 6 Tháng Production

Điều gì hoạt động tốt

Kiến trúc dựa trên stage làm cho các truy vấn phức tạp thực sự có thể debug được. Bạn có thể chạy pipeline đến bất kỳ stage nào và kiểm tra kết quả trung gian — điều mà bạn hoàn toàn không thể làm với một câu SQL đơn hay vòng lặp xử lý phía ứng dụng. Khi có gì đó sai, bạn biết chính xác cần nhìn vào đâu.

Sử dụng index rất vững chắc. Các stage $match đặt sớm trong pipeline có thể dùng index có sẵn, nghĩa là pipeline được cấu trúc đúng trên các trường được đánh index có hiệu suất tương đương với các câu SQL tương đương.

Độ linh hoạt thực sự cũng rất cao. Bạn có thể định hình lại document, tính toán các trường dẫn xuất, unwind mảng, join collection với $lookup, và xử lý object lồng nhau — tất cả trong một truy vấn. Trong PostgreSQL tôi sẽ dùng CTE hoặc window function; pipeline của MongoDB xử lý dữ liệu dạng document biểu cảm hơn với nhiều schema thực tế.

Điều gì không hoạt động tốt bằng

Cú pháp dài dòng. Một aggregation phức tạp vừa phải thường dài gấp 2-3 lần so với SQL tương đương. Lần đầu tiên tôi nhìn thấy một pipeline 15 stage trong codebase, tôi phải đọc đến lần thứ ba mới hiểu được luồng dữ liệu.

Giới hạn bộ nhớ sẽ cắn bạn. Theo mặc định, mỗi stage trong pipeline bị giới hạn ở 100MB RAM. Với dataset lớn, bạn cần allowDiskUse: true, điều này có ảnh hưởng thực sự đến hiệu suất — không phải cài đặt miễn phí bạn có thể bật lên mà không cần suy nghĩ.

Debug pipeline nhiều stage dưới áp lực thời gian rất đau khổ. Khi pipeline cho kết quả không mong đợi, truy vết vấn đề qua 10+ stage đòi hỏi nỗ lực thực sự. Cuối cùng tôi đã dùng $addFields như công cụ debug chính — thêm field tạm thời để kiểm tra giá trị trung gian, rồi xóa đi trước khi deploy.

Thiết Lập Khuyến Nghị

Trước khi viết bất kỳ truy vấn aggregation nào, một số quyết định thiết lập rất quan trọng:

Đánh index các trường $match trước. Một aggregation pipeline bắt đầu với $match trên trường không có index sẽ thực hiện full collection scan. Điều này đã phá hủy hiệu suất truy vấn analytics của chúng tôi cho đến khi chúng tôi thêm compound index trên các trường status và date.

Đặt $match và $sort sớm nhất có thể. Các stage này được hưởng lợi từ index. Bất kỳ stage biến đổi nào trước chúng đều vô hiệu hóa tối ưu hóa index cho các thao tác đó.

Dùng explain() trước khi deploy bất kỳ aggregation mới nào. explain("executionStats") của MongoDB cho thấy liệu các stage pipeline có dùng index hay phải dùng COLLSCAN. Chạy cái này trước khi deploy bất cứ thứ gì mới — chỉ mất 30 giây và đã phát hiện ra nhiều vấn đề mà sẽ rất đau khổ ở quy mô lớn.

db.orders.explain("executionStats").aggregate([
  { $match: { status: "completed", createdAt: { $gte: ISODate("2024-01-01") } } },
  { $group: { _id: "$customerId", total: { $sum: "$amount" } } }
])

Chỉ đặt allowDiskUse: true cho các truy vấn analytics xử lý tập kết quả lớn một cách có chủ đích. Với các truy vấn OLTP, việc cần đến nó hầu như luôn báo hiệu một index còn thiếu hoặc vấn đề cấu trúc trong pipeline.

Hướng Dẫn Triển Khai

Các Loại Stage Cốt Lõi

Sáu stage bao phủ 90% các trường hợp sử dụng thực tế. Hãy học thuộc lòng những stage này trước khi đụng đến bất cứ thứ gì khác:

  • $match — lọc document (tương đương SQL WHERE)
  • $group — nhóm document và tính toán các giá trị aggregate (tương đương SQL GROUP BY)
  • $project — định hình lại document, bao gồm hoặc loại trừ các trường
  • $sort — sắp xếp kết quả
  • $limit / $skip — phân trang
  • $lookup — join collection khác (tương đương SQL LEFT JOIN)

Nhóm và Tính Toán Aggregate

Phổ biến nhất, bạn muốn tổng, đếm hoặc trung bình theo từng nhóm. Đây là pattern:

// Top 10 khách hàng theo tổng doanh thu
db.orders.aggregate([
  { $match: { status: "completed" } },
  {
    $group: {
      _id: "$customerId",
      totalRevenue: { $sum: "$amount" },
      orderCount: { $sum: 1 },           // dùng { $count: {} } trên MongoDB 5.0+
      avgOrderValue: { $avg: "$amount" },
      lastOrder: { $max: "$createdAt" }
    }
  },
  { $sort: { totalRevenue: -1 } },
  { $limit: 10 }
])

MongoDB xử lý các trường document lồng nhau và mảng tự nhiên hơn SQL khi dữ liệu của bạn không phẳng — một lợi thế thực sự trong các schema hướng document.

Làm Việc với Mảng bằng $unwind

Một điểm mà aggregation của MongoDB vượt trội SQL hoàn toàn là dữ liệu dạng mảng. Stage $unwind phân rã một trường mảng thành các document riêng lẻ, cho phép aggregation theo từng phần tử.

// Đếm số đơn hàng chứa mỗi sản phẩm
db.orders.aggregate([
  { $unwind: "$items" },
  {
    $group: {
      _id: "$items.productId",
      orderCount: { $sum: 1 },
      totalQuantity: { $sum: "$items.quantity" }
    }
  },
  { $sort: { orderCount: -1 } }
])

Không có $unwind, bạn phải kéo toàn bộ collection vào bộ nhớ ứng dụng để lặp qua các mảng. Với một collection có một triệu đơn hàng, mỗi đơn trung bình 5-10 mặt hàng, đó là sự khác biệt giữa một aggregation 10ms và một lần truyền dữ liệu mất vài giây.

Join Collection với $lookup

$lookup là cách MongoDB xử lý JOIN. Dài dòng hơn SQL, nhưng hiệu quả cho các quan hệ một-nhiều:

// Bổ sung thông tin khách hàng vào đơn hàng
db.orders.aggregate([
  { $match: { status: "completed" } },
  {
    $lookup: {
      from: "customers",
      localField: "customerId",
      foreignField: "_id",
      as: "customerDetails"
    }
  },
  { $unwind: "$customerDetails" },
  {
    $project: {
      orderId: "$_id",
      amount: 1,
      customerName: "$customerDetails.name",
      customerEmail: "$customerDetails.email"
    }
  }
])

Một lưu ý tôi gặp phải trong production: hành vi của $lookup với index trên collection ngoài phụ thuộc vào phiên bản MongoDB của bạn. Dùng dạng pipeline của $lookup (có từ MongoDB 3.6+) để kiểm soát index tốt hơn trên các join phức tạp.

Pipeline Thực Tế: Báo Cáo Doanh Thu Hàng Tháng

Đây là pattern tôi thường xuyên sử dụng cho các báo cáo theo thời gian:

db.orders.aggregate([
  // Bước 1: Lọc theo khoảng thời gian liên quan
  {
    $match: {
      createdAt: { $gte: ISODate("2024-01-01"), $lt: ISODate("2025-01-01") },
      status: { $in: ["completed", "shipped"] }
    }
  },
  // Bước 2: Trích xuất tháng từ ngày
  {
    $addFields: {
      month: { $month: "$createdAt" },
      year: { $year: "$createdAt" }
    }
  },
  // Bước 3: Nhóm theo năm-tháng
  {
    $group: {
      _id: { year: "$year", month: "$month" },
      revenue: { $sum: "$amount" },
      orders: { $sum: 1 },
      uniqueCustomers: { $addToSet: "$customerId" }
    }
  },
  // Bước 4: Tính số khách hàng duy nhất từ tập hợp
  {
    $addFields: {
      uniqueCustomerCount: { $size: "$uniqueCustomers" }
    }
  },
  { $project: { uniqueCustomers: 0 } },
  { $sort: { "_id.year": 1, "_id.month": 1 } }
])

Tương đương trong PostgreSQL cần một CTE và ít nhất một window function. Pipeline của MongoDB ánh xạ trực tiếp đến các bước logic — mỗi stage có một nhiệm vụ rõ ràng duy nhất, giúp review dễ hơn dù cú pháp dài hơn.

Dùng Python với PyMongo

Hầu hết các aggregation production của tôi chạy qua PyMongo, nơi pipeline chỉ là một list các dict — dễ dàng xây dựng động dựa trên bộ lọc người dùng:

from pymongo import MongoClient

client = MongoClient("mongodb://localhost:27017/")
db = client["ecommerce"]

pipeline = [
    {"$match": {"status": "completed"}},
    {"$group": {
        "_id": "$customerId",
        "total": {"$sum": "$amount"},
        "count": {"$sum": 1}   # dùng {"$count": {}} trên MongoDB 5.0+
    }},
    {"$sort": {"total": -1}},
    {"$limit": 20}
]

results = list(db.orders.aggregate(pipeline, allowDiskUse=True))
for r in results:
    print(f"Khách hàng {r['_id']}: ${r['total']:.2f} với {r['count']} đơn hàng")

Xây dựng pipeline dưới dạng cấu trúc dữ liệu Python có nghĩa là bạn có thể unit test từng stage riêng lẻ. Điều đó thôi đã tiết kiệm nhiều giờ debug CI trong sáu tháng qua — điều mà các chuỗi SQL thô đơn giản không cho phép.

Kết Luận

Aggregation pipeline là công cụ phù hợp cho công việc biến đổi dữ liệu trong MongoDB. Sau sáu tháng, khuyến nghị cốt lõi của tôi là: học thuộc lòng sáu stage nền tảng trước khi dùng bất kỳ helper ODM hay lớp trừu tượng nào. Các helper này trừu tượng hóa đi chính xác những thứ bạn cần hiểu khi có gì đó hỏng.

Hầu hết các lỗi aggregation mà tôi debug đều bắt nguồn từ hai pattern. Thứ nhất, đặt $group trước $match — điều này phá hủy việc sử dụng index và biến một truy vấn 10ms thành một full scan mất vài giây. Thứ hai, aggregation trên các trường mảng mà không unwind trước — điều này làm phình các số đếm theo cách trông có vẻ hợp lý cho đến khi bạn kiểm tra chéo.

Đến từ SQL? Ánh xạ mỗi stage với một mệnh đề truy vấn: $match → WHERE, $group → GROUP BY, $sort + $limit → ORDER BY + LIMIT. Sự dài dòng là có thật và bạn sẽ không bao giờ yêu thích nó. Nhưng với dữ liệu dạng document có mảng lồng nhau và schema động, khả năng debug và tính linh hoạt của pipeline làm cho nó trở thành sự đánh đổi đúng đắn.

Share: