實戰 MongoDB Aggregate

PingCode研發中心 發表於 2022-01-17
MongoDB

前言

MongoDB是一款流行的無模式,記憶體資料庫,應用非常廣泛,其中作為 MongoDB 重要組成部分 MongoDB Aggregate ,它主要是用來做複雜查詢,統計資料,資料分析等等,隨著業務的發展,會累積大量的資料,需要寫各種各樣複雜的查詢語句,這就需要我們對Aggregate的原理,Aggregate的核心思想,Aggregate的效能分析要做深入的理解,以及如何寫更高效的查詢語句?如何提高查詢的效能?的方式方法需要深入的探索,接下來就讓我們一起來對MongoDB Aggregate的做全面的解析。

MongoDB Aggregate的發展歷史

image.png

MongoDB Aggregation 核心思想

MongoDB Aggregation Framework

MongoDB Aggregation Framework 主要是通過Aggregate Language來編寫Pipeline提供資料分析處理的能力,它包含倆部分:

  1. 應用程式通過MongoDB驅動提供的Aggregate Api來定義Pipeline,並將其交給Aggregate Runtime
  2. Aggregate Runtime 接受來自應用的請求,然後對儲存的資料執行PipeLine中的Stage來查詢資料

原理圖如下:
image.png

應用程式通過MongoDB Driver 提供的MQL API 或者 Agg API 來接受使用者的查詢請求,然後交給MongoDB Database Runtime來執行,其中Aggregation Runtime是Query Runtime的一部分,Aggregation Runtime 重用了Query Runtime的部分引擎的能力,主要是體現在Aggregation Runtime 執行Pipeline的第一階段$match,Aggregate Pipeline的第一個$match Stage 是通過MQL中的查詢分析的引擎對其處理的。

MongoDB Aggregation Language

對於初學者來說,Aggregate Framework 是難於理解,並且它的學習曲線是比較陡峭的,必須克服它才能提高自己的Aggregate的程式設計能力,能把複雜的業務拆解為Aggregate Pipeline中具體的每一個Stage,並能明白每個Stage的職責,然後正確的組合每個stage順序,最後通過Pipeline的Stream的方式去完成資料的處理,這是它的核心所在。

Aggregate Language 思想:

  • 面向資料庫的程式語言非面向解決業務問題的程式語言
  • 宣告式的程式語言非命令式的程式語言
  • 函數語言程式設計語言非過程式的程式語言

Aggregate Language 特點:

函式式的程式語言,Aggregate Pipeline 宣告瞭一系列有序的Stage,並且把上一個Stage產生的資料作為下一個Stage輸入的資料,這種行為本身就是函式的特徵行為,並且每一個Stage裡面的Operator也可以接受其它的Oprator的返回值作為輸入引數。針對Aggregate程式設計的本質核心,就是把業務邏輯拆分成一個一個Stage,然後在Stage階段通過各種內建的Operator操作符完成資料的轉化,每個Operator就是可以理解為一個內建的 Function

Aggregate Language 難點:

  1. 書寫起來冗長
  2. 難於理解
  3. 因為我們更多的開發場景是比較熟悉的程式式程式設計,但是對於Aggregate 來說你必須用函式式的程式設計的思維去思考問題,這是開發思維的轉變。

Aggregate Language 優點:

正是因為Aggregate 這種宣告式,函式式的特徵,以至於它能靈活的處理各種複雜的業務場景,我們只需要關心如何去定義每個Stage它是幹什麼的?而不需要關心這個Stage本身是如何工作的,只要你清晰的宣告每個Stage,然後交給Aggregate Runtime ,Aggregate Runtime清楚的知道具體每個Stage是如何工作的,正是因為這種宣告式的特性,所以Aggregate runtime 才有能力去重新優化Stage的順序,以便能更好的處理效能問題,同時這種宣告式的Stage特徵,我們還可以利用Shards去併發執行不同的Stage,能有效的降低響應時間,提高效能,下圖所示它描述了Aggregate Runtime的優化能力。
image.png

何時使用 Aggregation Framework

  1. 生成報告,Sum ,Average,Count
  2. 連線不同的集合來查詢資料
  3. 資料發現和資料探勘
  4. 過濾敏感資料
  5. 實現各種BI Connector
  6. 機器學習,等等場景

程式設計規範

正是由於Aggregate PipeLine的複雜性,並且難以維護的特徵,所以我們需要制定一些規範來讓我們更好的去約束我們的程式碼,可能對於不同的公司來說規範都不一樣,如下是筆者所在單位制定的一些規範:

  • 不要在開始或者結束的地方寫另外的一個Stage
  • 對於Stage中的每個欄位後面都要加“,”
  • 每個Stage中要增加一個空行
  • 對於複雜的Stage 通過 // 來寫註釋
  • 對於不需要的Stage,以及測試開發中需要禁用的Stage ,要通過 /**/ 來註釋掉
  • Stage 要遵守單一職責

示例:

// BAD
var pipeline = [
  {"$unset": [
    "_id",
    "address"
  ]}, {"$match": {
    "dateofbirth": {"$gte": ISODate("1970-01-01T00:00:00Z")}
  }}//, {"$sort": {
  //  "dateofbirth": -1
  //}}, {"$limit": 2}
];

// GOOD
var pipeline = [
  {"$unset": [
    "_id",
    "address",
  ]},    
    
  // Only match people born on or after 1st January 1970
  {"$match": {
    "dateofbirth": {"$gte": ISODate("1970-01-01T00:00:00Z")},
  }},
  
  /*
  {"$sort": {
    "dateofbirth": -1,
  }},      
    
  {"$limit": 2},  
  */
];

// GOOD
var unsetStage = {
  "$unset": [
    "_id",
    "address",
  ]};    

var matchStage = {
  "$match": {
    "dateofbirth": {"$gte": ISODate("1970-01-01T00:00:00Z")},
  }};

var sortStage = {
   "$sort": {
    "dateofbirth": -1,
  }}; 


var limitStage = {"$limit": 2};
    
var pipeline = [
  unsetStage,
  matchStage,
  sortStage,
  limitStage,
];

我們在聊完Mongo Aggregate的它的設計思想和語言特性,接下來就是具體的我們如何去編寫 Aggregate中的 Pipeline ,這裡有一些指導原則,供大家參考如下:

Pipeline程式設計指導原則

擁抱組合及組合技巧

Aggregate Pipeline 是包含了一些宣告的,有序的Statement,我們把它稱之為Stage,一個Stage的完整輸出會作為下一個Stage完整的輸入,每個Stage之間是互相沒有影響,獨立存在,Stage這種高度的自由組合性和單個Stage的內聚性的特徵充分滿足了複雜的業務場景的資料處理,並且能極大的增加我們對測試Stage的可能性,因為它們都是獨立的,對於Aggregate 複雜的Pipeline,我們首先需要把它分割成一個一個清晰的Stage,然後分別針對每個Stage進行獨立的測試和開發,如下圖

image.png

這樣即便是複雜的業務邏輯,你都可以把它拆分成具體的獨立的Stage,然後一步步的去除錯,分析,觀察每一步的資料到底是什麼樣的,對於這種組合,宣告的特性,有一些非常顯著的優點:

  • 很方便的註釋和除錯某一個Stage
  • 能方便的Copy,Paste來增加一個新的Stage
  • 更加清晰每個Stage的具體目的
  • 具體Stage中呼叫Mongo 提供的內建的Operator,以及通過邏輯表示式來控制資料的行為

Project Stage

在MQL語言中$Project 指定那些欄位要返回,那些欄位是要忽略的,在Aggregate中,在$Project Stage中指定排除或者返回那些欄位

顯著的缺點:

$Project 是冗長的且不靈活的,如果你想在$Project 階段新增加一個欄位,還要保留原來的欄位,你必須把原來的欄位都寫一遍

顯著的優點:

在$Project Stage階段,靈活的定義那些欄位要包含,那些欄位要忽略

何時使用$Project:

當你需要保留少數字段的時候, $Project 是比較佔優勢的,例如:

// INPUT  (a record from the source collection to be operated on by an aggregation)
{
  _id: ObjectId("6044faa70b2c21f8705d8954"),
  card_name: "Mrs. Jane A. Doe",
  card_num: "1234567890123456",
  card_expiry: "2023-08-31T23:59:59.736Z",
  card_sec_code: "123",
  card_provider_name: "Credit MasterCard Gold",
  transaction_id: "eb1bd77836e8713656d9bf2debba8900",
  transaction_date: ISODate("2021-01-13T09:32:07.000Z"),
  transaction_curncy_code: "GBP",
  transaction_amount: NumberDecimal("501.98"),
  reported: true
}

// OUTPUT  (a record in the results of the executed aggregation)
{
  transaction_info: { 
    date: ISODate("2021-01-13T09:32:07.000Z"),
    amount: NumberDecimal("501.98")
  },
  status: "REPORTED"
}
// BAD
[
  {"$set": {
    // Add some fields
    "transaction_info.date": "$transaction_date",
    "transaction_info.amount": "$transaction_amount",
    "status": {"$cond": {"if": "$reported", "then": "REPORTED", "else": "UNREPORTED"}},
  }},
  
  {"$unset": [
    // Remove _id field
    "_id",

    // Must name all other existing fields to be omitted
    "card_name",
    "card_num",
    "card_expiry",
    "card_sec_code",
    "card_provider_name",
    "transaction_id",
    "transaction_date",
    "transaction_curncy_code",
    "transaction_amount",
    "reported",         
  ]}, 
]
// GOOD
[
  {"$project": {
    // Add some fields
    "transaction_info.date": "$transaction_date",
    "transaction_info.amount": "$transaction_amount",
    "status": {"$cond": {"if": "$reported", "then": "REPORTED", "else": "UNREPORTED"}},
    
    // Remove _id field
    "_id": 0,
  }},
]

何時使用$set,$unset

$set,$unset 是在MongoDB 4.2才新增加的功能,當你在Stage中想保留更多的欄位,並且想新增,修改,移除最小的欄位集合的時候,這個時候$set,$unset是最使用的,例如:

// INPUT  (a record from the source collection to be operated on by an aggregation)
{
  _id: ObjectId("6044faa70b2c21f8705d8954"),
  card_name: "Mrs. Jane A. Doe",
  card_num: "1234567890123456",
  card_expiry: "2023-08-31T23:59:59.736Z",
  card_sec_code: "123",
  card_provider_name: "Credit MasterCard Gold",
  transaction_id: "eb1bd77836e8713656d9bf2debba8900",
  transaction_date: ISODate("2021-01-13T09:32:07.000Z"),
  transaction_curncy_code: "GBP",
  transaction_amount: NumberDecimal("501.98"),
  reported: true
}
// OUTPUT  (a record in the results of the executed aggregation)
{
  card_name: "Mrs. Jane A. Doe",
  card_num: "1234567890123456",
  card_expiry: ISODate("2023-08-31T23:59:59.736Z"), // Field type converted from text
  card_sec_code: "123",
  card_provider_name: "Credit MasterCard Gold",
  transaction_id: "eb1bd77836e8713656d9bf2debba8900",
  transaction_date: ISODate("2021-01-13T09:32:07.000Z"),
  transaction_curncy_code: "GBP",
  transaction_amount: NumberDecimal("501.98"),
  reported: true,
  card_type: "CREDIT"                               // New added literal value field
}
// BAD
[
  {"$project": {
    // Modify a field + add a new field
    "card_expiry": {"$dateFromString": {"dateString": "$card_expiry"}},
    "card_type": "CREDIT",        

    // Must now name all the other fields for those fields to be retained
    "card_name": 1,
    "card_num": 1,
    "card_sec_code": 1,
    "card_provider_name": 1,
    "transaction_id": 1,
    "transaction_date": 1,
    "transaction_curncy_code": 1,
    "transaction_amount": 1,
    "reported": 1,                
    
    // Remove _id field
    "_id": 0,
  }},
]
// GOOD
[
  {"$set": {
    // Modified + new field
    "card_expiry": {"$dateFromString": {"dateString": "$card_expiry"}},
    "card_type": "CREDIT",        
  }},
  
  {"$unset": [
    // Remove _id field
    "_id",
  ]},
]



何時使用$AddFields

$AddFields 是在3.4 才增加的新功能,主要想在$Porject的基礎上能增加資料的修改能力,它和$set 有很多相似的能力,但是它只能增加一個新多欄位,不能用來修改,在一般的情況下面我們不推薦使用,這可能是Mongo的一個過段期的產物。

在瞭解了PipeLine 之後,以及PipeLine 中的Stage的執行順序,我們如何具體的寫一個Stage呢?其中Expression就是它的核心。

Expression 是什麼?

Expression是Aggrgate Pipeline Stage的核心能力,在開發過程中我們一般都是檢視Mongo官方文件,找對應的例子,然後複製過來改改,缺乏深度的思考,但是如果你想熟練使用Aggregate Expression 的話,是需要深度理解Expression。
Aggregate Expression 主要包含3個方面:

  1. 操作符-Operator,以$為字首,訪問一個Object的key,例如:$arrayElementAt , $cond, $dateToString
  2. Field Path,訪問一個物件的嵌入路徑以$為字首,例如:$account.sortcode ,$addresses.address.city
  3. 變數,訪問的時候以$$作為字首
    3.1 系統的變數,主要是來源於系統的環境而不是具體的某條運算元據記錄,例如:"$$NOW`", "`$$CLUSTER_TIME"
    3.2 標記系統變數,主要是對資料處理的值進行標記,在重新傳遞給下一個Stage時候的資料行為,例如:"$$ROOT`", "`$$REMOVE", "$$PRUNE"
    3.3 使用者變數,主要是儲存使用者自定義的變數,通過$let定義的變數 ,以及在$Lookup ,$Map ,中間定義的臨時變數
    你可以很方便的通過組合這3種不同分類的變數來處理各種邏輯,計算資料,例如:
"customer_info": {"$cond": {
                    "if":   {"$eq": ["$customer_info.category", "SENSITIVE"]}, 
                    "then": "$$REMOVE",     
                    "else": "$customer_info",
                 }}

Expression返回值是什麼?

表示式的返回值是Json / Bson 的資料型別

  • a Number  (including integer, long, float, double, decimal128)
  • a String  (UTF-8)
  • a Boolean
  • a DateTime  (UTC)
  • an Array
  • an Object

一個特定的表示式能返回指定的幾種資料型別,例如

  • $contact 返回值型別是 string | null , $ROOT 僅僅能返回在Pipeline Stage中涉及的root 文件
  • 對於Field Path 來說,它的返回值型別就不同了,主要是依賴你輸入的文件是什麼資料結構,如果Address 是一個物件,那返回值就是Object ,如果是String 那返回值就是String,總之來說,對於Field Path,或者使用者自定義的變數,它的返回值型別是取決於執行環境的上下文,這點非常關鍵,這點和Javascript 非常類似,它是一種弱的約束。
  • 對於Operator的Expression ,它可以接受其它的Operator Expression返回值作為輸入引數,這也是函數語言程式設計的重要體現
{"$dayOfWeek": ISODate("2021-04-24T00:00:00Z")}
{"$dayOfWeek": "$person_details.data_of_birth"}
{"$dayOfWeek": "$$NOW"}
{"$dayOfWeek": {"$dateFromParts": {"year" : 2021, "month" : 4, "day": 24}}} **

其中 $limit,$skip,$sort,$count,$out Stage不能使用表示式。

並且需要特別注意在 $Match 中使用$expr可能會有命中不了索引的問題,這個具體要看指定的Operator , 以及你使用的Mongo的版本,如下是在$match使用$expr:

[
  { _id: 1, width: 2, height: 8 },
  { _id: 2, width: 3, height: 4 },
  { _id: 3, width: 20, height: 1 }
]
var pipeline = [
  {"$match": {
    "$expr": {"$gt": [{"$multiply": ["$width", "$height"]}, 12]},
  }},      
];

Expression 處理陣列的高階技巧

對於MongoDB來說,內嵌陣列本身就是它的核心能力,它不同於關係型的資料庫,它的特徵就是把整個原始資料作為一個文件來處理,這更加符合真實世界的資料描述,這樣一個儲存了太多資料型別的Document,對於開發人員來說如何獲取到自己想要的資料就非常重要,Agrregate 提供了Expression來對Array進行操作,增強這種能力,在處理陣列元素中,我們更重要的是思維模式的轉變,由原來的過程式思維模式轉變為函式式的思維模式來思考問題,這樣才能理解和處理複雜的業務需求,也更符合Mongo Aggreage的思維邏輯。

"IF- ELSE" 條件表示式

let order = {"product" : "WizzyWidget", "price": 25.99, "qty": 8};

// Procedural style JavaScript
if (order.qty > 5) {
  order.cost = order.price * order.qty * 0.9;
} else {
  order.cost = order.price * order.qty;
}

db.customer_orders.insertOne(order);
// Aggregate 
var pipeline = [
  {"$set": {
    "cost": {
      "$cond": { 
        "if":   {"$gte": ["$qty", 5 ]}, 
        "then": {"$multiply": ["$price", "$qty", 0.9]},
        "else": {"$multiply": ["$price", "$qty"]},
      }    
    },
  }},
];

db.customer_orders.aggregate(pipeline);

// Functional style JavaScript
order.cost = (
              (order.qty > 5) ?
              (order.price * order.qty * 0.9) :
              (order.price * order.qty)
             );

// output
{product: 'WizzyWidget', qty: 8, price: 25.99, cost: 187.128}

"FOR-EACH" 迴圈訪問陣列中的每個元素

let order = {
  "orderId": "AB12345",
  "products": ["Laptop", "Kettle", "Phone", "Microwave"]
};
 
// Procedural style JavaScript
for (let pos in order.products) {
  order.products[pos] = order.products[pos].toUpperCase();
}

db.orders.insertOne(order);
// Aggregate
var pipeline = [
  {"$set": {
    "products": {
      "$map": {
        "input": "$products",
        "as": "product",
        "in": {"$toUpper": "$$product"}
      }
    }
  }}
];

db.orders.aggregate(pipeline);

// Functional style JavaScript
order.products = order.products.map(
  product => {
    return product.toUpperCase(); 
  }
);

// Output 
{orderId: 'AB12345', products: ['LAPTOP', 'KETTLE', 'PHONE', 'MICROWAVE']}

"FOR-EACH" 計算陣列中元素累加之後的值

let order = {
  "orderId": "AB12345",
  "products": ["Laptop", "Kettle", "Phone", "Microwave"]
};
 
order.productList = "";
// Procedural style JavaScript
for (const pos in order.products) {
  order.productList += order.products[pos] + "; ";
}
db.orders.insertOne(order);

// Aggregate 
var pipeline = [
  {"$set": {
    "productList": {
      "$reduce": {
        "input": "$products",
        "initialValue": "",
        "in": {
          "$concat": ["$$value", "$$this", "; "]
        }            
      }
    }
  }}
];

db.orders.aggregate(pipeline);

// Functional style JavaScript
order.productList = order.products.reduce(
  (previousValue, currentValue) => {
    return previousValue + currentValue + "; ";
  },
  ""
);

// output
{
  orderId: 'AB12345',
  products: [ 'Laptop', 'Kettle', 'Phone', 'Microwave' ],
  productList: 'Laptop; Kettle; Phone; Microwave; '
}

“FOR-EACH” ,迴圈訪問陣列,找到具體的元素所在陣列中的位置

// 找出room_sizes 陣列中第一個面積大於60M 的元素所在的陣列中的順序
db.buildings.insertOne({
  "building": "WestAnnex-1",
  "room_sizes": [
    {"width": 9, "length": 5},
    {"width": 8, "length": 7},
    {"width": 7, "length": 9},
    {"width": 9, "length": 8},
  ]
});

// Aggregate
var pipeline = [
  {"$set": {
    "firstLargeEnoughRoomArrayIndex": {
      "$reduce": {
        "input": {"$range": [0, {"$size": "$room_sizes"}]},
        "initialValue": -1,
        "in": {
          "$cond": { 
            "if": {
              "$and": [
                // IF ALREADY FOUND DON'T CONSIDER SUBSEQUENT ELEMENTS
                {"$lt": ["$$value", 0]}, 
                // IF WIDTH x LENGTH > 60
                {"$gt": [
                  {"$multiply": [
                    {"$getField": {"input": {"$arrayElemAt": ["$room_sizes", "$$this"]}, "field": "width"}},
                    {"$getField": {"input": {"$arrayElemAt": ["$room_sizes", "$$this"]}, "field": "length"}},
                  ]},
                  60
                ]}
              ]
            }, 
            // IF ROOM SIZE IS BIG ENOUGH CAPTURE ITS ARRAY POSITION
            "then": "$$this",  
            // IF ROOM SIZE NOT BIG ENOUGH RETAIN EXISTING VALUE (-1)
            "else": "$$value"  
          }            
        }            
      }
    }
  }}
];

db.buildings.aggregate(pipeline);

// output 
{
  building: 'WestAnnex-1',
  room_sizes: [
    { width: 9, length: 5 },
    { width: 8, length: 7 },
    { width: 7, length: 9 },
    { width: 9, length: 8 }
  ],
  firstLargeEnoughRoomArrayIndex: 2
}
// summary 
1. 找到元素之後不會中斷,如果是大陣列,可能會有效能上面的損失

$map 和 $reduce 差異

// sourc data
db.deviceReadings.insertOne({
  "device": "A1",
  "readings": [27, 282, 38, 22, 187]
});

// output
{
  device: 'A1',
  readings: [ 27, 282, 38, 22, 187 ],
  deviceReadings: [ 'A1:27', 'A1:282', 'A1:38', 'A1:22', 'A1:187' ]
}

// $map
var pipeline = [
  {"$set": {
    "deviceReadings": {
      "$map": {
        "input": "$readings",
        "as": "reading",
        "in": {
          "$concat": ["$device", ":", {"$toString": "$$reading"}]
        }
      }
    }
  }}
];
db.deviceReadings.aggregate(pipeline);

// $reduce
var pipeline = [
  {"$set": {
    "deviceReadings": {
      "$reduce": {
        "input": "$readings",
        "initialValue": [],
        "in": {
          "$concatArrays": [
            "$$value",
            [{"$concat": ["$device", ":", {"$toString": "$$this"}]}]
          ]
        }
      }
    }
  }}
];

db.deviceReadings.aggregate(pipeline);
// output 
{
  device: 'A1',
  readings: [ 27, 282, 38, 22, 187 ],
  deviceReadings: [ 'A1-0:27', 'A1-1:282', 'A1-2:38', 'A1-3:22', 'A1-4:187' ]
}

// $reduce 
var pipeline = [
  {"$set": {
    "deviceReadings": {
      "$reduce": {
        "input": {"$range": [0, {"$size": "$readings"}]},
        "initialValue": [],
        "in": {
          "$concatArrays": [
            "$$value",
            [{"$concat": [
              "$device",
              "-",
              {"$toString": "$$this"},
              ":",
              {"$toString": {"$arrayElemAt": ["$readings", "$$this"]}},
            ]}]
          ]
        }
      }
    }
  }}
];

db.deviceReadings.aggregate(pipeline);


$map 給陣列中的每個物件增加一個新的欄位

db.orders.insertOne({
    "custid": "[email protected]",
    "items": [
      {
        "product" : "WizzyWidget", 
        "unitPrice": 25.99,
        "qty": 8,
      },
      {
        "product" : "HighEndGizmo", 
        "unitPrice": 33.24,
        "qty": 3,
      }
    ]
});

// aggregate
var pipeline = [
  {"$set": {
    "items": {
      "$map": {
        "input": "$items",
        "as": "item",
        "in": {
          "product": "$$item.product",
          "unitPrice": "$$item.unitPrice",
          "qty": "$$item.qty",
          "cost": {"$multiply": ["$$item.unitPrice", "$$item.qty"]}},
        }
      }
    }
  }
];

db.orders.aggregate(pipeline);
// output
{
  custid: '[email protected]',
  items: [
    {
      product: 'WizzyWidget',
      unitPrice: 25.99,
      qty: 8,
      cost: 187.128
    },
    {
      product: 'HighEndGizmo',
      unitPrice: 33.24,
      qty: 3,
      cost: 99.72
    }
  ]
}
// 缺點和$project 類似,需要你指定輸出的欄位,如果欄位特別多就會特別的繁瑣
// 改進的方式
var pipeline = [
  {"$set": {
    "items": {
      "$map": {
        "input": "$items",
        "as": "item",
        "in": {
          "$mergeObjects": [
            "$$item",            
            {"cost": {"$multiply": ["$$item.unitPrice", "$$item.qty"]}},
          ]
        }
      }
    }
  }}
];

db.orders.aggregate(pipeline);
// 等同的其它寫法
var pipeline = [
  {"$set": {
    "items": {
      "$map": {
        "input": "$items",
        "as": "item",
        "in": {
          "$arrayToObject": {
            "$concatArrays": [
              {"$objectToArray": "$$item"},            
              [{
                "k": "cost",
                "v": {"$multiply": ["$$item.unitPrice", "$$item.qty"]},
              }]              
            ]
          }
        }
      }
    }}
  }
];

db.orders.aggregate(pipeline);

// 動態組合欄位
var pipeline = [
  {"$set": {
    "items": {
      "$map": {
        "input": "$items",
        "as": "item",
        "in": {
          "$arrayToObject": {
            "$concatArrays": [
              {"$objectToArray": "$$item"},            
              [{
                "k": {"$concat": ["costFor", "$$item.product"]},
                "v": {"$multiply": ["$$item.unitPrice", "$$item.qty"]},
              }]              
            ]
          }
        }
      }
    }}
  }
];

db.orders.aggregate(pipeline);

// output
{
  custid: '[email protected]',
  items: [
    {
      product: 'WizzyWidget',
      unitPrice: 25.99,
      qty: 8,
      costForWizzyWidget: 207.92
    },
    {
      product: 'HighEndGizmo',
      unitPrice: 33.24,
      qty: 3,
      costForHighEndGizmo: 99.72
    }
  ]
}

reflection 返回每個元素的資料型別,並分組

db.customers.insertMany([
  {
    "_id": ObjectId('6064381b7aa89666258201fd'),
    "email": '[email protected]',
    "dateOfBirth": ISODate('1991-05-30T08:35:52.000Z'),
    "accNnumber": 123456,
    "balance": NumberDecimal("9.99"),
    "address": {
      "firstLine": "1 High Street",
      "city": "Newtown",
      "postcode": "NW1 1AB",
    },
    "telNums": ["07664883721", "01027483028"],
    "optedOutOfMarketing": true,
  },
  {
    "_id": ObjectId('734947394bb73732923293ed'),
    "email": '[email protected]',
    "dateOfBirth": ISODate('1993-07-11T22:01:47.000Z'),
    "accNnumber": 567890,
    "balance": NumberDecimal("299.22"),
    "telNums": "07836226281",
    "contactPrefernece": "email",
  },
]);

// aggregate
var pipeline = [
  {"$project": {
    "_id": 0,
    "schema": {
      "$map": {
        "input": {"$objectToArray": "$$ROOT"},
        "as": "field",
        "in": {
          "fieldname": "$$field.k",
          "type": {"$type": "$$field.v"},          
        }
      }
    }
  }}
];

db.customers.aggregate(pipeline);

// output
{
  schema: [
    {fieldname: '_id', type: 'objectId'},
    {fieldname: 'email', type: 'string'},
    {fieldname: 'dateOfBirth', type: 'date'},
    {fieldname: 'accNnumber', type: 'int'},
    {fieldname: 'balance', type: 'decimal'},
    {fieldname: 'address', type: 'object'},
    {fieldname: 'telNums', type: 'array'},
    {fieldname: 'optedOutOfMarketing', type: 'bool'}
  ]
},
{
  schema: [
    {fieldname: '_id', type: 'objectId'},
    {fieldname: 'email', type: 'string'},
    {fieldname: 'dateOfBirth', type: 'date'},
    {fieldname: 'accNnumber', type: 'int'},
    {fieldname: 'balance', type: 'decimal'},
    {fieldname: 'telNums', type: 'string'},
    {fieldname: 'contactPrefernece', type: 'string'}
}

// group 
    var pipeline = [
  {"$project": {
    "_id": 0,
    "schema": {
      "$map": {
        "input": {"$objectToArray": "$$ROOT"},
        "as": "field",
        "in": {
          "fieldname": "$$field.k",
          "type": {"$type": "$$field.v"},          
        }
      }
    }
  }},
  
  {"$unwind": "$schema"},

  {"$group": {
    "_id": "$schema.fieldname",
    "types": {"$addToSet": "$schema.type"},
  }},
  
  {"$set": {
    "fieldname": "$_id",
    "_id": "$$REMOVE",
  }},
];

db.customers.aggregate(pipeline);

// output
{fieldname: '_id', types: ['objectId']},
{fieldname: 'address', types: ['object']},
{fieldname: 'email', types: ['string']},
{fieldname: 'telNums', types: ['string', 'array']},
{fieldname: 'contactPrefernece', types: ['string']},
{fieldname: 'accNnumber', types: ['int']},
{fieldname: 'balance', types: ['decimal']},
{fieldname: 'dateOfBirth', types: ['date']},
{fieldname: 'optedOutOfMarketing', types: ['bool']}

在我們編寫完Aggregate Pipeline之後,緊接著就需要對它做效能測試,這樣我們可以通過Explain plan 來對Aggregate Pipeline 做效能分析:

Explain Plans

對於MQL查詢語句來說,你可以很方便的通過查詢計劃來檢視執行的過程,檢視索引的行為,通過查詢計劃的反饋來調整定義的查詢,和相應的調整資料模型,對於Aggregate Pipeline也是一樣的,但是Aggregate Pipeline相對來說是更復雜的,因為它有複雜的業務邏輯,通過分析查詢計劃,你可以定位效能的瓶頸,MongoDb Aggrgate Runtime有它自己的查詢優化的邏輯,但是它首選要保證的是Function Behavior的是正確的,對於一些複雜的邏輯計算,它是沒辦法知道該如何優化的,正是因為這個缺點,我們才需要通過分析查詢計劃,來理清楚邏輯,調整對應的效能。

  • 檢視執行計劃
db.coll.explain().aggregate([{"$match": {"name": "Jo"}}]);

// QueryPlanner verbosity  (default if no verbosity parameter provided)
db.coll.explain("queryPlanner").aggregate(pipeline);

// ExecutionStats verbosity
db.coll.explain("executionStats").aggregate(pipeline);

// AllPlansExecution verbosity 
db.coll.explain("allPlansExecution").aggregate(pipeline);

  • 分析查詢計劃
{
  "customer_id": "[email protected]",
  "orders": [
    {
      "orderdate": ISODate("2020-01-13T09:32:07Z"),
      "product_type": "GARDEN",
      "value": NumberDecimal("99.99")
    },
    {
      "orderdate": ISODate("2020-05-30T08:35:52Z"),
      "product_type": "ELECTRONICS",
      "value": NumberDecimal("231.43")
    }
  ]
}
// pipeline
var pipeline = [
  // Unpack each order from customer orders array as a new separate record
  {"$unwind": {
    "path": "$orders",
  }},
  
  // Match on only one customer
  {"$match": {
    "customer_id": "[email protected]",
  }},

  // Sort customer's purchases by most expensive first
  {"$sort" : {
    "orders.value" : -1,
  }},
  
  // Show only the top 3 most expensive purchases
  {"$limit" : 3},

  // Use the order's value as a top level field
  {"$set": {
    "order_value": "$orders.value",
  }},
    
  // Drop the document's id and orders sub-document from the results
  {"$unset" : [
    "_id",
    "orders",
  ]},
];
// output
[
  {
    customer_id: '[email protected]',
    order_value: NumberDecimal("1024.89")
  },
  {
    customer_id: '[email protected]',
    order_value: NumberDecimal("187.99")
  },
  {
    customer_id: '[email protected]',
    order_value: NumberDecimal("4.59")
  }
]
// execute query plan
db.customer_orders.explain("queryPlanner").aggregate(pipeline);

stages: [
  {
    '$cursor': {
      queryPlanner: {
        parsedQuery: { customer_id: { '$eq': '[email protected]' } },
        winningPlan: {
          stage: 'FETCH',
          inputStage: {
            stage: 'IXSCAN',
            keyPattern: { customer_id: 1 },
            indexName: 'customer_id_1',
            direction: 'forward',
            indexBounds: {
              customer_id: [
                '["[email protected]", "[email protected]"]'
              ]
            }
          }
        },
      }
    }
  },
  
  { '$unwind': { path: '$orders' } },
  
  { '$sort': { sortKey: { 'orders.value': -1 }, limit: 3 } },
  
  { '$set': { order_value: '$orders.value' } },
  
  { '$project': { _id: false, orders: false } }
]

//executionStats
db.customer_orders.explain("executionStats").aggregate(pipeline);
executionStats: {
  nReturned: 1,
  totalKeysExamined: 1,
  totalDocsExamined: 1,
  executionStages: {
    stage: 'FETCH',
    nReturned: 1,
    works: 2,
    advanced: 1,
    docsExamined: 1,
    inputStage: {
      stage: 'IXSCAN',
      nReturned: 1,
      works: 2,
      advanced: 1,
      keyPattern: { customer_id: 1 },
      indexName: 'customer_id_1',
      direction: 'forward',
      indexBounds: {
        customer_id: [
          '["[email protected]", "[email protected]"]'
        ]
      },
      keysExamined: 1,
    }
  }
}



為了進一步的去改善我們的Aggregate Pipeline的效能,我們需要明確的清楚Pipeline的原理:

管道流和阻塞

Mongo Aggregate Runtime 開始執行Pipeline的時候,它通過Aggregate Init Query Cursor來載入第一批的資料,然後交給第一個Stage,第一個Stage處理完直接就交給第二個Stage,以此類推,並且後面Stage是不需要等待前面Stage所有的資料載入完,就會直接交給下個Stage來進行處理,我們稱之為Stream處理,然而 $Sort,$Group 是阻塞性的,也就是說這2個階段,必須前面的Stage把符合條件的資料全部載入到記憶體中,然後才會進行排序或者分組,這個是非常消耗資料庫伺服器的記憶體的,如下圖:
image.png

$Sort 記憶體消耗和改進

由於$Sort 是阻塞性質的,所以要求把符合條件的資料全部都要載入到記憶體中然後才能進行排序,這樣如果資料量太大的情況下,會導致資料庫記憶體溢位,並且Pipeline Stage 約束的記憶體使用是100MB,超過這個就會報錯,只能通過設定引數,“allowDiskUse:true”,來突破這個記憶體的限制,最大化的載入資料,但是隨著資料量大增大,它會越來越慢,這種行為在一定程度上是很難避免的,但是有些原則能幫助我們提升效能

  • 使用索引排序

如果$Sort 不依賴於前面的$Unwind,$Project ,$Group Stage ,我們可以把$Sort 移動到距離第一個Stage最近地方,等於我們載入資料的時候,就按照索引排序的來載入,而不是在記憶體中計算,指導原則如下:

  • 和Limit同時使用,限制資料量大大小
  • 減少排序的資料量,如果有複雜的查詢,並且無法$Sort無法命中索引的情況下,儘量把$Sort 移動到整個Pipeline的最後Stage來進行排序

$Group 記憶體消耗和改進

$Group 其實和$Sort 的行為是一樣的,因為它們都是阻塞性質的,我們沒辦法分批來分組,因為分組的場景就是用來統計累計的值,例如求和,求平均值,等等,提高效能,指導原則如下:

  • 避免$Unwind ,$ReGroup 來處理陣列的元素
  • $Group 的職責更加的單一,只是處理一些累計值
[
  {
    customer_id: '[email protected]',
    orderdate: ISODate('2020-05-30T08:35:52.000Z'),
    value: NumberDecimal('9999')
  }
  {
    customer_id: '[email protected]',
    orderdate: ISODate('2020-01-13T09:32:07.000Z'),
    value: NumberDecimal('10101')
  }
]
// SUBOPTIMAL
var pipeline = [
  {"$set": {
    "value_dollars": {"$multiply": [0.01, "$value"]}, // Converts cents to dollars
  }},
  
  {"$unset": [
    "_id",
    "value",
  ]},         

  {"$match": {
    "value_dollars": {"$gte": 100},  // Peforms a dollar check
  }},    
];

// OPTIMAL

var pipeline = [
  {"$set": {
    "value_dollars": {"$multiply": [0.01, "$value"]},
  }},
  
  {"$match": {                // Moved to before the $unset
    "value": {"$gte": 10000},   // Changed to perform a cents check
  }},    

  {"$unset": [
    "_id",
    "value",
  ]},         
];

//

避免$Unwind ,$ReGroup 來處理陣列的元素

// source collection 
[
  {
    _id: 1197372932325,
    products: [
      {
        prod_id: 'abc12345',
        name: 'Asus Laptop',
        price: NumberDecimal('429.99')
      }
    ]
  },
  {
    _id: 4433997244387,
    products: [
      {
        prod_id: 'def45678',
        name: 'Karcher Hose Set',
        price: NumberDecimal('23.43')
      },
      {
        prod_id: 'jkl77336',
        name: 'Picky Pencil Sharpener',
        price: NumberDecimal('0.67')
      },
      {
        prod_id: 'xyz11228',
        name: 'Russell Hobbs Chrome Kettle',
        price: NumberDecimal('15.76')
      }
    ]
  }
]

// SUBOPTIMAL
var pipeline = [
  // Unpack each product from the each order's product as a new separate record
  {"$unwind": {
    "path": "$products",
  }},

  // Match only products valued over 15.00
  {"$match": {
    "products.price": {
      "$gt": NumberDecimal("15.00"),
    },
  }},

  // Group by product type
  {"$group": {
    "_id": "$_id",
    "products": {"$push": "$products"},    
  }},
];

// OPTIMAL
var pipeline = [
  // Filter out products valued 15.00 or less
  {"$set": {
    "products": {
      "$filter": {
        "input": "$products",
        "as": "product",
        "cond": {"$gt": ["$$product.price", NumberDecimal("15.00")]},
      }
    },    
  }},
];

// output 
[
  {
    _id: 1197372932325,
    products: [
      {
        prod_id: 'abc12345',
        name: 'Asus Laptop',
        price: NumberDecimal('429.99')
      }
    ]
  },
  {
    _id: 4433997244387,
    products: [
      {
        prod_id: 'def45678',
        name: 'Karcher Hose Set',
        price: NumberDecimal('23.43')
      },
      {
        prod_id: 'xyz11228',
        name: 'Russell Hobbs Chrome Kettle',
        price: NumberDecimal('15.76')
      }
    ]
  }
]

在Pipeline的早期使用更多的Filter

探索是否可能使$match全部命中索引

[
  {
    customer_id: '[email protected]',
    orderdate: ISODate('2020-05-30T08:35:52.000Z'),
    value: NumberDecimal('9999')
  }
  {
    customer_id: '[email protected]',
    orderdate: ISODate('2020-01-13T09:32:07.000Z'),
    value: NumberDecimal('10101')
  }
]

// SUBOPTIMAL

var pipeline = [
  {"$set": {
    "value_dollars": {"$multiply": [0.01, "$value"]}, // Converts cents to dollars
  }},
  
  {"$unset": [
    "_id",
    "value",
  ]},         

  {"$match": {
    "value_dollars": {"$gte": 100},  // Peforms a dollar check
  }},    
];

// OPTIMAL

var pipeline = [
  {"$set": {
    "value_dollars": {"$multiply": [0.01, "$value"]},
  }},
  
  {"$match": {                // Moved to before the $unset
    "value": {"$gte": 10000},   // Changed to perform a cents check
  }},    

  {"$unset": [
    "_id",
    "value",
  ]},         
];

探索是否可能使$match部分命中索引

你所要進行查詢的欄位,不是資料庫的原生欄位,這個時候你可能需要而外的增加一個$Match 來匹配原生欄位,命中索引來進行資料過濾

[
  {
    date_of_birth: ISODate('2019-05-30T08:35:52.000Z'),
  }
  {
    date_of_birth: ISODate('2019-05-31T08:35:52.000Z'),
  }
  {
    date_of_birth: ISODate('2019-06-01T08:35:52.000Z'),
  }
]

由於出生日期是個敏感欄位,我們需要加一個隨機數來脫敏,我們需要用masked_date來代替,(0-7)
masked_date > 2019-06-05

// OPTIMAL
var pipeline = [
  // extra $match
  {"$match": {
    "date_of_birth": {"$gt": 2019-05-30 },
  }},
  
  {"$match": {               
    "masked_date": {"$gt": 2019-06-05},   
  }},     
];

如果你的Aggregate是依賴計算的中間欄位的,這個時候要儘可能的增加額外的$match 來獲取儘可能少的資料。

總結

至此,MongoDB Aggregate 相關的內容就介紹完了,對MongoDB Aggregate的原理深入理解,這非常有助於我們處理複雜的業務查詢,並保持高的效能,如果大家有不理解的,歡迎在評論區溝通,如果有需要改正的地方,也歡迎大家指出,希望這篇文章可以幫助大家更好的理解MongoDB Aggregate.


最後,推薦我們的智慧化研發管理工具 PingCode 給大家。

PingCode官網

關於 PingCode

PingCode 是由國內老牌 SaaS 廠商 Worktile 打造的智慧化研發管理工具,圍繞企業研發管理需求推出了 Agile(敏捷開發)、Testhub(測試管理)、Wiki(知識庫)、Plan(專案集)、Goals(目標管理)、Flow(自動化管理)、Access (目錄管理)七大子產品以及應用市場,實現了對專案、任務、需求、缺陷、迭代規劃、測試、目標管理等研發管理全流程的覆蓋以及程式碼託管工具、CI/CD 流水線、自動化測試等眾多主流開發工具的打通。

自正式釋出以來,以酷狗音樂、商湯科技、電銀資訊、51 社保、萬國資料、金鷹卡通、用友、國汽智控、智齒客服、易快報等知名企業為代表,已經有超過 13 個行業的眾多企業選擇 PingCode 落地研發管理。