Skip to main content

Command Palette

Search for a command to run...

MongoDB Aggregation Pipeline chuyên sâu

Updated
77 min read

Note: Đây là nội dung đã được em/mình trình bày tại sự kiện Meetup của MongoDB User Group (Hanoi) ngày 18/10/2025. Link sự kiện: https://www.meetup.com/hanoi-mongodb-user-group/events/311403827/

Tổng quan Aggregation pipeline

1. Aggregation Pipeline là gì?

Aggregation Pipeline (hay còn gọi là Aggregation Framework) là một tính năng mạnh mẽ trong MongoDB. Nó cho phép bạn thực hiện các tác vụ phân tích dữ liệu phức tạp và khối lượng công việc xử lý dữ liệu trực tiếp trên database server.

Về bản chất, Pipeline hoạt động bằng cách truyền dữ liệu qua một chuỗi các giai đoạn (stage) có thứ tự. Mỗi stage thực hiện một thao tác cụ thể trên dữ liệu đầu vào trước khi truyền toàn bộ kết quả đầu ra của nó đến stage tiếp theo.

Bạn có thể hình dung Aggregation Pipeline như một băng chuyền xử lý dữ liệu có khả năng tùy chỉnh cao:

  1. Dữ liệu thô của bạn được đưa vào đầu băng chuyền.

  2. Đi qua các stage để được lọc, biến đổi, nhóm, sắp xếp.

  3. Và cuối cùng cho ra kết quả mong muốn.

Đặc tính này thể hiện tư duy lập trình hàm (functional programming), nơi các stage là các câu lệnh không trạng thái (stateless). Toàn bộ đầu ra của một stage tạo thành toàn bộ đầu vào của stage kế tiếp.

2. Lợi ích cốt lõi của Aggregation Pipeline

Aggregation Pipeline mang lại nhiều ưu thế vượt trội so với các phương pháp xử lý dữ liệu truyền thống, đặc biệt là trong môi trường dữ liệu lớn:

  • In-database analytics: Đây là một lợi ích cốt lõi, cho phép bạn phân tích dữ liệu ngay trên database server. Việc này giúp tránh phải di chuyển dữ liệu sang các hệ thống phân tích bên ngoài (như Apache Spark hay Hadoop), từ đó tiết kiệm đáng kể thời gian, băng thông và chi phí.

  • Đảm bảo tính chính xác và cập nhật: Khi truy vấn và phân tích dữ liệu trực tiếp trên database, bạn luôn làm việc với dữ liệu mới nhất, đảm bảo kết quả phân tích có tính chính xác và cập nhật cao.

  • Khả năng biến đổi dữ liệu mạnh mẽ và linh hoạt: Aggregation Framework cung cấp một tập hợp phong phú các stageoperator, cho phép bạn thực hiện các phép biến đổi phức tạp, từ lọc, nhóm, sắp xếp đến join với các collection khác.

  • Tối ưu hóa hiệu năng cao: Vì mỗi stage có một mục đích rõ ràng và cụ thể (ví dụ: lọc hay nhóm), công cụ database có thể hiểu rõ ý định chính xác của từng stage. Nhờ kiến thức này, database engine có cơ hội tự động tối ưu hóa pipeline trong thời gian chạy, ví dụ như việc quyết định sử dụng index nào hoặc sắp xếp lại thứ tự các stage.

3. So sánh Aggregation Pipeline với Map-Reduce

Map-Reduce là công cụ mạnh mẽ ban đầu trong MongoDB để xử lý và phân tích dữ liệu. Tuy nhiên, Map-Reduce phức tạp để hiểu và triển khai đúng, thường yêu cầu viết và chạy mã JavaScript, điều này không hiệu quả.

Aggregation Pipeline được đưa vào từ phiên bản MongoDB 2.2 và nhanh chóng trở thành công cụ được ưa chuộng hơn trong hầu hết các trường hợp nhờ những ưu điểm sau:

Đặc điểm Aggregation Pipeline Map-Reduce
Hiệu năng Cao hơn (Được tối ưu hóa và chạy natively trong database, không cần JavaScript engine) Thấp hơn (Phụ thuộc vào JavaScript engine)
Tính dễ sử dụng Dễ hơn (Cú pháp dễ đọc và dễ hiểu, dễ debug) Khó hơn (Cần cung cấp hai hàm JavaScript là mapreduce)
Tính linh hoạt Rất linh hoạt (Cung cấp nhiều stageoperator tích hợp sẵn) Kém linh hoạt hơn (Chủ yếu cho phép tùy chỉnh logic xử lý dữ liệu ở mức độ chi tiết bằng JavaScript)
Tính năng Các stage có tính composability cao Logic tùy chỉnh không rõ ràng, hạn chế cơ hội tối ưu hóa runtime
Khuyến nghị Luôn luôn là câu trả lời đúng để xử lý dữ liệu trong database. Đã bị đánh dấu là không được khuyến khích (deprecated) từ MongoDB 5.0 và có khả năng bị loại bỏ trong phiên bản tương lai.

Map-Reduce hiện nay vẫn có thể hữu ích cho một số tác vụ phân tích rất phức tạp, yêu cầu logic tùy chỉnh chi tiết, nhưng thường được thay thế bằng các khả năng mạnh mẽ và hiệu suất cao của Aggregation Pipeline.

4.Các thành phần chính

Aggregation Pipeline được xây dựng dựa trên sự kết hợp có thứ tự của các stages (Giai đoạn) và sử dụng operators/expressions (toán tử/biểu thức) để định nghĩa logic xử lý dữ liệu bên trong các stage đó.

Stages

Mỗi stage thực hiện một thao tác độc lập trên tập hợp các document. Dữ liệu đầu ra của một stage sẽ là dữ liệu đầu vào cho stage tiếp theo.

Các stage cơ bản (foundational stages)

Đây là các stage thường xuyên được sử dụng nhất trong hầu hết các pipeline:

Stage Chức năng Cú pháp và ứng dụng thực tế
$match Lọc dữ liệu dựa trên điều kiện, tương tự như mệnh đề WHERE trong SQL. Thường được đặt ở đầu pipeline để tối ưu hiệu năng. Ví dụ: Lọc các đơn hàng có trạng thái "completed".{ $match: { status: "completed" } }
$group Nhóm các document dựa trên một hoặc nhiều trường (như GROUP BY trong SQL) và tính toán các giá trị tổng hợp (sử dụng Accumulators). Ví dụ: Nhóm theo khách hàng (customer_id) và tính tổng giá trị đơn hàng.{ \(group: { _id: "\)customer_id", totalAmount: { \(sum: "\)amount" } } }
$project Chọn các trường cần hiển thị, tạo ra các trường mới, hoặc đổi tên trường. Thường được dùng để thay đổi cấu trúc dữ liệu đầu ra. Ví dụ: Chỉ chọn trường customer_nametotal_amount, bỏ qua _id mặc định.{ $project: { _id: 0, customer_name: 1, total_amount: 1 } }
$sort Sắp xếp các document đầu ra dựa trên một hoặc nhiều trường (như ORDER BY trong SQL). Ví dụ: Sắp xếp các đơn hàng theo ngày tạo giảm dần (mới nhất trước).{ $sort: { created_at: -1 } }
$limit Giới hạn số lượng document trả về. Thường được dùng kết hợp với $sort để tìm top-N bản ghi. Ví dụ: Trả về 5 đơn hàng đầu tiên.{ $limit: 5 }
\(set / \)unset \(set (alias của \)addFields) dùng để thêm hoặc chỉnh sửa trường. \(unset dùng để loại bỏ trường khỏi document. Lưu ý: Thường được ưu tiên hơn \)project khi chỉ cần thêm/sửa/xóa một số ít trường để tránh phải liệt kê lại tất cả các trường cũ. Ví dụ \(set: Thêm trường fullName:{ \)set: { fullName: { \(concat: ["\)firstName", " ", "\(lastName"] } } }.Ví dụ \)unset: Xóa trường passwordsecret.{ $unset: [ "password", "secret" ] }

Các stages nâng cao

Đây là các stage cung cấp các khả năng biến đổi, tổng hợp và liên kết dữ liệu phức tạp hơn:

Stage Chức năng Ứng dụng và ví dụ
$unwind Tách các phần tử trong một trường kiểu mảng thành các document riêng biệt. Quan trọng trong việc xử lý dữ liệu mảng, nhưng cần cẩn trọng vì nó có thể làm tăng đáng kể số lượng document đầu ra. Ví dụ: Tách các sản phẩm (products) trong một đơn hàng thành các document riêng biệt.{ \(unwind: "\)products" }
$lookup Thực hiện phép join (Left Outer Join) với một collection khác trong cùng database. Kết quả join được trả về dưới dạng một mảng. Ví dụ: Join collection orders với customers dựa trên customer_id.{ $lookup: { from: "customers", localField: "customer_id", foreignField: "_id", as: "customer_info" } }
$facet Cho phép xử lý nhiều aggregation pipeline song song trong cùng một luồng dữ liệu, cho phép tạo báo cáo tổng hợp theo nhiều chiều (faceted search). Ví dụ: Thực hiện 2 pipeline song song: đếm tổng sản phẩm và tính giá trung bình.{ \(facet: { totalProducts: [ { \)count: "count" } ], averagePrice: [ { \(group: { _id: null, avgPrice: { \)avg: "$price" } } } ] } }
$setWindowFields (MDB 5.0+) Tính toán các giá trị dựa trên một "cửa sổ" các document được sắp xếp và phân vùng (partitionBy, sortBy). Rất quan trọng trong phân tích chuỗi thời gian (time-series). Ví dụ: Tính tổng doanh thu tích lũy (cumulativeRevenue) cho mỗi khách hàng.{ \(setWindowFields: { partitionBy: "\)customerId", sortBy: { orderDate: 1 }, output: { cumulativeRevenue: { \(sum: "\)amount", window: { documents: [ "unbounded", "current" ] } } } } }.
\(merge / \)out Ghi kết quả của pipeline vào một collection mới hoặc collection hiện có. $merge (MDB 4.2+) linh hoạt hơn vì hỗ trợ cả sharded collections và khả năng cập nhật gia tăng (Incremental Analytics). Ví dụ $merge: Ghi kết quả vào order_summary, thay thế nếu khớp và chèn nếu không khớp.

Operators & expressions (toán tử và biểu thức)

Expressions là các khối xây dựng logic cốt lõi trong Aggregation Pipeline, được sử dụng để định nghĩa các toán tử và phép tính phức tạp.

Operators (Toán tử/Hàm)

Operators là các hàm thực hiện các phép tính và biến đổi dữ liệu, được sử dụng trong các stage như $project, $group, $set, và $match (thông qua $expr).

Chức năng Operators nổi bật Ví dụ thực tế (sử dụng trong \(set hoặc \)project)
Toán học \(add, \)multiply, \(divide, \)abs. Tính Tổng tiền: { total: { \(multiply: ["\)price", "$quantity"] } }.
Mảng \(size, \)arrayElemAt, \(filter, \)map, $reduce. Lấy phần tử cuối cùng: { lastElement: { \(arrayElemAt: ["\)array_field", -1] } }.
Logic \(and, \)or, $not. Kiểm tra hai điều kiện: { \(match: { \)and: [ { status: "completed" }, { amount: { $gt: 100 } } ] } }.
Điều kiện \(cond, \)switch, $ifNull. Gán nhãn giá: { label: { \(cond: { if: { \)gt: [ "$price", 100 ] }, then: "high", else: "low" } } }.
Chuỗi \(concat, \)substr, \(toUpper, \)trim. Nối chuỗi họ tên: { fullName: { \(concat: ["\)last_name", ", ", "$first_name"] } }.
Chuyển đổi kiểu \(toInt, \)toDecimal, $toDate. Chuyển chuỗi thành ngày: { order_date: { \(toDate: "\)order_date_string" } }.
Tổng hợp (Accumulators) \(sum, \)avg, \(max, \)min, \(push. (Chỉ dùng trong \)group) Tính trung bình: { avgPrice: { \(avg: "\)price" } }.

Expressions (biểu thức)

Biểu thức là cách bạn viết logic để cung cấp giá trị cho các toán tử và trường. Expressions được cấu thành từ các yếu tố sau:

  1. Field path (đường dẫn trường):

    • Sử dụng để tham chiếu đến giá trị của một trường trong document đang được xử lý.

    • Cú pháp: Bắt đầu bằng dấu $.

    • Ví dụ: "$customer_id".

  2. Literal values (giá trị cố định):

    • Là các giá trị số, chuỗi, boolean, ngày tháng cố định được nhúng trực tiếp trong pipeline.

    • Ví dụ: 100, "completed", true.

  3. Operators:

    • Các hàm biến đổi dữ liệu như đã đề cập ở phần trên.

Variables (biến)

Variables cung cấp thông tin về ngữ cảnh hoặc cho phép lưu trữ giá trị tạm thời.

System variables (biến hệ thống)

Các biến này cung cấp thông tin về môi trường hoặc ngữ cảnh thực thi pipeline:

Biến Mô tả Ứng dụng
$$NOW Trả về thời gian hiện tại của hệ thống. Được dùng kết hợp với \(expr trong \)match để lọc các document dựa trên thời gian hiện tại (ví dụ: người dùng trên 18 tuổi).
$$ROOT Tham chiếu đến toàn bộ document gốc đang được xử lý. Hữu ích trong các stage như \(project hoặc \)replaceRoot.
$$CURRENT Tham chiếu đến trường hiện tại đang được xử lý. Thường được sử dụng trong các operators xử lý mảng như \(map hoặc \)filter.
$$REMOVE Biến marker dùng để hướng dẫn runtime MongoDB loại bỏ hoàn toàn một trường hoặc document khỏi kết quả đầu ra. Rất hữu ích trong \(set hoặc \)project để ẩn các trường nhạy cảm dựa trên điều kiện.
Ví dụ sử dụng $$NOW (Tìm người trên 18 tuổi):

Để lọc những người có dateofbirth (ngày sinh) thỏa mãn điều kiện tuổi >= 18, bạn phải sử dụng $expr bên trong $match vì $match thông thường không hỗ trợ $NOW:

// Tính toán ngày sinh trước 18 năm (18*365.25*24*60*60*1000 là số miligiây trong 18 năm)
{ "$match": {
    "$expr": {
      "\(lt": ["\)dateofbirth", {
        "$subtract": ["$$NOW", 18*365.25*24*60*60*1000]
      }]
    }
  }
}

User-defined variables (biến do người dùng định nghĩa)

Các biến này được tạo ra trong stage \(let hoặc thông qua các tùy chọn let trong \)lookup hay as trong \(map/\)filter.

  • Mục đích: $let cho phép bạn lưu trữ các giá trị trung gian được tính toán để tái sử dụng trong một biểu thức phức tạp hơn, làm cho logic rõ ràng hơn và giảm thiểu việc tính toán lặp lại.

  • Ví dụ:

{
  "$let": {
    // 1. Định nghĩa biến
    "vars": {
      "totalAmount": { "\(multiply": ["\)price", "$quantity"] }
    },
    // 2. Sử dụng biến trong biểu thức chính
    "in": {
      "$cond": {
        "if": { "$gt": [ "$$totalAmount", 100 ] },
        "then": "high",
        "else": "low"
      }
    }
  }
}

5. Phương pháp xây dựng

Aggregation Pipeline được xây dựng dựa trên tư duy lập trình hàm (functional programming), trong đó đầu ra của một bước trở thành đầu vào cho bước tiếp theo. Vì vậy, việc xây dựng Pipeline đòi hỏi một quy trình logic và có hệ thống.

Quy trình xây dựng Pipeline là quá trình phân rã một vấn đề phức tạp thành các thao tác đơn giản, riêng lẻ (stages) và lắp ráp chúng theo một thứ tự logic.

Bước 1: Xác định mục tiêu phân tích

Bước đầu tiên và quan trọng nhất là xác định rõ ràng bạn muốn đạt được điều gì bằng cách sử dụng Pipeline. Mục tiêu rõ ràng sẽ giúp bạn lựa chọn các stageoperator phù hợp.

Các loại mục tiêu phổ biến:

  • Tính toán các chỉ số tổng hợp (ví dụ: Tính tổng doanh thu theo từng tháng).

  • Tìm kiếm tập hợp con của dữ liệu (ví dụ: Tìm 5 khách hàng chi tiêu nhiều nhất).

  • Phân tích hành vi theo thời gian hoặc phân loại dữ liệu (ví dụ: Phân loại sản phẩm theo mức giá).

Ví dụ thực tế (Mục tiêu): Ta muốn tính toán tổng giá trị đơn hàng, số lượng đơn hàng, và ngày mua hàng đầu tiên cho mỗi khách hàng, chỉ xét các đơn hàng diễn ra trong năm 2020.

Bước 2: Lựa chọn các stages và sắp xếp logic

Sau khi xác định mục tiêu, bạn cần chọn các stage cần thiết và sắp xếp chúng theo thứ tự hợp lý, vì dữ liệu sẽ được truyền tuần tự qua từng stage,.

Nguyên tắc sắp xếp logic:

  1. Lọc sớm nhất có thể: Luôn ưu tiên đặt $match (lọc) ở đầu hoặc gần đầu pipeline. Việc này giúp giảm đáng kể lượng dữ liệu cần xử lý ở các stage tiếp theo, giúp tối ưu hiệu năng.

  2. Chuẩn bị dữ liệu: Các stage biến đổi cấu trúc (như $unwind để tách mảng) thường đi trước các stage tổng hợp.

  3. Tổng hợp và nhóm: \(group, \)bucket, hoặc $count là nơi thực hiện tính toán tổng hợp.

  4. Làm sạch và trình bày: \(sort, \)limit, \(project (hoặc \)unset/$set) thường được đặt ở cuối để định hình cấu trúc đầu ra.

Ví dụ thực tế (Sắp xếp Stages cho mục tiêu trên):

  1. $match: Lọc các đơn hàng có orderdate nằm trong năm 2020. (Lọc sớm để giảm dữ liệu).

  2. \(sort (Lần 1): Sắp xếp đơn hàng theo orderdate tăng dần. (Bắt buộc phải có để \)group có thể tìm ra ngày mua đầu tiên bằng toán tử $first),.

  3. $group: Nhóm các đơn hàng theo customer_id.

  4. $sort (Lần 2): Sắp xếp kết quả cuối cùng theo first_purchase_date tăng dần để trình bày,.

  5. \(set / \)unset: Đổi tên trường và loại bỏ các trường không cần thiết.

Bước 3: Sử dụng operators và expressions để biến đổi

Trong mỗi stage (như \(group, \)project, $set), bạn sẽ sử dụng operators (toán tử) và expressions (biểu thức) để định nghĩa logic tính toán và biến đổi dữ liệu,.

Các thành phần Expression quan trọng:

  • Field Path: Tham chiếu giá trị trường (ví dụ: "$customer_id").

  • Operators: Các hàm thực hiện tính toán (ví dụ: \(sum, \)avg, $cond).

  • System Variables: Các biến ngữ cảnh ($$NOW, $$ROOT),.

  • User-Defined Variables: Biến tạm thời được tạo trong $let,.

Ví dụ thực tế (Sử dụng operators trong $group):

Trong stage $group, ta sử dụng các Accumulator Operators sau:

Mục đích Operator sử dụng Mô tả
Ngày mua hàng đầu tiên first_purchase_date: { "\(first": "\)orderdate" } Lấy giá trị đầu tiên của orderdate trong nhóm (do đã được sắp xếp trước),.
Tổng giá trị đơn hàng total_value: { "\(sum": "\)value" } Tính tổng giá trị trường $value cho tất cả đơn hàng trong nhóm.
Tổng số đơn hàng total_orders: { "$sum": 1 } Đếm số lượng document (đơn hàng) trong nhóm.
Lưu chi tiết đơn hàng orders: { "$push": { ... } } Tạo một mảng chứa chi tiết từng đơn hàng trong nhóm.

Bước 4: Ghi kết quả vào collection (tùy chọn)

Nếu mục tiêu phân tích là tạo các báo cáo tổng hợp hoặc Materialized Views (Bảng tổng hợp vật lý) cho các ứng dụng tiêu thụ dữ liệu, bạn có thể sử dụng các stage đầu ra:

  • \(out: Ghi kết quả vào một collection mới hoặc thay thế toàn bộ collection hiện có, \)out chỉ hỗ trợ unsharded collections.

  • \(merge: (Từ MDB 4.2+) Linh hoạt hơn \)out. $merge cho phép ghi kết quả vào collection hiện có hoặc mới, hỗ trợ cả sharded collections, và có thể xác định hành vi khi khớp (whenMatched) hoặc không khớp (whenNotMatched) với document đích,,.

    • Ứng dụng thực tế: $merge rất quan trọng để thực hiện phân tích gia tăng (incremental analytics). Thay vì tính toán lại toàn bộ lịch sử dữ liệu, bạn chỉ tính toán lại dữ liệu mới nhất (ví dụ: ngày hôm nay) và cập nhật vào collection tổng hợp,.

Ví dụ $merge: Ghi kết quả vào daily_orders_summary, thay thế nếu khớp và chèn nếu không khớp.

{
  "$merge": {
    "into": "daily_orders_summary",
    "on": "day",
    "whenMatched": "replace",
    "whenNotMatched": "insert"
  }
}

6. Debug và tối ưu hóa aggregation pipeline (explain())

Đây là bước bắt buộc để đảm bảo pipeline hoạt động hiệu quả trên dữ liệu lớn.

Sử dụng phương thức .explain() sau khi định nghĩa pipeline để xem kế hoạch thực thi của MongoDB.

Cú pháp:db.collection.explain("executionStats").aggregate(pipeline),.

explain("executionStats") là chế độ cung cấp thông tin chi tiết nhất, bao gồm thống kê thực thi thực tế (tổng số khóa được kiểm tra, tổng số documents được kiểm tra, v.v.), giúp bạn xác định các điểm nghẽn hiệu năng.

Các nguyên tắc tối ưu hóa thiết yếu:

Kỹ thuật tối ưu Mô tả chi tiết
Đẩy $match lên đầu Lọc dữ liệu càng sớm càng tốt,. Database engine sẽ cố gắng tự động tối ưu hóa vị trí $match để tận dụng index.
Tận dụng Index Tạo index cho các trường được sử dụng trong \(match\)sort,. Kiểm tra explain() để đảm bảo index được sử dụng,.
Giảm thiểu dữ liệu truyền tải Sử dụng \(project hoặc \)unset sớm để loại bỏ các trường không cần thiết, giảm lượng dữ liệu mà các stage sau phải xử lý.
Tối ưu hóa \(sort \)limit Nếu chỉ cần tập con dữ liệu đầu tiên (ví dụ: top 10), hãy thêm \(limit ngay sau \)sort. Engine sẽ gộp chúng lại, chỉ cần theo dõi top-N record trong bộ nhớ, tránh phải giữ toàn bộ tập dữ liệu trong RAM.
Tránh Anti-pattern Unwind/Regroup Tránh chuỗi \(unwind\)match\(group khi mục tiêu là biến đổi nội bộ mảng của từng document một cách độc lập. Thay vào đó, hãy sử dụng Array Operators (như \)map, \(reduce, \)filter) để tránh đưa vào blocking stage không cần thiết, tăng hiệu suất đáng kể.
Sử dụng allowDiskUse Nếu pipeline yêu cầu bộ nhớ lớn hơn giới hạn 100MB (đặc biệt trong các blocking stage như \(sort\)group), hãy dùng tùy chọn allowDiskUse: true để cho phép ghi dữ liệu tạm thời ra đĩa. Tuy nhiên, điều này sẽ làm giảm hiệu năng đáng kể.

Aggregation pipeline techniques

Thao tác với mảng (array manipulation)

Aggregation Pipeline trong MongoDB cho phép biến đổi dữ liệu mạnh mẽ và linh hoạt. Các kỹ thuật thao tác mảng (array manipulation) là một phần cốt lõi của việc xử lý dữ liệu phức tạp trong MongoDB documents, và việc làm chủ các operator liên quan là chìa khóa để đạt được hiệu năng tối ưu.

Tính toán tổng hợp mảng cơ bản

Các toán tử tổng hợp mảng đơn giản cho phép bạn tính toán các chỉ số thống kê trực tiếp trên trường kiểu mảng mà không cần phải thực hiện các bước biến đổi phức tạp.

Ví dụ: Tính Điểm trung bình cho học sinh ($avg)

Để tính điểm trung bình của một học sinh dựa trên mảng điểm (scores), ta sử dụng toán tử tích lũy \(avg bên trong stage \)project.

Dữ liệu mẫu (collection students):

[
  { "_id": 1, "name": "Alice", "scores": },
  { "_id": 2, "name": "Bob", "scores": }
]

Pipeline:

db.students.aggregate([
  {
    $project: {
      "name": 1,
      "averageScore": { \(avg: "\)scores" } // Sử dụng $avg trực tiếp trên mảng
    }
  }
]);

Kết quả:

Document averageScore
Alice 81.666...
Bob 90

Kỹ thuật xử lý mảng nâng cao (tránh anti-pattern \(unwind/\)group)

Trong MongoDB, một trong những anti-pattern (mô hình phản lập trình) phổ biến và gây tốn kém hiệu năng nhất là sử dụng chuỗi \(unwind\)match$group khi mục tiêu là biến đổi nội dung của mảng trong phạm vi từng document một cách độc lập.

$group là một blocking stage (giai đoạn chặn), nó phải chờ đợi toàn bộ dữ liệu đến, có nguy cơ vượt quá giới hạn bộ nhớ 100MB và làm tăng đáng kể thời gian thực thi.

Để giải quyết vấn đề này, chúng ta nên sử dụng các array operators như \(map, \)reduce, \(filter, và \)setUnion để xử lý mảng in-document (nội tuyến).

Operator Chức năng Tỷ lệ Input:Output Ứng dụng
$map Lặp qua mảng và áp dụng logic để biến đổi từng phần tử, trả về một mảng mới. Nhiều-tới-Nhiều (M:M) Chuyển đổi kiểu dữ liệu, tính toán giá trị mới cho mỗi phần tử.
$reduce Lặp qua mảng và tính toán tích lũy, trả về một giá trị đơn lẻ. Nhiều-tới-Một (M:1) Tính tổng (không theo nhóm), tìm kiếm, nối chuỗi từ mảng.
$filter Trả về một tập hợp con của mảng, giữ lại các phần tử thỏa mãn điều kiện. Nhiều-tới-Ít (M:N) Lọc các phần tử không cần thiết.
$setUnion Trả về một mảng chứa các phần tử duy nhất xuất hiện trong các mảng đầu vào. Hỗ trợ tìm khóa nhóm duy nhất.

Nhóm phần tử mảng mà không cần \(unwind/\)group

Khi cần nhóm các phần tử bên trong mảng của mỗi document dựa trên một khóa và tính tổng (ví dụ: tính tổng số lượng cho mỗi loại mặt hàng), ta có thể kết hợp các toán tử mảng phức tạp.

Kỹ thuật cốt lõi:

  1. Sử dụng $setUnion để trích xuất danh sách các khóa duy nhất (ví dụ: các loại tiền thưởng) từ mảng.

  2. Sử dụng $map bên ngoài để lặp qua danh sách các khóa duy nhất đó.

  3. Bên trong \(map, sử dụng \)filter để lọc các phần tử trong mảng gốc chỉ giữ lại những phần tử khớp với khóa hiện tại.

  4. Sử dụng \(reduce hoặc \)sum (hoặc $size cho việc đếm) trên tập con đã lọc đó để tính tổng/đếm.

Ví dụ thực tế: Tổng hợp tiền thưởng theo loại coin

Giả sử bạn có collection user_rewards ghi lại các lần người chơi nhận thưởng:

{
  "userId": 123456789,
  "rewards": [
    {"coin": "gold", "amount": 25},
    {"coin": "bronze", "amount": 100},
    {"coin": "gold", "amount": 10} // Lặp lại
  ]
}

Mục tiêu là tạo ra một mảng mới hiển thị tổng số tiền (amount) cho mỗi loại coin (gold, bronze).

Pipeline (sử dụng hàm macro arrayGroupBySum):

// Macro (Giả định đã được định nghĩa để tính tổng theo khóa)
/* function arrayGroupBySum(arraySubdocField, groupByKeyField, groupByValueField) { ... } */

var pipeline = [
  {
    "$set": {
      // 1. Trích xuất các khóa duy nhất, 2. Lặp qua các khóa đó, 3. Dùng $reduce để tính tổng
      "coinTypeTotals": {
        "$map": {
          "input": { "\(setUnion": "\)rewards.coin" }, // Lấy danh sách coin duy nhất (gold, bronze)
          "as": "key",
          "in": {
            "id": "$$key",
            "total": {
              "$reduce": {
                "input": "$rewards",
                "initialValue": 0,
                "in": {
                  "$cond": {
                    // Nếu key của phần tử mảng khớp với key đang lặp
                    "if": { "$eq": ["$$this.coin", "$$key"] },
                    // Cộng giá trị hiện tại vào tổng tích lũy
                    "then": { "$add": ["$$value", "$$this.amount"] },
                    "else": "$$value"
                  }
                }
              }
            }
          }
        }
      },
      "_id": "$$REMOVE",
      "rewards": "$$REMOVE",
    }
  }
];

Output:

{
  "userId": 123456789,
  "coinTypeTotals": [
    { "id": "bronze", "total": 100 },
    { "id": "gold", "total": 35 } // 25 + 10
  ]
}

Kỹ thuật này giúp xử lý việc nhóm và tổng hợp phức tạp hoàn toàn trong một stage $set (hoặc $project), tránh việc phải sử dụng các blocking stage như $unwind và $group, từ đó tối ưu hóa hiệu năng đáng kể.

Sắp xếp mảng nội tuyến (in-line array sorting)

Việc sắp xếp mảng trong mỗi document là cần thiết cho các tác vụ như tính toán percentiles (bách phân vị).

Phương pháp cho MongoDB phiên bản 5.2 trở lên

Từ MongoDB 5.2, toán tử $sortArray được giới thiệu để thực hiện sắp xếp mảng nội tuyến một cách hiệu quả và đơn giản.

Ví dụ: Sắp xếp thời gian phản hồi (response times) tăng dần

{
  "$set": {
    "sortedResponseTimesMillis": {
      "$sortArray": {
        "input": "$responseTimesMillis",
        "sortBy": 1 // Sắp xếp tăng dần
      }
    }
  }
}

Kết hợp với các toán tử mới như $percentile và $median (từ MDB 7.0), quá trình tính toán thống kê trở nên rất đơn giản mà không cần bất kỳ hàm macro phức tạp nào.

Phương pháp cho MongoDB phiên bản 5.1 trở xuống (sử dụng macro/hàm)

Đối với các phiên bản cũ hơn (trước 5.2), do không có toán tử \(sortArray gốc, cần phải sử dụng các hàm macro/JavaScript tùy chỉnh (như hàm sortArray() được đề cập trong document) để tạo ra các biểu thức Aggregation phức tạp dựa trên \)reduce$let.

Việc sử dụng \(reduce để mô phỏng thuật toán sắp xếp (chèn từng phần tử vào mảng đã sắp xếp) là cần thiết để tránh việc sử dụng \)unwind/\(sort/\)group. Tuy nhiên, các hàm tùy chỉnh này có thể chậm hơn đáng kể so với $sortArray gốc khi xử lý các mảng lớn do bị giới hạn bởi ngôn ngữ miền aggregation.

// Ví dụ về cấu trúc macro (giả định)
function sortArray(sourceArrayField) {
  return {
    // ... logic phức tạp sử dụng \(reduce và \)let để sắp xếp ...
  };
}

Kỹ thuật này đảm bảo rằng ngay cả các phiên bản MongoDB cũ hơn cũng có thể thực hiện sắp xếp nội tuyến để hỗ trợ các tính toán phức tạp như tìm kiếm phần tử tại vị trí bách phân vị thứ N (arrayElemAtPercentile).

Data type conversions

Trong môi trường dữ liệu thực tế, đặc biệt khi nhập dữ liệu từ bên thứ ba hoặc các hệ thống cũ, các trường có thể được lưu trữ dưới dạng chuỗi (string) thay vì kiểu dữ liệu mạnh (strongly-typed) mà chúng đại diện (ví dụ: ngày tháng, số nguyên, số thập phân). Việc sử dụng Pipeline để chuyển đổi kiểu dữ liệu là một ứng dụng quan trọng, giúp đảm bảo dữ liệu sẵn sàng cho việc phân tích và truy vấn hiệu quả.

Chuyển đổi string sang strong typed

Các toán tử chuyển đổi kiểu được sử dụng chủ yếu trong các stage $set (hoặc $project) để thay thế giá trị chuỗi của trường bằng giá trị đã chuyển đổi.

Toán tử Chức năng Ứng dụng
$toInt Chuyển đổi giá trị sang kiểu Integer (Số nguyên 32-bit). Chuyển đổi số lượng sản phẩm (item_qty) từ chuỗi sang số nguyên.
$toDecimal Chuyển đổi giá trị sang kiểu Decimal128 (Số thập phân chính xác cao). Rất quan trọng cho các giá trị tiền tệ (value) để tránh mất độ chính xác.
$toDate Chuyển đổi giá trị sang kiểu Date (Ngày tháng BSON). Chuyển đổi chuỗi ngày tháng hợp lệ (order_date) sang kiểu Date.

Ví dụ:

Giả sử bạn có dữ liệu đơn hàng (order) với các trường order_date, valueitem_qty đều là kiểu chuỗi.

Pipeline (Sử dụng $set):

db.orders.aggregate([
  {
    "$set": {
      "order_date": { "\(toDate": "\)order_date" }, // Chuyển sang Date
      "value": { "\(toDecimal": "\)value" },       // Chuyển sang Decimal128
      "further_info.item_qty": { "\(toInt": "\)further_info.item_qty" }, // Chuyển sang Int
      // ... Các chuyển đổi khác (ví dụ: Boolean)
    }
  },
  { "$merge": { "into": "orders_typed" } } // Ghi kết quả vào collection mới
]);

Lưu ý về $toBoolNumberDecimal:

  • Boolean conversion: Toán tử \(toBool không được khuyến khích để chuyển đổi chuỗi Boolean (ví dụ: "true", "false") vì nó sẽ chuyển đổi bất kỳ chuỗi không rỗng nào thành true. Thay vào đó, bạn nên sử dụng \)switch hoặc \(cond để so sánh rõ ràng chuỗi với "true" hoặc "false" (sau khi chuyển sang chữ thường bằng \)toLower).

  • Độ chính xác cao: Việc sử dụng \(toDecimal là bắt buộc cho các giá trị tiền tệ để đảm bảo kết quả tính toán (ví dụ: \)sum) không bị mất độ chính xác (floating-point precision).

Xử lý ngày tháng thiếu thông tin ($dateFromString và $let)

Trong nhiều trường hợp, chuỗi ngày tháng (date string) bị thiếu thông tin quan trọng như định dạng ngày tháng không chuẩn, thiếu thông tin thế kỷ (chỉ có 2 chữ số) hoặc thiếu thông tin múi giờ. Trong những trường hợp này, toán tử $toDate không thể hoạt động vì nó yêu cầu chuỗi hợp lệ.

Bạn cần sử dụng toán tử \(dateFromString để kiểm soát định dạng, kết hợp với các toán tử \)let$concat để chuẩn hóa chuỗi ngày tháng trước khi chuyển đổi.

Ví dụ:

Giả sử bạn nhận được trường paymentDate có định dạng không đầy đủ và không chuẩn hóa (ví dụ: 01-JAN-20 01.01.01.123000000).

Kỹ thuật xử lý:

  1. Xác định thiếu sót: Chuỗi này thiếu thế kỷ (20 có thể là 1920 hoặc 2020), thiếu múi giờ, và tên tháng (JAN) không phải là số.

  2. Chuẩn hóa bằng \(concat\)switch:

    • Sử dụng $let để định nghĩa biến tạm thời (ví dụ: txt cho chuỗi gốc, month cho 3 ký tự tháng).

    • Sử dụng \(switch (hoặc \)cond) để ánh xạ tên tháng (ví dụ: "JAN") sang số tháng tương ứng (ví dụ: "01").

    • Sử dụng $concat để ghép các phần của chuỗi gốc với thông tin đã biết (ví dụ: thêm 20 để cố định thế kỷ).

  3. Chuyển đổi: Dùng $dateFromString với định dạng khớp với chuỗi đã được chuẩn hóa.

Pipeline (Sử dụng \(set\)let):

db.payments.aggregate([
  { "$set": {
    "paymentDate": {
      "$let": {
        "vars": {
          "txt": "$paymentDate", // Biến tạm lưu chuỗi gốc
          "month": { "\(substrCP": ["\)paymentDate", 3, 3] }, // Trích xuất 3 ký tự tháng
        },
        "in": {
          "$dateFromString": {
            "format": "%d-%m-%Y %H.%M.%S.%L", // Định dạng khớp với chuỗi mới
            "dateString": {
              "$concat": [
                { "$substrCP": ["$$txt", 0, 3] }, // Ngày
                { "$switch": { "branches": [ // Ánh xạ tên tháng sang số tháng
                  { "case": { "$eq": ["$$month", "JAN"] }, "then": "01" },
                  // ... Các tháng khác
                  { "case": { "$eq": ["$$month", "DEC"] }, "then": "12" },
                ], "default": "ERROR" } },
                "-20", // Hardcode thế kỷ 20XX
                { "$substrCP": ["$$txt", 7, 15] } // Phần thời gian
              ]
            }
          }
        }
      }
    }
  } },
  { "$unset": ["_id"] }
]);

Kỹ thuật này cho thấy cách Aggregation Pipeline có thể xử lý các nhiệm vụ biến đổi dữ liệu phức tạp bằng cách kết hợp nhiều operators và expressions lại với nhau. Việc sử dụng $let giúp định nghĩa các giá trị tạm thời (txt, month), tránh việc phải lặp lại các phép tính $substrCP nhiều lần trong biểu thức.

Join & linking trong aggregation pipeline

Các stage liên quan đến việc liên kết dữ liệu bao gồm: \(lookup (cho phép join giữa các collection) và \)graphLookup (cho phép duyệt đồ thị).

Join cơ bản 1:1 hoặc 1:N với $lookup (sử dụng localField/foreignField)

Stage $lookup thực hiện phép Left Outer Join với một collection khác trong cùng database. Đây là cú pháp cơ bản nhất để nối dữ liệu giữa hai collection dựa trên sự khớp của một cặp trường giá trị.

Cú pháp cơ bản:

Tham số Mô tả
from Tên của collection đích (foreign collection) để join tới.
localField Tên trường trong collection nguồn (nơi pipeline đang chạy).
foreignField Tên trường trong collection đích (from).
as Tên trường mới trong document kết quả, chứa các document đã join (luôn là một mảng).

Ví dụ: join đơn hàng với chi tiết sản phẩm (One-to-One join)

Giả sử ta có collection orders (chứa product_id) và collection products (chứa id, name, category). Mục tiêu là lấy tên và danh mục sản phẩm cho mỗi đơn hàng.

Dữ liệu Mẫu (orders): { customer_id: "elise_smith@myemail.com", orderdate: ISODate("2020-05-30T08:35:52Z"), product_id: "a1b2c3d4", value: NumberDecimal("431.43") }

Pipeline:

db.orders.aggregate([
  // 1. Join orders với products dựa trên product_id/id
  {
    $lookup: {
      from: "products",
      localField: "product_id",
      foreignField: "id",
      as: "product_mapping", // Kết quả join là một mảng
    }
  },
  // 2. Vì đây là join 1:1, ta lấy phần tử đầu tiên của mảng (dùng $first - yêu cầu MDB 4.4+)
  {
    $set: {
      "product_mapping": { \(first: "\)product_mapping" },
      // Hoặc dùng \(arrayElemAt: ["\)product_mapping", 0] cho MDB < 4.4
    }
  },
  // 3. Trích xuất các trường mong muốn lên cấp cao nhất
  {
    $set: {
      "product_name": "$product_mapping.name",
      "product_category": "$product_mapping.category",
    }
  },
  // 4. Loại bỏ các trường không cần thiết
  { $unset: ["_id", "product_id", "product_mapping"] }
]);

Stage \(lookup luôn trả về kết quả là một mảng. Do đó, sau khi join 1:1, cần sử dụng toán tử \)first (từ MDB 4.4 trở lên) để trích xuất document duy nhất đó ra khỏi mảng, giúp cấu trúc dữ liệu phẳng hơn.

Join phức tạp: Multi-field join và embedded pipeline

Khi cần thực hiện join dựa trên nhiều hơn một trường hoặc khi cần áp dụng logic lọc/biến đổi phức tạp cho collection đích trước khi join, bạn phải sử dụng tham số pipeline nhúng bên trong $lookup.

Kỹ thuật này sử dụng tham số let để truyền các giá trị từ document nguồn vào pipeline nhúng.

Cơ chế multi-field join

  1. let: Định nghĩa các biến tạm thời ($$ prefix) lấy giá trị từ trường của document nguồn.

  2. pipeline: Chạy một pipeline độc lập trên collection đích (from).

  3. \(match\)expr: Trong pipeline nhúng, sử dụng \(match kết hợp với toán tử \)expr và toán tử logic $and để so sánh các biến ($$) với các trường trong document đích.

Ví dụ: Join 1:N với nhiều trường và lọc điều kiện

Ta muốn join collection products (nguồn) với tất cả các đơn hàng (orders) tương ứng (đích) dựa trên cả product_nameproduct_variation. Đồng thời, chỉ lấy các đơn hàng diễn ra trong năm 2020.

Pipeline (Chạy trên collection products):

var pipeline = [
  {
    $lookup: {
      from: "orders",
      // Định nghĩa các biến để giữ giá trị từ document sản phẩm (nguồn)
      let: {
        "prdname": "$name",      // Biến $$prdname = products.name
        "prdvartn": "$variation", // Biến $$prdvartn = products.variation
      },
      // Pipeline nhúng chạy trên collection "orders"
      pipeline: [
        // 1. Match/Join dựa trên nhiều trường bằng \(expr và \)and
        {
          $match: {
            $expr: {
              $and: [
                { \(eq: ["\)product_name", "$$prdname"] },
                { \(eq: ["\)product_variation", "$$prdvartn"] },
              ],
            },
          },
        },
        // 2. Lọc bổ sung (chỉ lấy đơn hàng trong năm 2020)
        {
          $match: {
            "orderdate": {
              "$gte": ISODate("2020-01-01T00:00:00Z"),
              "$lt": ISODate("2021-01-01T00:00:00Z"),
            },
          },
        },
        // 3. Loại bỏ các trường không cần thiết từ document orders (trước khi join)
        { $unset: ["_id", "product_name", "product_variation"] },
      ],
      as: "orders", // Mảng đơn hàng đã join
    }
  },
  // 4. Chỉ hiển thị các sản phẩm có ít nhất một đơn hàng (tương đương Inner Join)
  { \(match: { "orders": { \)ne: [] } } },
  { $unset: ["_id"] },
];

Kỹ thuật này sử dụng \(expr bên trong \)match để thực hiện phép so sánh phức tạp giữa các trường động (àprdnamevàprdvartn). Pipeline nhúng cũng cho phép lọc dữ liệu sâu hơn (ví dụ: orderdate) và tối ưu hóa dữ liệu đầu ra ngay trong quá trình join.

Duyệt đồ thị (graph traversal) với $graphLookup

Stage $graphLookup cho phép bạn duyệt qua các mối quan hệ đệ quy hoặc phân cấp (hierarchical) giữa các document trong một collection. Nó cực kỳ hữu ích trong các ứng dụng mạng xã hội, quản lý chuỗi cung ứng, hoặc tìm kiếm mối quan hệ cha-con. Stage này được giới thiệu trong MongoDB 3.4.

Cú pháp cơ bản:

Tham số Mô tả
from Collection để duyệt (thường là cùng collection nguồn).
startWith Giá trị (hoặc biểu thức) để bắt đầu duyệt (ví dụ: mảng ID của những người được theo dõi).
connectFromField Trường chứa các liên kết để duyệt tới (ví dụ: followed_by).
connectToField Trường đích để khớp với liên kết (ví dụ: name của người dùng).
as Tên trường mới chứa tất cả các document tìm thấy trong quá trình duyệt.
depthField (Tùy chọn) Trường lưu trữ độ sâu của mỗi document tìm thấy trong quá trình duyệt.

Ví dụ: Tìm mạng lưới kết nối lớn nhất

Giả sử ta có collection users chứa thông tin mạng xã hội, nơi mỗi người dùng có một mảng followed_by (những người theo dõi họ). Mục tiêu là tìm người dùng có mạng lưới kết nối mở rộng lớn nhất.

Pipeline (Chạy trên collection users):

db.users.aggregate([
  // 1. Duyệt đồ thị bắt đầu từ trường 'followed_by'
  {
    "$graphLookup": {
      "from": "users", // Duyệt trong cùng collection
      "startWith": "$followed_by", // Bắt đầu duyệt từ mảng người theo dõi
      "connectFromField": "followed_by", // Liên kết từ trường 'followed_by' (nguồn)
      "connectToField": "name", // Khớp với trường 'name' (đích)
      "depthField": "depth", // Lưu độ sâu tìm kiếm
      "as": "extended_network", // Kết quả là mảng tất cả các kết nối tìm thấy
    }
  },
  // 2. Tính toán quy mô mạng lưới
  {
    "$set": {
      // Đếm số lượng document trong mảng 'extended_network'
      "network_reach": { "\(size": "\)extended_network" },
      // Trích xuất tên của các kết nối mở rộng (dùng $map để chỉ lấy trường 'name')
      "extended_connections": {
        "$map": {
          "input": "$extended_network",
          "as": "connection",
          "in": "$$connection.name",
        }
      },
    }
  },
  // 3. Sắp xếp để tìm người có mạng lưới lớn nhất
  { "$sort": { "network_reach": -1 } },
  { "$unset": ["_id", "followed_by", "extended_network"] }
]);

Kết quả cho thấy \(graphLookup đã tìm ra các mối quan hệ gián tiếp (ví dụ: Carol chỉ được theo dõi trực tiếp bởi 2 người, nhưng mạng lưới mở rộng của cô ấy lại lên đến 8 người, lớn nhất trong nhóm). Điều này minh họa sức mạnh của stage \)graphLookup trong việc phân tích các mối quan hệ không trực tiếp.

Security & views

Aggregation Pipeline được sử dụng rộng rãi để tạo ra các view nhằm kiểm soát truy cập và che giấu dữ liệu nhạy cảm. Việc này có thể áp dụng kiểm soát truy cập ở hai cấp độ: cấp độ document (document-level redaction) và cấp độ trường (field-level security).

Redacted View: Lọc dữ liệu nhạy cảm (document & field redaction)

Redacted View là kỹ thuật tạo View để giới hạn các document được hiển thị (lọc document) và loại bỏ các trường nhạy cảm khỏi document kết quả (lọc trường).

Lọc dữ liệu document theo tính toán động ($match kết hợp $expr và $$NOW)

Các truy vấn lọc thông thường (\(match) dựa trên MQL (MongoDB Query Language). Tuy nhiên, để lọc dựa trên một điều kiện động, chẳng hạn như tính tuổi của người dùng tại thời điểm truy vấn, bạn cần sử dụng toán tử \)expr bên trong $match để tận dụng các biểu thức của Aggregation Framework.

Mục đích: Chỉ hiển thị những người có tuổi từ 18 trở lên.

  • Vấn đề: Để tính tuổi, bạn cần so sánh trường ngày sinh ($dateofbirth) với thời gian hiện tại của hệ thống. Thời gian hiện tại này được cung cấp bởi Biến Hệ thống $$NOW,.

  • Giải pháp: Sử dụng $expr để tính toán ngày sinh phải nhỏ hơn (cũ hơn) ngày hiện tại trừ đi 18 năm.

Ví dụ:

var pipeline = [
  // Lọc ra những người có tuổi >= 18
  {
    "$match": {
      "$expr": {
        // So sánh ngày sinh ($dateofbirth) nhỏ hơn (cũ hơn) ngày hiện tại trừ 18 năm
        "\(lt": ["\)dateofbirth", {
          "$subtract": ["$$NOW", 18*365.25*24*60*60*1000] // 18 năm tính bằng milliseconds
        }]
      }
    },
  },
  // ... các stage khác
];
  • Toán tử \(expr cho phép \)match sử dụng các biến hệ thống như $$NOW và các hàm phức tạp khác, điều mà truy vấn $match thông thường không làm được,.

  • Công thức trên sử dụng giá trị tính bằng millisecond (ms) của 18 năm để so sánh.

Lọc bỏ các trường nhạy cảm ($unset)

Sau khi lọc document, View cần đảm bảo rằng các trường chứa thông tin nhạy cảm (ví dụ: số an sinh xã hội, mật khẩu) bị loại bỏ khỏi kết quả trả về.

  • Stage $unset được sử dụng để loại bỏ các trường cụ thể,.

  • Stage \(unset thường được ưu tiên hơn \)project trong trường hợp này vì nó giúp làm cho ý định của mã rõ ràng hơn và ít dài dòng hơn.

Ví dụ:

// Tiếp tục pipeline ở trên...
[
  // ...
  // Loại bỏ trường '_id' và 'social_security_num' khỏi View
  {"$unset": [
    "_id",
    "social_security_num"
  ]},
];

Field-level security: Bảo mật cấp trường theo role (Programmatic RBAC)

Kỹ thuật này cho phép bạn định nghĩa các quy tắc truy cập có lập trình (programmatic access rules) ngay trong Pipeline của View, dựa trên role của user đang kết nối.

Biến hệ thống $$USER_ROLES (Yêu cầu MongoDB 7.0+)

Từ MongoDB phiên bản 7.0, biến hệ thống $$USER_ROLES đã được giới thiệu để sử dụng trong Pipeline, trả về các vai trò (roles) được gán cho người dùng hệ thống đang thực thi truy vấn.

Cơ chế ẩn/hiện trường động ($set và $$REMOVE)

Để kiểm soát việc hiển thị một trường dựa trên user role, bạn sẽ sử dụng Stage \(set (hoặc \)project) cùng với toán tử điều kiện $cond và biến marker $$REMOVE.

  1. Kiểm tra role: Sử dụng toán tử tập hợp $setIntersection để kiểm tra xem tập hợp các vai trò của người dùng ($$USER_ROLES.role) có chứa bất kỳ vai trò nào được phép truy cập (ví dụ: "Doctor", "Nurse") hay không.

  2. Loại bỏ có điều kiện:

    • Nếu người dùng không có vai trò cần thiết, toán tử $cond sẽ trả về biến $$REMOVE. Biến này là một biến đánh dấu (marker flag system variable) chỉ dẫn cho Aggregation Runtime loại bỏ hoàn toàn trường đó khỏi document kết quả,.

    • Nếu người dùng có vai trò cần thiết, giá trị gốc của trường đó sẽ được giữ lại.

Ví dụ Ứng dụng: Kiểm soát truy cập trường medication

Giả sử chỉ role "Doctor" được phép xem trường medication, và role "Nurse" hoặc "Receptionist" thì không.

var pipeline = [
  // ...
  {"$set": {
    // Ẩn/Hiện trường cân nặng (weight)
    "weight": {
      "$cond": {
        // Nếu tập hợp giao của vai trò người dùng và [Doctor, Nurse] là rỗng (tức là không có quyền)
        "if": {
          "\(eq": [{"\)setIntersection": ["$$USER_ROLES.role", ["Doctor", "Nurse"]]}, []]
        },
        "then": "$$REMOVE", // Loại bỏ trường nếu không có quyền
        "else": "$weight" // Giữ lại giá trị
      }
    },

    // Ẩn/Hiện trường thuốc men (medication)
    "medication": {
      "$cond": {
        // Nếu người dùng KHÔNG phải là Doctor
        "if": {
          "\(eq": [{"\)setIntersection": ["$$USER_ROLES.role", ["Doctor"]]}, []]
        },
        "then": "$$REMOVE", // Loại bỏ trường nếu không phải Doctor
        "else": "$medication" // Giữ lại giá trị
      }
    },
    // Luôn loại bỏ trường _id
    "_id": "$$REMOVE",
  }},
];

Kết quả dự kiến khi truy vấn patients_view:

Role người dùng Trường weight Trường medication
Receptionist (Không có quyền) Bị loại bỏ Bị loại bỏ
Nurse (Chỉ có quyền xem weight) Hiển thị Bị loại bỏ
Doctor (Có tất cả các quyền) Hiển thị Hiển thị

Kỹ thuật này cho phép quản trị viên nhúng logic nghiệp vụ phức tạp vào View để áp dụng các quy tắc kiểm soát truy cập theo role, giảm thiểu chi phí bảo trì so với việc phải tạo ra nhiều View khác nhau cho từng role.

Faceted search & báo cáo

Các Stage $facet, $bucketAuto, $search, và $searchMeta là những công cụ nâng cao, thường được sử dụng để xây dựng các dashboard và giao diện tìm kiếm phức tạp, nơi dữ liệu cần được phân loại và tổng hợp theo nhiều chiều khác nhau.

Phân loại dữ liệu theo nhiều chiều song song ($facet và $bucketAuto)

Tổng quan về $facet (parallel pipelines)

Stage $facet cho phép bạn chạy nhiều aggregation pipeline song song trên cùng một tập hợp dữ liệu đầu vào. Điều này cực kỳ hữu ích khi bạn cần tạo ra một báo cáo tổng hợp hoặc giao diện tìm kiếm phân loại (faceted search) dựa trên nhiều tiêu chí khác nhau (ví dụ: phân loại theo giá, theo xếp hạng, theo thương hiệu).

  • Đặc điểm:

    • Mỗi nhánh bên trong $facet là một pipeline con độc lập, có thể chứa bất kỳ stage nào (trừ một vài ngoại lệ).

    • Kết quả của $facet luôn là một document duy nhất. document này chứa các trường cấp cao nhất, mỗi trường đại diện cho kết quả của một pipeline con (facet). Việc trả về một document duy nhất là cần thiết để tránh làm lẫn lộn các bản ghi từ các phân loại khác nhau.

    • Do kết quả là một document duy nhất, bạn cần đảm bảo kích thước của nó không vượt quá giới hạn 16MB. Tuy nhiên, vì faceted search thường chỉ trả về dữ liệu tổng hợp nhỏ gọn, điều này hiếm khi là vấn đề.

Phân loại tự động ($bucketAuto)

\(bucketAuto là một stage nhóm (grouping stage) tự động phân loại các document vào một số lượng bucket (nhóm) đã định trước. Thay vì yêu cầu bạn xác định ranh giới cho mỗi nhóm (như \)bucket), $bucketAuto tự động tính toán các ranh giới này để phân phối document đồng đều nhất có thể giữa các nhóm.

  • groupBy: Trường để phân loại.

  • buckets: Số lượng nhóm bạn muốn tạo ra.

  • output: Xác định các phép tính tổng hợp (như \(sum, \)avg, $push) được thực hiện trên mỗi nhóm.

Ví dụ: Phân loại sản phẩm theo giá và xếp hạng

Mục tiêu là phân loại một collection sản phẩm theo hai chiều song song: theo khoảng giá và theo khoảng xếp hạng.

Pipeline (Sử dụng \(facet\)bucketAuto):

db.products.aggregate([
  // Group products by 2 facets: 1) by price ranges, 2) by rating ranges
  {"$facet": {
    // Facet 1: Phân loại theo Giá
    "by_price": [
      {"$bucketAuto": {
        "groupBy": "$price",
        "buckets": 3,
        "granularity": "1-2-5", // Sử dụng lược đồ số hóa để phân chia khoảng giá hợp lý hơn
        "output": {
          "count": {"$sum": 1},
          "products": {"\(push": "\)name"},
        },
      }},
      {"\(set": {"price_range": "\)_id"}},
      {"$unset": ["_id"]},
    ],

    // Facet 2: Phân loại theo Xếp hạng
    "by_rating": [
      {"$bucketAuto": {
        "groupBy": "$rating",
        "buckets": 5, // Chia đều thành 5 nhóm xếp hạng
        "output": {
          "count": {"$sum": 1},
          "products": {"\(push": "\)name"},
        },
      }},
      {"\(set": {"rating_range": "\)_id"}},
      {"$unset": ["_id"]},
    ],
  }},
]);
  • Pipeline này chạy hai phân tích nhóm hoàn toàn độc lập (by_priceby_rating) trên cùng một tập dữ liệu đầu vào.

  • Kết quả trả về là một document chứa cả hai trường by_priceby_rating, mỗi trường là một mảng mô tả các nhóm (bucket) đã được tạo ra và số lượng sản phẩm trong mỗi nhóm.

Full-text search ($search và $searchMeta)

Đối với tìm kiếm toàn văn (full-text search) nhanh chóng và dựa trên độ liên quan (relevance-based), bạn nên sử dụng Atlas Search. Atlas Search là dịch vụ của MongoDB Atlas tích hợp công cụ tìm kiếm Lucene trực tiếp cạnh database của bạn, tự động xử lý đồng bộ hóa dữ liệu.

Sử dụng \(search\)searchMeta là phương pháp được khuyến nghị thay vì sử dụng các toán tử truy vấn MongoDB truyền thống như \(text\)regex, đặc biệt khi bạn đang chạy database trên Atlas.

$search: Thực hiện tìm kiếm phức hợp và tính điểm relevancy

Stage $search được sử dụng để thực thi truy vấn tìm kiếm toàn văn.

  • Quy tắc bắt buộc: $search phải là stage đầu tiên trong Aggregation Pipeline.

  • Tìm kiếm phức hợp ($compound): Cho phép bạn kết hợp nhiều điều kiện tìm kiếm bằng các toán tử logic như:

    • must: Các điều kiện phải khớp.

    • should: Các điều kiện nên khớp (ảnh hưởng đến điểm relevancy).

    • mustNot: Các điều kiện không được phép khớp.

    • filter: Các điều kiện lọc cứng không ảnh hưởng đến điểm relevancy.

  • Điểm relevancy (\(meta): Sau khi \)search chạy, bạn có thể sử dụng toán tử $meta để truy xuất điểm liên quan (searchScore) của mỗi document. Điểm này cho thấy document đó khớp với tiêu chí tìm kiếm tốt đến mức nào.

Ví dụ:

Mục tiêu là tìm các đĩa DVD có mô tả liên quan đến chủ đề "hậu tận thế" (apocalyptic) và "hạt nhân" (nuclear) nhưng loại trừ các bộ phim có từ khóa "zombie".

db.products.aggregate([
  // Stage 1: Thực hiện tìm kiếm toàn văn phức hợp
  {"$search": {
    "index": "default", // Tên index Atlas Search đã tạo
    "compound": {
      "must": [
        {"text": { "path": "description", "query": "apocalyptic" }}, // Bắt buộc
      ],
      "should": [
        {"text": { "path": "description", "query": "nuclear survives" }}, // Ưu tiên khớp
      ],
      "mustNot": [
        {"text": { "path": "description", "query": "zombie" }}, // Loại trừ
      ],
      "filter": [
        {"text": { "path": "category", "query": "DVD" }}, // Lọc cứng theo thể loại
      ],
    }
  }},

  // Stage 2: Thêm điểm relevancy vào kết quả
  {"$set": {
    "score": {"$meta": "searchScore"}, // Lấy điểm liên quan
    "_id": "$$REMOVE",
  }},
]);

$searchMeta: Lấy metadata và facet (phân loại) cho tìm kiếm

Stage $searchMeta được sử dụng để truy xuất các siêu dữ liệu (metadata) về kết quả tìm kiếm, bao gồm số lượng kết quả (count) và phân tích theo Facet, mà không cần trả về các document thực tế.

  • Quy tắc bắt buộc: $searchMeta cũng phải là stage đầu tiên trong pipeline.

  • Ứng dụng: Thường dùng để xây dựng các báo cáo tổng hợp nhanh chóng về kết quả tìm kiếm.

  • Phân loại theo Date: Rất hữu ích khi kết hợp với loại dateFacet trong Atlas Search Index để phân tích xu hướng theo thời gian.

Ví dụ: Phân tích tần suất cuộc gọi gian lận theo giờ

Mục tiêu là tìm kiếm các cuộc gọi dịch vụ khách hàng có từ khóa "fraud" (gian lận) trong một ngày cụ thể và phân loại chúng thành các khoảng thời gian 6 giờ để phân bổ nhân viên.

Pipeline (Sử dụng $searchMeta để lấy count và facet):

db.enquiries.aggregate([
  // Stage 1: Tìm kiếm metadata và phân loại (facet)
  {"$searchMeta": {
    "index": "default",
    "facet": {
      "operator": { // Định nghĩa tiêu chí tìm kiếm
        "compound": {
          "must": [
            {"text": { "path": "summary", "query": "fraud" }},
          ],
          "filter": [ // Lọc cứng theo ngày 30/01/2022
            {"range": {
              "path": "datetime",
              "gte": ISODate("2022-01-30"),
              "lt": ISODate("2022-01-31"),
            }},
          ],
        },
      },
      "facets": { // Định nghĩa cấu trúc phân loại (facet)
        "fraudEnquiryPeriods": {
          "type": "date",
          "path": "datetime",
          "boundaries": [ // Định nghĩa các khoảng 6 giờ
            ISODate("2022-01-30T00:00:00.000Z"),
            ISODate("2022-01-30T06:00:00.000Z"),
            ISODate("2022-01-30T12:00:00.000Z"),
            ISODate("2022-01-30T18:00:00.000Z"),
            ISODate("2022-01-31T00:00:00.000Z"),
          ],
        }
      }
    }
  }},
]);
  • Kết quả trả về bao gồm count (tổng số bản ghi khớp) và facet chứa fraudEnquiryPeriods.

  • Facet này hiển thị số lượng cuộc gọi gian lận trong mỗi khoảng thời gian 6 giờ (buckets), giúp ngân hàng dễ dàng nhận diện giờ cao điểm (ví dụ: 6am đến 12pm) để phân bổ nhân sự.

Kết hợp $search và Metadata ($$SEARCH_META)

Nếu bạn cần cả document kết quả tìm kiếm metadata/facets, bạn nên sử dụng stage \(search (thay vì \)searchMeta). Khi $search được thực thi, metadata sẽ được tự động lưu vào Biến Hệ thống $$SEARCH_META.

Bạn có thể truy cập biến này trong các stage tiếp theo để lấy thông tin về Facet hoặc Count:

db.collection.aggregate([
    { "$search": { /* ... tiêu chí tìm kiếm ... */ } },
    { "$set": {
        "metadata": "$$SEARCH_META" // Lấy toàn bộ metadata vào trường mới
    } }
    // ... các stage khác để xử lý kết quả tìm kiếm và metadata
]);

Pipeline optimization

Việc tối ưu hóa Pipeline là cần thiết để đảm bảo hiệu năng cao, đặc biệt khi xử lý các tập dữ liệu lớn. Mặc dù công cụ MongoDB Query Engine có khả năng tự động tối ưu hóa Pipeline trong quá trình chạy, nhưng lập trình viên vẫn cần nắm vững các nguyên tắc cơ bản để thiết kế Pipeline một cách hiệu quả ngay từ đầu.

Tư duy tối ưu hóa: đẩy stage $match lên đầu

Nguyên tắc tối ưu hóa quan trọng nhất là giảm thiểu khối lượng dữ liệu được truyền qua các giai đoạn xử lý.

Nguyên tắc cơ bản: Đặt $match sớm nhất có thể để tận dụng index

  • Lọc sớm: Stage $match (lọc dữ liệu) nên được đặt ở đầu pipeline. Lọc dữ liệu càng sớm càng tốt giúp giảm thiểu lượng dữ liệu cần xử lý ở các stage tiếp theo.

  • Tận dụng index: Khi $match nằm ở đầu pipeline, nó cho phép Aggregation Runtime tận dụng cơ chế query engine của MongoDB. Query Engine sẽ cố gắng sử dụng các index đã tạo cho các trường được lọc để tăng tốc độ truy vấn.

Tối ưu hóa ngữ nghĩa (semantic optimization)

Mặc dù MongoDB Engine sẽ cố gắng tự động sắp xếp lại vị trí của $match để tận dụng index, nhưng có những trường hợp không thể tự động tối ưu hóa nếu việc đó làm thay đổi kết quả của Pipeline.

  • Vấn đề: Nếu một \(match stage lọc dựa trên một trường được tính toán trong một stage trước đó (ví dụ: trường total_revenue được tính trong \)group), thì $match này sẽ bị "kẹt" lại (trapped) và không thể đẩy lên đầu Pipeline. Điều này khiến Index trên các trường dữ liệu gốc không được sử dụng.

  • Giải pháp: Lập trình viên nên tính toán lại điều kiện lọc dựa trên trường dữ liệu gốc.

Ví dụ (tối ưu hóa $match):

Giả sử collection lưu giá trị đơn hàng bằng cents (value). Bạn muốn tìm đơn hàng có giá trị $100 trở lên.

  • Pipeline không tối ưu (SUBOPTIMAL): Lọc trên trường $value_dollars được tính toán.
[
  // 1. Tính toán trường mới (value_dollars)
  {"\(set": { "value_dollars": {"\)multiply": [0.01, "$value"]}, /* cents to dollars */ }},
  // ... các stage khác ...
  // 3. Lọc dựa trên trường tính toán. Stage \(match này bị kẹt lại phía sau \)set.
  {"\(match": { "value_dollars": {"\)gte": 100}, }},
];
  • Pipeline tối ưu (OPTIMAL): Thay đổi điều kiện $match để lọc trên trường $value (cents) gốc, cho phép $match được đẩy lên đầu (hoặc sử dụng index).
[
  // 1. Đẩy \(match lên trước \)set
  {"\(match": { "value": {"\)gte": 10000}, }}, // 100 dollars = 10000 cents
  // 2. Sau đó mới tính toán trường dollars (nếu cần)
  {"\(set": { "value_dollars": {"\)multiply": [0.01, "$value"]}, }},
  // ...
];

Khi kiểm tra bằng .explain(), bạn sẽ thấy Pipeline tối ưu đã được Query Engine sử dụng index trên trường value.

Giảm thiểu lượng dữ liệu truyền qua pipeline

Để tối ưu hóa, hãy đảm bảo rằng bạn chỉ truyền các trường dữ liệu cần thiết qua các stage.

Kỹ thuật:

Sử dụng $project hoặc $unset sớm để chọn chỉ các trường cần thiết, tránh truyền các trường không cần thiết qua các stage tiếp theo. Việc này giúp giảm dung lượng bộ nhớ mà các blocking stages (như $group hoặc $sort) cần sử dụng.

So sánh \(set/\)unset và $project

Trước phiên bản MongoDB 4.2, $project là công cụ duy nhất để định nghĩa các trường đầu ra. Tuy nhiên, $set và $unset (được giới thiệu trong MDB 4.2) là các công cụ thay thế tốt hơn trong hầu hết các trường hợp. $set là bí danh (alias) của $addFields.

Khi nào nên dùng $set và $unset (khuyến nghị chung)

Nên sử dụng \(set\)unset khi bạn muốn giữ lại hầu hết các trường trong tài liệu đầu vào và chỉ muốn thêm, sửa đổi, hoặc loại bỏ một số ít trường.

  • Rõ ràng và linh hoạt hơn: Các stage này làm cho ý định của mã rõ ràng hơn. Quan trọng hơn, chúng giảm nhu cầu phải chỉnh sửa pipeline khi mô hình dữ liệu thay đổi (ví dụ: một trường mới xuất hiện trong dữ liệu đầu vào).

  • Ví dụ (dùng \(set/\)unset - Cấu trúc tốt): Chỉ thêm trường card_type và chuyển đổi kiểu dữ liệu của card_expiry, đồng thời loại bỏ trường _id mà không cần liệt kê tất cả các trường khác.

// GOOD: Ưu tiên dùng \(set và \)unset
[
  {"$set": {
    // Sửa đổi trường (chuyển đổi kiểu) + Thêm trường mới
    "card_expiry": {"\(dateFromString": {"dateString": "\)card_expiry"}},
    "card_type": "CREDIT",
  }},
  {"$unset": [
    // Loại bỏ trường _id
    "_id",
  ]},
]

Nhược điểm của $project (Khi tránh sử dụng)

  • Phức tạp và dài dòng: $project yêu cầu bạn phải chọn giữa bao gồm (inclusion) hoặc loại trừ (exclusion) các trường, không thể làm cả hai trong cùng một stage (chỉ ngoại trừ trường _id).

  • Thiếu linh hoạt khi thay đổi dữ liệu: Nếu bạn sử dụng $project để bao gồm các trường, bạn phải liệt kê tất cả các trường bạn muốn giữ lại. Nếu tài liệu đầu vào có 100 trường và bạn chỉ muốn thêm trường thứ 101, bạn buộc phải liệt kê lại 100 trường cũ. Điều này gây phiền toái nếu mô hình dữ liệu thường xuyên phát triển.

Ví dụ (dùng \(project - Cấu trúc kém): Để đạt được kết quả tương tự như ví dụ trên, \)project buộc phải liệt kê tất cả các trường muốn giữ lại.

// BAD: Dài dòng và không linh hoạt
[
  {"$project": {
    "card_expiry": {"\(dateFromString": {"dateString": "\)card_expiry"}},
    "card_type": "CREDIT",
    // BẮT BUỘC phải liệt kê TẤT CẢ các trường còn lại để giữ chúng
    "card_name": 1,
    "card_num": 1,
    // ... và nhiều trường khác
    "reported": 1,
    "_id": 0, // Loại trừ _id
  }},
]

Khi nào nên dùng $project (ngoại lệ)

Trường hợp ngoại lệ duy nhất nên sử dụng $project là khi cấu trúc tài liệu đầu ra rất khác biệt so với tài liệu đầu vào, và bạn chỉ cần giữ lại một tập hợp con rất nhỏ các trường dữ liệu gốc.

Trong tình huống này, việc liệt kê các trường muốn bao gồm trong \(project sẽ ngắn gọn hơn so với việc liệt kê một danh sách dài các trường cần loại bỏ trong \)unset.

Ví dụ (sử dụng \(project): Khi chỉ cần lấy transaction_date, transaction_amount và tạo trường status, bạn chỉ cần liệt kê 3 trường đó trong \)project.

// GOOD: Khi cần cấu trúc đầu ra rất khác biệt
[
  {"$project": {
    // Chỉ chọn 3 trường và định hình lại cấu trúc
    "transaction_info.date": "$transaction_date",
    "transaction_info.amount": "$transaction_amount",
    "status": {"\(cond": {"if": "\)reported", "then": "REPORTED", "else": "UNREPORTED"}},
    "_id": 0,
  }},
]

=> Luôn ưu tiên sử dụng \(set (hoặc \)addFields) và \(unset để thêm/sửa đổi và loại trừ trường. Chỉ sử dụng \)project khi bạn có yêu cầu rõ ràng về cấu trúc đầu ra hoàn toàn khác biệt, chỉ giữ lại một tập hợp con nhỏ các trường đầu vào.

Phân biệt stages streaming và stages chặn (streaming vs. blocking stages)

Khi MongoDB thực thi một Aggregation Pipeline, engine sẽ kéo các lô (batch) tài liệu từ collection nguồn và cố gắng xử lý chúng theo luồng (stream) qua các stage.

Stages chặn (blocking stages)

Hầu hết các stagestreaming stages và có thể xử lý từng lô dữ liệu ngay lập tức. Tuy nhiên, một số stage bắt buộc phải chặn (block) và chờ đợi toàn bộ dữ liệu từ các lô trước đó tích lũy tại stage này trước khi chúng có thể tiếp tục xử lý.

Các blocking stages chính bao gồm:

  • $sort: Cần xem tất cả các bản ghi đầu vào để đảm bảo thứ tự sắp xếp là chính xác trên toàn bộ tập dữ liệu (toàn cục), chứ không chỉ trong phạm vi từng lô.

  • $group: Cần phải thấy tất cả các bản ghi để đảm bảo các nhóm (group) được tổng hợp đầy đủ và chính xác (ví dụ: tính tổng doanh thu cho tất cả đơn hàng của một khách hàng).

  • Các stage tổng hợp/nhóm khác như \(bucket, \)bucketAuto, \(count, \)sortByCount, và $facet cũng được coi là blocking stages trong ngữ cảnh này.

Hậu quả về bộ nhớ

  • Giới hạn bộ nhớ (100 MB): MongoDB áp đặt giới hạn 100 MB RAM cho mọi blocking stage. Nếu một stage vượt quá giới hạn này, database sẽ báo lỗi.

  • Hiệu năng giảm sút: Blocking stages làm giảm tính đồng thời (concurrency) và tăng đáng kể mức tiêu thụ bộ nhớ, khiến độ trễ (latency) của pipeline bị chậm lại.

  • Sử dụng đĩa (allowDiskUse): Để tránh bị lỗi 100MB, bạn có thể thiết lập tùy chọn allowDiskUse: true cho toàn bộ aggregation. Khi đó, hoạt động sắp xếp hoặc nhóm sẽ ghi dữ liệu tạm thời ra đĩa nếu cần. Tuy nhiên, việc này làm giảm đáng kể hiệu năng và thời gian thực thi có thể tăng lên nhiều lần.

Giảm thiểu bộ nhớ: Kết hợp $sort và $limit

Việc sử dụng $sort một cách không cẩn thận đòi hỏi máy chủ phải có đủ bộ nhớ để chứa toàn bộ dữ liệu đầu vào.

Kỹ thuật tối ưu Top-N

Nếu mục tiêu của bạn là tìm tập hợp con đầu tiên (top-N) của dữ liệu đã sắp xếp (ví dụ: 10 khách hàng chi tiêu nhiều nhất), hãy áp dụng kỹ thuật sau:

  1. Đảm bảo \(sort đứng trước \)limit.

  2. Đặt \(limit ngay sau \)sort.

Cơ chế tối ưu hoá:

Tại thời điểm chạy (runtime), engine Aggregation sẽ gộp \(sort\)limit thành một internal sort stage đặc biệt. Trong quá trình sắp xếp này, engine chỉ cần theo dõi N bản ghi có thứ tự tốt nhất hiện tại trong bộ nhớ. Nó không cần giữ toàn bộ tập dữ liệu trong bộ nhớ để hoàn thành việc sắp xếp.

Ví dụ thực tế (tìm 3 người trẻ nhất):

db.persons.aggregate([
  {"$match": { "vocation": "ENGINEER" }},       // Lọc sớm
  {"$sort": { "dateofbirth": -1 }},            // Sắp xếp ngày sinh giảm dần (tìm người trẻ nhất)
  {"$limit": 3},                               // CHỈ theo dõi 3 bản ghi hàng đầu
  {"$unset": ["vocation", "address"]},
]);

Kỹ thuật này đặc biệt quan trọng trong môi trường Sharding, vì nó giảm thiểu lượng dữ liệu trung gian cần truyền qua mạng giữa các shard và vị trí hợp nhất (merger part).

Chống anti-pattern: \(unwind/\)group

Trong nhiều tình huống, khi bạn chỉ muốn biến đổi hoặc tính toán trên một trường kiểu mảng trong phạm vi từng tài liệu (in-document), việc sử dụng $unwind và $group là một anti-pattern kém hiệu quả.

Anti-pattern: $unwind → $match → $group

  • Mục đích sai: Dùng chuỗi này để sửa đổi hoặc lọc mảng con, sau đó nhóm lại bằng _id.

  • Vấn đề: Việc nhóm lại bằng _id (hoặc null) sẽ kích hoạt blocking stage $group, gây tốn kém bộ nhớ và làm tăng đáng kể thời gian thực thi.

  • Dấu hiệu nhận biết: Pipeline có chứa stage {"\(group": {"_id": "\)_id", ...}}.

Giải pháp tối ưu: Sử dụng array operators

Để xử lý mảng nội tuyến hiệu quả hơn, hãy sử dụng các array operators như \(map, \)reduce, hoặc $filter. Các toán tử này hoạt động trong phạm vi từng document và tránh được việc đưa vào blocking stage.

Ví dụ thực tế: Lọc sản phẩm đắt tiền trong mảng

Mục tiêu: Lọc các sản phẩm trong mảng products của mỗi đơn hàng, chỉ giữ lại những sản phẩm có giá > $15.00.

  • Pipeline kém tối ưu (SUBOPTIMAL): Sử dụng \(unwind/\)group.
[
  {"\(unwind": {"path": "\)products"}},
  {"\(match": {"products.price": {"\)gt": NumberDecimal("15.00")}}},
  {"\(group": {"_id": "\)_id", "products": {"\(push": "\)products"}}}, // BLOCKING STAGE
]
  • Pipeline tối ưu (OPTIMAL): Sử dụng array operator $filter.
[
  // Chỉ sử dụng $set để thay thế mảng 'products' bằng mảng đã lọc
  {"$set": {
    "products": {
      "$filter": {
        "input": "$products", // Mảng đầu vào
        "as": "product",      // Biến tạm cho phần tử
        "cond": {"$gt": ["$$product.price", NumberDecimal("15.00")]}, // Điều kiện lọc
      }
    },
  }},
]

Kỹ thuật Array Operators đảm bảo quá trình xử lý array diễn ra trong một stage, tránh overhead của việc tạo và hợp nhất lại hàng loạt tài liệu trung gian.

Debug chuyên sâu: Sử dụng explain("executionStats")

Để xác định các điểm nghẽn hiệu năng mà database engine không thể tự động tối ưu hóa, việc phân tích kế hoạch thực thi là cực kỳ quan trọng.

Phương pháp xem explain plan:

Bạn nên sử dụng chế độ executionStats vì đây là chế độ cung cấp thông tin chi tiết và đầy đủ nhất:

db.collection.explain("executionStats").aggregate(pipeline)

Phân tích thông số executionStats:

Chế độ executionStats cung cấp các số liệu thực tế về quá trình thực thi pipeline. Các thông số quan trọng cần tập trung bao gồm:

Thông số Ý nghĩa Ứng dụng để tối ưu hóa
winningPlan Kế hoạch thực thi mà MongoDB đã lựa chọn. Xác nhận các stage đã được Query Engine sắp xếp lại (ví dụ: $match được đẩy lên đầu).
totalKeysExamined Tổng số khóa Index đã được kiểm tra. So sánh với totalDocsExamined để đánh giá mức độ tận dụng Index.
totalDocsExamined Tổng số tài liệu (document) thô đã được đọc từ collection. Nếu totalKeysExamined \(\approx\) totalDocsExamined, Index đã được sử dụng hiệu quả.
indexName Tên của Index đã được sử dụng (ví dụ: customer_id_1). Đảm bảo rằng Index phù hợp đã được sử dụng cho \(match\)sort.
totalDocsExamined: 0 Cho thấy truy vấn đã được Covered Index thỏa mãn hoàn toàn, database không cần đọc bất kỳ tài liệu thô nào. Đây là mục tiêu tối ưu hóa cao nhất.

Lưu ý: Nếu bạn thấy một blocking stage như $sort hoặc $group xuất hiện ở đầu pipeline, điều này thường chỉ ra rằng không có Index nào được sử dụng, hoặc Index không đủ điều kiện để tối ưu hóa truy vấn lọc ($match) trước khi sắp xếp/nhóm.

Ứng dụng time-series

Phân tích chuỗi thời gian (Time-Series Analytics) là một lĩnh vực quan trọng, đặc biệt trong các trường hợp sử dụng dữ liệu tài chính hoặc IoT (Internet of Things). Công cụ chính để thực hiện các phân tích này là stage $setWindowFields.

Tính toán cửa sổ (Windowing) với $setWindowFields

Stage \(setWindowFields cho phép bạn thực hiện các phép tính trên một tập hợp con (gọi là "cửa sổ" – window) của các tài liệu. Kết quả tính toán sẽ được gán vào một trường mới trong từng document mà không cần làm thay đổi cấu trúc dữ liệu tổng thể hoặc phải sử dụng \)group.

Stage này thường được dùng để tính toán tích lũy, giá trị trung bình di chuyển (moving average), hoặc so sánh giá trị hiện tại với các giá trị trước đó hoặc sau đó.

Cấu hình cốt lõi:

Cấu trúc của $setWindowFields bao gồm các thành phần sau:

  1. partitionBy: Định nghĩa cách dữ liệu được chia thành các phân vùng (partition). Phép tính cửa sổ sẽ được thực hiện độc lập trong mỗi phân vùng (ví dụ: chia theo ID khách hàng, ID thiết bị).

  2. sortBy: Sắp xếp các tài liệu trong mỗi phân vùng theo một trường, thường là trường ngày tháng (timestamp hoặc orderDate), điều này là bắt buộc.

  3. output: Định nghĩa trường mới được tính toán, sử dụng window operator (ví dụ: \(sum, \)avg, \(integral, \)shift) và quy định phạm vi cửa sổ (window).

Ví dụ: Tính doanh thu tích lũy (cumulative revenue)

Pipeline này tính tổng doanh thu lũy kế (cumulativeRevenue) cho mỗi khách hàng, sắp xếp theo ngày đơn hàng:

{
  "$setWindowFields": {
    "partitionBy": "$customerId", // Phân vùng theo ID khách hàng
    "sortBy": { "orderDate": 1 }, // Sắp xếp theo ngày đơn hàng tăng dần
    "output": {
      "cumulativeRevenue": {
        "\(sum": "\)amount",
        "window": {
          "documents": [ "unbounded", "current" ] // Tính tổng từ document đầu tiên đến document hiện tại
        }
      }
    }
  }
}

Tính toán tích phân ($integral): Ước tính mức tiêu thụ năng lượng

Toán tử $integral (chỉ có thể sử dụng trong $setWindowFields) rất hữu ích trong các trường hợp IoT, đặc biệt khi bạn cần ước tính tổng giá trị tích lũy từ dữ liệu đo tốc độ hoặc công suất.

Cơ chế tích phân quy tắc hình thang (trapezoidal rule)

  • Toán tử $integral trả về một giá trị xấp xỉ cho giá trị tích phân toán học, được tính bằng quy tắc hình thang (trapezoidal rule).

  • Trong ví dụ về IoT, nếu dữ liệu đầu vào là công suất (power) tính bằng Kilowatt (kW), thì năng lượng tiêu thụ (energy consumed) tính bằng Kilowatt-giờ (kWh) chính là diện tích dưới đồ thị công suất (trục Y) so với thời gian (trục X). Quy tắc hình thang ước tính diện tích này.

Ví dụ thực tế: Tính Kilowatt-giờ tiêu thụ

Ví dụ này tính năng lượng (kWh) mà mỗi thiết bị (deviceID) đã tiêu thụ trong khoảng thời gian một giờ trước đó, dựa trên các lần đọc công suất (powerKilowatts).

var pipelineRawReadings = [
  {"$setWindowFields": {
    "partitionBy": "$deviceID",
    "sortBy": {"timestamp": 1},
    "output": {
      "consumedKilowattHours": {
        "$integral": {
          "input": "$powerKilowatts",
          "unit": "hour", // Kết quả tính bằng Kilowatt-giờ
        },
        "window": {
          "range": [-1, "current"], // Phạm vi 1 giờ trước đó
          "unit": "hour", // Đơn vị của phạm vi
        },
      },
    },
  }},
];

Các điểm cần lưu ý:

  • Tận dụng Index: Để tối ưu hóa hiệu năng, một index compound (phức hợp) như {deviceID: 1, timestamp: 1} nên được tạo. Index này sẽ giúp tối ưu hóa việc phân vùng (partitionBy) và sắp xếp (sortBy) trong $setWindowFields, tránh việc phải thực hiện sắp xếp chậm trong bộ nhớ và tránh giới hạn bộ nhớ 100MB.

  • Đơn vị đo lường: Tham số unit: "hour" bên trong $integral định nghĩa rằng đầu ra phải là giờ (Kilowatt-giờ). Nếu sử dụng "minute", kết quả sẽ là Kilowatt-phút.

Xác định ranh giới thay đổi trạng thái ($shift)

Toán tử $shift cho phép bạn truy cập giá trị của một trường từ một document đứng trước hoặc đứng sau tài liệu hiện tại, trong cùng một phân vùng. Điều này cực kỳ hữu ích để so sánh và phát hiện các điểm chuyển đổi hoặc ranh giới trạng thái.

Ví dụ ứng dụng: Phát hiện chu kỳ bật/tắt của thiết bị

Mục tiêu là cô đọng một chuỗi các bản ghi trạng thái liên tục (on hoặc off) thành các tài liệu chỉ đánh dấu thời điểm bắt đầuthời điểm kết thúc của một trạng thái cụ thể.

Kỹ thuật chính:

  1. Dùng \(shift để lấy trạng thái trước đó: Lấy giá trị của trường \)state từ tài liệu trước đó (by: -1) để so sánh với trạng thái hiện tại.

  2. Xác định Ranh giới Bắt đầu: Nếu trạng thái hiện tại (\(state) khác trạng thái trước đó (\)previousState), tài liệu hiện tại đánh dấu thời điểm bắt đầu (startTimestamp) của trạng thái mới.

Pipeline (Sử dụng $setWindowFields lần 1 để tạo biến dịch chuyển):

// Giai đoạn 1: Lấy trạng thái trước đó và trạng thái kế tiếp
{"$setWindowFields": {
    "partitionBy": "$deviceID",
    "sortBy": {"timestamp": 1},
    "output": {
      "previousState": {
        "$shift": {
          "output": "$state",
          "by": -1, // Lấy trạng thái của tài liệu trước
        }
      },
      "nextState": {
        "$shift": {
          "output": "$state",
          "by": 1, // Lấy trạng thái của tài liệu kế tiếp
        }
      },
      // Ngoài ra, có thể lấy timestamp của tài liệu kế tiếp để dùng làm end date
      "nextMarkerDate": {
        "$shift": {
          "output": "$timestamp",
          "by": 1,
        }
      },
    }
}},

// Giai đoạn 2: Đánh dấu thời điểm bắt đầu và kết thúc có điều kiện
{"$set": {
    "startTimestamp" : {
      "$cond": [
        {"\(eq": ["\)state", "$previousState"]}, // Nếu trạng thái không đổi
        "$$REMOVE", // Loại bỏ trường này
        "$timestamp", // Nếu trạng thái thay đổi, đây là thời điểm bắt đầu
      ]
    },
    // ... logic để xác định endTimestamp bằng cách so sánh với $nextState
}},

// Giai đoạn 3: Lọc (filter) chỉ giữ lại các tài liệu đánh dấu ranh giới
{"$match": {
    "$expr": {
      "$or": [
        {"\(ne": ["\)state", "$previousState"]}, // Trạng thái vừa thay đổi (start)
        {"\(ne": ["\)state", "$nextState"]}, // Trạng thái sắp thay đổi (end)
      ]
    }
}},

Lưu ý: Việc sử dụng $shift trong $setWindowFields yêu cầu dữ liệu phải được phân vùng và sắp xếp trước. Nếu không có tài liệu nào tiếp theo trong phân vùng, toán tử $shift sẽ trả về null cho các trường như nextState hoặc nextMarkerDate, giúp xác định trạng thái cuối cùng của thiết bị.

Mở rộng và khả năng chịu tải

Giới thiệu về Materialized Views và thách thức

Aggregation Pipeline không chỉ được sử dụng để trả về kết quả truy vấn tức thời mà còn là công cụ chính để xây dựng các Materialized Views – tức là các collection chứa dữ liệu tổng hợp đã được tính toán sẵn. Kỹ thuật này giúp chuyển đổi các tác vụ phân tích chậm thành các truy vấn đơn giản, nhanh chóng.

Trong các hệ thống có dữ liệu lớn tích lũy qua nhiều năm, việc tạo ra các báo cáo tổng hợp (như tổng doanh thu hàng ngày, trung bình theo tháng) sẽ mất nhiều thời gian hơn theo cấp số nhân khi collection gốc ngày càng lớn. Để khắc phục vấn đề này, các báo cáo tổng hợp cần được tính toán trước và lưu trữ trong một collection riêng biệt, cho phép các ứng dụng truy vấn chúng nhanh chóng.

Kỹ thuật để duy trì các Materialized Views này được gọi là incremental analytics.

Incremental analytics: cơ chế cập nhật gia tăng

Incremental Analytics là phương pháp tối ưu hóa hiệu năng bằng cách chỉ tính toán lại các bản tóm tắt cho dữ liệu mới nhất (hoặc dữ liệu đã thay đổi gần đây), thay vì phải xử lý lại toàn bộ tập dữ liệu gốc. Điều này đảm bảo thời gian tạo báo cáo (report refresh time) được giữ ổn định (constant) dù collection gốc có tăng trưởng đến mức nào.

Công cụ để thực hiện điều này là Stage $merge.

Vai trò trung tâm của stage $merge

Stage $merge (được giới thiệu từ MongoDB 4.2) là stage đầu ra ưu việt, được sử dụng để ghi kết quả của pipeline vào một collection khác.

  • Tính năng chính: Không giống như \(out (chỉ hỗ trợ thay thế toàn bộ collection và chỉ hoạt động với unsharded collections), \)merge cung cấp các tùy chọn chi tiết để xác định hành vi khi khớp (whenMatched) và không khớp (whenNotMatched) với document đích.

  • Hỗ trợ khả năng chịu tải (Scalability): \(merge là lựa chọn tốt hơn vì nó hỗ trợ cả sharded collections (collection phân mảnh), điều mà \)out không làm được.

Ví dụ:

Giả sử bạn cần tạo một collection tóm tắt (daily_orders_summary) chứa tổng giá trị và số lượng đơn hàng cho mỗi ngày giao dịch.

Định nghĩa pipeline và lọc dữ liệu mới nhất:

Đầu tiên, pipeline sử dụng $match để lọc chỉ những đơn hàng thuộc ngày cần cập nhật. Sau đó, $group để tính toán tổng hợp cho ngày đó.

// Pipeline chỉ chạy cho một ngày cụ thể (ví dụ: 01-Feb-2021)
var pipeline = [
  // 1. Match Orders cho một ngày duy nhất (lọc sớm nhất có thể)
  {"$match": {
    "orderdate": {
      "$gte": ISODate("2021-02-01T00:00:00Z"),
      "$lt": ISODate("2021-02-02T00:00:00Z"),
    },
  }},
  // 2. Group và tính tổng cho ngày đó
  {"$group": {
    "_id": null,
    // ... tính total_value, total_orders ...
  }},
  // ... các stage biến đổi khác ...
  // 3. Ghi kết quả bằng $merge
  {"$merge": {
    "into": "daily_orders_summary",
    "on": "day", // Khóa để xác định sự trùng khớp (ngày)
    "whenMatched": "replace", // Nếu bản ghi summary đã tồn tại, thay thế nó
    "whenNotMatched": "insert" // Nếu chưa tồn tại, chèn bản ghi mới
  }},
];

Cơ chế cập nhật bằng $merge:

Trong ví dụ trên, tham số $merge được cấu hình để đảm bảo tính gia tăng:

  • on: "day": MongoDB sử dụng trường day (ngày) làm khóa để kiểm tra xem bản ghi tổng hợp này đã tồn tại trong collection đích chưa.

  • whenMatched: "replace": Nếu bản ghi tồn tại (ví dụ: bạn đang chạy lại pipeline cho ngày 01-Feb-2021), nó sẽ được thay thế hoàn toàn bằng kết quả tính toán mới.

  • whenNotMatched: "insert": Nếu đây là bản tóm tắt cho một ngày mới chưa từng có, bản ghi sẽ được chèn vào.

Lợi ích nâng cao: tính ổn định (idempotency) và xử lý hồi tố

Kỹ thuật Incremental Analytics sử dụng $merge mang lại các lợi ích quan trọng về tính ổn định và khả năng phục hồi dữ liệu:

  • Tính ổn định (idempotency): Với tùy chọn whenMatched: "replace", pipeline là idempotent (có thể chạy lại nhiều lần mà không làm hỏng dữ liệu kết quả). Nếu quá trình tổng hợp thất bại giữa chừng (ví dụ: do lỗi hệ thống), bạn chỉ cần chạy lại pipeline, và nó sẽ tự động tính toán lại, thay thế các bản ghi đã lỗi/thiếu, tạo ra một giải pháp tự phục hồi (self-healing).

  • Xử lý thay đổi hồi tố (retrospective changes): Khi có sự điều chỉnh dữ liệu cũ (ví dụ: một đơn hàng bị thiếu được thêm vào ngày cũ), bạn chỉ cần chạy lại pipeline cho ngày cụ thể đó. $merge sẽ đảm bảo bản ghi tổng hợp cũ được tính toán lại chính xác và được thay thế bằng bản ghi mới nhất, duy trì tính chính xác của báo cáo lịch sử mà không cần tính toán lại toàn bộ dữ liệu của các ngày khác.

Cân nhắc về sharding

Sharding (phân mảnh) không chỉ là một phương pháp hiệu quả để mở rộng cơ sở dữ liệu nhằm chứa nhiều dữ liệu hơn và hỗ trợ thông lượng giao dịch cao hơn. Nó còn giúp mở rộng khối lượng công việc phân tích, cho phép các tác vụ tổng hợp hoàn thành nhanh hơn đáng kể.

Mục tiêu chính là thực thi càng nhiều giai đoạn (stages) của pipeline càng tốt một cách song song trên từng shard có chứa dữ liệu cần thiết.

Cơ chế chia pipeline (pipeline splitting)

MongoDB Aggregation Engine tại thời điểm chạy (runtime) sẽ cố gắng thực thi càng nhiều stage càng tốt trên các shard. Tuy nhiên, một số stage yêu cầu tất cả dữ liệu phải được tổng hợp tại một vị trí duy nhất để có thể hoạt động chính xác (các blocking stages).

Khi công cụ tổng hợp gặp blocking stage đầu tiên trong pipeline, nó sẽ chia pipeline thành hai phần tại chính điểm xảy ra blocking stage đó:

  1. Shards Part (Phần trên Shard): Đoạn đầu tiên của pipeline, chạy song song trên nhiều shard.

  2. Merger Part (Phần Hợp nhất): Phần còn lại của pipeline, thực thi tại một vị trí duy nhất để hợp nhất và hoàn thiện kết quả.

Các giai đoạn chặn (blocking stages) gây ra sự chia tách pipeline chủ yếu là các giai đoạn sắp xếp và nhóm, cụ thể là \(sort\)group (cùng với các stage nhóm khác như \(bucket, \)bucketAuto, \(count, và \)sortByCount).

Shards part

Shards Part là phần pipeline được thực thi đồng thời trên các shard chứa dữ liệu nguồn cần thiết.

Tối ưu hóa trong shards part

Shards Part thực hiện các thao tác tiền xử lý và tính toán một phần nhằm tối ưu hóa quá trình truyền dữ liệu:

  • Lọc và giảm thiểu dữ liệu: Nếu có stage \(match ở đầu pipeline, nó sẽ được đẩy xuống và thực hiện đầu tiên trên từng shard. Nếu \)match sử dụng shard key hoặc tiền tố của shard key, mongos có thể thực hiện targeted operation (thao tác nhắm mục tiêu), chỉ định tuyến công việc đến các shard liên quan, tránh việc phát sóng (broadcasting) đến tất cả các shard.

  • Tính toán từng phần: Khi một blocking stage (ví dụ $group) gây ra sự chia tách, giai đoạn đó được chia làm hai. Giai đoạn đầu tiên của tác vụ chặn sẽ được thực thi như stage cuối cùng của Shards Part.

    • Đối với $group, các shard thực hiện việc nhóm song song, tính toán các tổng và tổng số tích lũy một phần (accumulating partial sums and totals).

    • Đối với $sort, các shard thực hiện sắp xếp một phần song song.

  • Lợi ích: Kỹ thuật này ngăn chặn việc phải chuyển lượng lớn dữ liệu thô, chưa được nhóm, qua mạng từ các shard nguồn đến vị trí hợp nhất.

Chạy song song (parallel execution)

Shards Part cho phép tính toán song song. Nếu collection gốc của bạn được phân mảnh đồng đều trên bốn shard, thời gian xử lý tổng thể của aggregation có thể được giảm gần bốn lần (ví dụ: từ 60 giây xuống 15 giây).

Merger part (phần hợp nhất)

Merger Part là phần còn lại của pipeline, chịu trách nhiệm kết hợp kết quả một phần từ các shard và hoàn thành các phép tính chặn (nếu có).

Hoàn thành tác vụ chặn

  • Hợp nhất \(group: Phần hợp nhất của \)group là một hoạt động chặn (blocking activity) vì nó phải chờ đợi tất cả dữ liệu đến từ các shard mục tiêu để tổng hợp các tổng số đã tính toán một phần, tạo ra kết quả cuối cùng.

  • Hợp nhất \(sort: Giai đoạn hợp nhất cuối cùng của \)sort (gọi là "merge sort") là một hoạt động xử lý theo luồng (streaming fashion) và không phải là hoạt động chặn.

Vị trí thực thi merger part

Vị trí mà Merger Part được thực thi phụ thuộc vào một số quy tắc quyết định. Hai vị trí phổ biến nhất là mongos hoặc một shard được chọn ngẫu nhiên (anyShard).

  • Mongos Merge (Mặc định): Đây là cách tiếp cận mặc định khi Merger Part chỉ chứa các streaming stages. Điều này bao gồm giai đoạn cuối cùng của một $sort đã được chia tách. Hợp nhất trên mongos thường là lựa chọn tối ưu vì nó giảm thiểu các bước truyền dữ liệu qua mạng, giảm độ trễ (latency).

  • Any-Shard Merge (Chủ yếu cho Dữ liệu lớn): Nếu aggregation sử dụng tùy chọn allowDiskUse: true (để tránh giới hạn bộ nhớ 100MB) và Pipeline chứa một grouping stage (hoặc $sort tiếp theo là một blocking stage khác), công cụ runtime sẽ chọn một shard bất kỳ để thực thi Merger Part. Việc này nhằm tối đa hóa khả năng máy chủ có đủ dung lượng lưu trữ để ghi dữ liệu tạm thời ra đĩa, vì máy chủ của shard thường có dung lượng lưu trữ lớn hơn máy chủ của mongos.

  • Targeted-Shard Execution (Không cần Chia tách): Nếu bộ lọc $match có thể đảm bảo rằng Pipeline chỉ khớp với một tập hợp con dữ liệu nằm trên một shard duy nhất (ví dụ: khớp chính xác giá trị shard key), Pipeline sẽ không bị chia tách. Toàn bộ Pipeline sẽ chạy trên shard đó, tránh được chi phí di chuyển dữ liệu trung gian.

Tối ưu hóa pipeline trong môi trường sharding

Mọi nguyên tắc tối ưu hóa thông thường (như đẩy $match lên đầu) đều áp dụng cho sharding, nhưng chúng trở nên quan trọng hơn.

  • Nhắm mục tiêu (targeting): Luôn tìm cơ hội để bao gồm shard key hoặc tiền tố của shard key trong stage $match đầu tiên. Điều này cho phép mongos chỉ định tuyến công việc đến các shard liên quan, tối đa hóa tính song song và giảm tải mạng.

  • Tránh \(unwind\)group không cần thiết: Việc sử dụng array operators thay cho chuỗi \(unwind/\)group giúp Pipeline tránh bị chia tách và có thể xử lý, truyền dữ liệu hiệu quả hơn trực tiếp đến mongos.

  • Tránh allowDiskUse: true: Trừ khi thực sự cần thiết, nên tránh thiết lập allowDiskUse: true khi có \(group hoặc \)sort gây chia tách pipeline. Việc này sẽ buộc merger part chạy trên một shard (any-shard merge) thay vì mongos, dẫn đến nhiều bước truyền dữ liệu hơn và tăng độ trễ.

Tối ưu hóa bằng targeting khi bộ lọc $match bao gồm shard key

Trong sharded cluster, mục tiêu tối ưu hóa hàng đầu là đảm bảo rằng công cụ định tuyến (mongos) có thể định tuyến công việc Aggregation đến chính xác các shard chứa dữ liệu cần thiết, thay vì phát sóng (broadcasting) truy vấn đến tất cả các shard. Kỹ thuật này được gọi là Targeting.

Targeted operation

Khi một router mongos nhận được yêu cầu thực thi một Aggregation Pipeline, nó cần xác định nơi để nhắm mục tiêu phần Shards Part của pipeline.

  • Nếu Stage $match xuất hiện ở đầu pipeline, và bộ lọc này bao gồm shard key hoặc tiền tố của shard key, mongos có thể thực hiện một thao tác nhắm mục tiêu (targeted operation).

  • Khi đó, mongos chỉ định tuyến Shards Part của pipeline đến các shard áp dụng. Việc này giúp tối đa hóa khả năng xử lý song song và giảm tải mạng.

Tối ưu hóa cao nhất: Thực thi trên một shard duy nhất (targeted-shard execution)

Trường hợp tối ưu hóa hiệu năng cao nhất xảy ra khi mongos có thể đảm bảo rằng toàn bộ pipeline chỉ khớp với dữ liệu nằm trên một shard duy nhất.

  • Điều kiện: Nếu bộ lọc $match chứa khớp chính xác (exact match) trên giá trị của shard key cho collection nguồn.

  • Lợi ích: Trong trường hợp này, Pipeline sẽ không bị chia tách thành Shards PartMerger Part. Thay vào đó, toàn bộ pipeline sẽ chạy tại một vị trí duy nhất, trên shard đó.

  • Ý nghĩa Hiệu suất: Tối ưu hóa này giúp tránh việc chia tách Pipeline một cách không cần thiết, nơi dữ liệu trung gian sau đó phải được di chuyển từ shards part sang merger part. Điều này giảm đáng kể độ trễ (latency) và chi phí truyền dữ liệu qua mạng.

Tận dụng tiền tố shard key

Ngay cả khi bộ lọc $match chỉ khớp một phần trên tiền tố của shard key, nếu runtime xác định rằng phạm vi tài liệu khớp nằm gọn trong một chunk duy nhất, hoặc nhiều chunk nhưng chỉ trên một shard duy nhất, thì runtime vẫn sẽ nhắm mục tiêu và thực thi toàn bộ pipeline trên shard đó.

Hãy luôn tìm kiếm cơ hội để bao gồm một stage $match với bộ lọc trên giá trị shard key hoặc tiền tố shard key ở đầu Pipeline. Kỹ thuật này là một trong hai tối ưu hóa hiệu suất cụ thể cần hướng tới trong môi trường sharding.

Slide

Đây là slide của mình trong sự kiện: https://drive.google.com/file/d/12e9l4E2SWu35BMGfW-iwkMQ2Nvmq86hI/view?usp=sharing

Quiz

https://www.quizne.vn/quiz/mongodb-aggregation-pipeline-quiz

More from this blog

Learn DevOps

289 posts