MongoDB 系列 - 數據查詢遊標你用對了嗎?

幾個話題

本文會根據以下幾個話題進行討論與講解,文中的目錄不完全和這幾個話題一致,但當你閱讀完本文後,相信這些答案應該也有了,都在文中。

爲什麼要使用遊標?

這樣的寫法 collection.find().toArray(),大家在學習 MongoDB 時應該見的也不少,它的原理是客戶端驅動程序會自動把返回的所有數據一次性加載到應用程序內存中,理解起來相對簡單些,如果數據量小是沒問題的,在一些數據處理的場景中,具體有多少數據也許是未知的,有可能返回大量的數據,如果全部 hold 在內存,在服務端內存寸土寸金的地方,白白消耗服務內存不說,內存佔用過高還可能造成服務 OOM。

MongoDB 裏面的遊標,有點類似於在 Node.js 裏使用 Stream 處理文件數據,相比把整個文件讀入內存在處理這種模式,Stream 帶來的收益是很大的。

很形象的一個圖,來源:https://www.cnblogs.com/vajoy/p/6349817.html[1]

遊標基本工作原理

當我們使用 collection.find()collection.aggregate() 返回的是一個指向該集合的指針,也稱爲遊標(cursor),是不能直接訪問數據的,只有當循環迭代這個遊標時纔會真正的從數據庫集合讀取數據

在 Node.js 中使用很簡單,只要支持 for await of 語法,即可遍歷遊標返回的數據集,和正常使用 for of 遍歷數組很相似,區別是 for await of 遍歷的數據源是異步的。當循環迭代開始時驅動程序會使用 getMore() 命令批量從數據庫集合中獲取一批數據先緩存起來,例如 Node.js MongoDB 驅動程序每次默認批量獲取 1000 條(注意,第一次 getMore() 時實際請求是 101 條),取決於 batchSize[2] 參數設置,待這批數據處理完成之後,在向 MongoDB Server 執行 getMore()  繼續請求直到遊標耗盡。

以下爲 Node.js 中的兩種使用示例,個人比較推薦 for await of 這種寫法。方法二 while 循環這種寫法在一個 MongoDB Node.js 驅動程序版本中存在一個 Bug 下文會介紹。

const userCursor = await collection.find();

// 如果沒有返回數據,需要做一些特殊處理的,可以使用 userCursor.count() 或 userCursor.hasNext()
if (!await userCursor.count()) {
  // TODO: 提前結束,做一些其它操作
  return;
}

// 方法一:
for await (const user of userCursor) {
}

// 方法二:
while (await userCursor.hasNext()) {
 const doc = userCursor.next();
}

例如,數據庫集合有 10000 條數據,每次批量獲取 1000 條,I/O 消耗應該也爲 10 次。終端鏈接至 MongoDB Server  設置 db.setProfilingLevel(0, { slowms: 0 })記錄所有的操作日誌,之後在打開 MongoDB Server 控制檯日誌,執行應用程序之後會看到如下日誌信息,每次 getMore 都指向了同一個遊標 ID getMore: 5098682199385946244

遊標讀取結果. png

如果需要修改 batchSize 結果的,通過 options 指定 batchSize 屬性或調用 batchSize 方法都可以。

collection.find().batchSize(1100)
// 或以下方法
collection.find({}{
  batchSize: 1100
})

切記不要將 batchSize 設置爲 1,例如,10000 條數據每獲取一條數據,客戶端都將連接服務器讀取,這將會產生 10000 次網絡 IO,下圖使用 mongostat 監控,展示了每秒查詢遊標時的 getMore 次數。

遊標超時

如果一個遊標在一定時間內無人訪問,超時之後會被回收,防止產生內存泄漏,啓動時可通過 mongod --setParameter cursorTimeoutMillis=300000 參數設置,默認超時爲 10 分鐘,參見文檔 cursorTimeoutMillis#Default: 600000 (10 minutes)[3]。

例如,總共查詢 10000 條數據,第一次 getmore() 默認批量獲取 1000 條數據,如果在默認的 10 分鐘內沒有處理完成這 1000 條數據,遊標會被關閉,待下次執行 getmore() 就會報錯 cursor id 4011961159809892672 not found,一般稱之爲遊標超時。

如有遇到遊標超時,可通過調整 cursorTimeoutMillis 參數或減少 batchSize 數量選擇適合於自己的程序配置,通常默認配置是不需要調整的。例如,在遍歷遊標數據時調了一個外部接口,由於接口超時導致的遊標超時這種外部業務原因的,應先去優化業務本身,再考慮調整配置。

爲了解決遊標超時,你可能還見到過 cursor.addCursorFlag('noCursorTimeout', true) 這樣的配置,這會禁用掉遊標的超時限制,只有等到遊標耗盡或手動關閉 cursor.close() 遊標纔可能被釋放,禁用超時時間這種做法,很不推薦使用,每個遊標都存在額外的內存佔用消耗,如果因爲疏忽忘記手動關閉遊標導致的  MongoDB Server 內存泄漏就得不償失了

遊標狀態

登陸 MongoDB 客戶端,執行 db.serverStatus().metrics.cursor 命令,查看當前遊標使用狀態。如果真的出現遊標導致的 MongoDB 服務器內存泄漏,以下幾個數據指標,做爲運維人員在排查問題時,會有幫助。

{
 "timedOut" : NumberLong(4),
 "open" : {
  "noTimeout" : NumberLong(0),
  "pinned" : NumberLong(0),
  "total" : NumberLong(0)
 }
}

遊標與異步迭代器

JavaScript 在 ES6 語法提供了一個功能叫迭代器,定義了一套統一的接口,只要實現了該接口的數據類型,都可使用 for of 關鍵詞遍歷,例如數組、Map、Set 類型等,這些類型上有一個方法 Symbol.iterator 返回的就是一個迭代器對象,迭代器對象的 next() 方法返回值包含了 vlaue、done 兩個屬性,如果 done 爲 true 表示數據已遍歷完成,但 Symbol.iterator 只支持同步的數據源。

而我們從數據庫集合獲取數據涉及到網絡 I/O,這是一個異步的操作,Symbol.iterator 就無法支持了,在 ECMAScript 2018 標準中提供了一個新的屬性  Symbol.asyncIterator,這是一個異步迭代器,與 Symbol.iterator 不同的是 Symbol.asyncIterator 的 next() 方法返回的是一個包含  { value, done} 的 Promise 對象,如果一個對象設置了該屬性,它就是異步可迭代對象,相應的我們可使用 for await...of 循環遍歷數據。

下面看下 MonogoDB Node.js 驅動程序在 v4.2.2 版本中的實現,同樣也提供了 Symbol.asyncIterator 接口,這也就是爲什麼我們可以使用 for await...of 循環遍歷。

// mongodb/lib/cursor/abstract_cursor.js
class AbstractCursor extends mongo_types_1.TypedEventEmitter {
  [Symbol.asyncIterator]( "Symbol.asyncIterator") {
    return {
      next: () => this.next().then(value => value != null ? { value, donefalse }{ value: undefined, donetrue })
    };
  }
}

容錯處理

在遍歷遊標的過程中,for 循環體內如果出現一些錯誤導致循環提前終止,這個時候遊標並不會被立刻銷燬,可以選擇手動關閉遊標或等待超過默認的遊標超時時間後,遊標也會被銷燬。

如果設置了 noCursorTimeout 屬性爲永不超時,這個時候就一定記得要關閉遊標,因此在上面也建議儘量不要做這個設置。

const userCursor = await collection.find();
try {
  for await (const user of userCursor) {
    // 可能拋出錯誤 throw new Error('124')
  }
} catch (e) {
  // 處理錯誤
} finally {
 userCursor.close();  
}

Mongoose 需要注意的地方

使用 mongoose 和原生支持的 mongodb 模塊還是有很多差異的,mongoose 的 find() 方法默認不會返回遊標對象,需要在 find 後顯示調用 cursor() 方法,且沒有 cursor.count()cursor.hasNext() 方法支持,對於一些想判斷如果遊標沒有數據做一些特殊處理,處理起來不是很友好。

const userCursor = await User.find({}).cursor();

for await (const user of userCursor) {
}

一個關於遊標的 Bug

在 Node.js 羣裏,一個羣友發來消息使用遊標遇到了問題,後來也對這個問題做了一些查找和驗證,下文會介紹,基於一個特定版本和特定的應用場景纔會出現這個問題,放在這裏也是希望用到的朋友能少踩一個坑。

MongoDB Node.js 驅動程序在 3.5.4 版本基於遊標迭代查詢數據時,如果用了 limit 限制返回的數據條目,並且使用 hasNext(),存在一個 Bug,首先是從返回的遊標對象取出的 count 數不對,其次是遍歷出的數據條目與實際 limit count 數對不上,如果 limit 爲奇數還會收到 MongoError: Cursor is closed 錯誤。

如果需要調整每一次的 getMore() 數量,遊標可以結合 batchSize 使用。爲什麼用了遊標還要使用 limit?這個也可以思考下。

const userCursor = await collection.find({}).limit(5);
console.log('cursor count: ', await userCursor.count());
try {
  while (await userCursor.hasNext()) {
    const doc = await userCursor.next();
    console.log(doc);
  }
} catch (err) {
  console.error(err.stack);
}
userCursor.close();

mongodb@^3.5.4 版本輸出結果:

cursor count:  10000
{ _id: 61d6590b92058ddefbac6a14, userID: 0 }
{ _id: 61d6590b92058ddefbac6a15, userID: 1 }
null
MongoError: Cursor is closed
    at Function.create (/test/node_modules/mongodb/lib/core/error.js:43:12)
    at Cursor.hasNext (/test/node_modules/mongodb/lib/cursor.js:197:24)
    at file:///test/index.mjs:42:27
    at processTicksAndRejections (internal/process/task_queues.js:93:5)

NPM 包 mongodb 受影響版本爲 3.5.4 參見 issue jira.mongodb.org/browse/NODE-2483[5]NPM 包 mongoose 受影響版本爲 5.9.4 參見 issue github.com/Automattic/mongoose/issues/8664[6]

參考資料

[1]

https://www.cnblogs.com/vajoy/p/6349817.html: https://www.cnblogs.com/vajoy/p/6349817.html

[2]

batchSize: https://docs.mongodb.com/manual/tutorial/iterate-a-cursor/#cursor-batches

[3]

cursorTimeoutMillis#Default: 600000 (10 minutes): https://docs.mongodb.com/manual/reference/parameters/#mongodb-parameter-param.cursorTimeoutMillis

[4]

DBQuery.Option.noTimeout: https://docs.mongodb.com/manual/reference/method/cursor.addOption/#mongodb-data-DBQuery.Option.noTimeout

[5]

NPM 包 mongodb 受影響版本爲 3.5.4 參見 issue jira.mongodb.org/browse/NODE-2483: https://jira.mongodb.org/browse/NODE-2483

[6]

NPM 包 mongoose 受影響版本爲 5.9.4 參見 issue github.com/Automattic/mongoose/issues/8664: https://github.com/Automattic/mongoose/issues/8664

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/5Bjw9_pfRnIZ8JuAHMssNw