MongoDB 系列 - 數(shù)據(jù)查詢游標(biāo)你用對了嗎?
本文轉(zhuǎn)載自微信公眾號「編程界」,作者五月君 。轉(zhuǎn)載本文請聯(lián)系編程界公眾號。
幾個話題
本文會根據(jù)以下幾個話題進(jìn)行討論與講解,文中的目錄不完全和這幾個話題一致,但當(dāng)你閱讀完本文后,相信這些答案應(yīng)該也有了,都在文中。
- 為什么要使用游標(biāo)、什么時候使用?
- 關(guān)注服務(wù)器內(nèi)存,游標(biāo)什么時候關(guān)閉?
- 需要注意的游標(biāo)超時與容錯處理
- 為什么不要隨意調(diào)整 batchSize 數(shù)量?
- 使用時需注意 Mongoose 與原生 Node.js MongoDB 驅(qū)動程序的不同之處
- 解答群友問題時發(fā)現(xiàn)的一個關(guān)于游標(biāo)的 Bug
- 擴(kuò)展 - 為什么可以使用 for await of 遍歷游標(biāo)對象?
為什么要使用游標(biāo)?
這樣的寫法 collection.find().toArray(),大家在學(xué)習(xí) MongoDB 時應(yīng)該見的也不少,它的原理是客戶端驅(qū)動程序會自動把返回的所有數(shù)據(jù)一次性加載到應(yīng)用程序內(nèi)存中,理解起來相對簡單些,如果數(shù)據(jù)量小是沒問題的,在一些數(shù)據(jù)處理的場景中,具體有多少數(shù)據(jù)也許是未知的,有可能返回大量的數(shù)據(jù),如果全部 hold 在內(nèi)存,在服務(wù)端內(nèi)存寸土寸金的地方,白白消耗服務(wù)內(nèi)存不說,內(nèi)存占用過高還可能造成服務(wù) OOM。
MongoDB 里面的游標(biāo),有點(diǎn)類似于在 Node.js 里使用 Stream 處理文件數(shù)據(jù),相比把整個文件讀入內(nèi)存在處理這種模式,Stream 帶來的收益是很大的。
很形象的一個圖,來源:https://www.cnblogs.com/vajoy/p/6349817.html[1]
游標(biāo)基本工作原理
當(dāng)我們使用 collection.find() 或 collection.aggregate() 返回的是一個指向該集合的指針,也稱為游標(biāo)(cursor),是不能直接訪問數(shù)據(jù)的,只有當(dāng)循環(huán)迭代這個游標(biāo)時才會真正的從數(shù)據(jù)庫集合讀取數(shù)據(jù)。
在 Node.js 中使用很簡單,只要支持 for await of 語法,即可遍歷游標(biāo)返回的數(shù)據(jù)集,和正常使用 for of 遍歷數(shù)組很相似,區(qū)別是 for await of 遍歷的數(shù)據(jù)源是異步的。當(dāng)循環(huán)迭代開始時驅(qū)動程序會使用 getMore() 命令批量從數(shù)據(jù)庫集合中獲取一批數(shù)據(jù)先緩存起來,例如 Node.js MongoDB 驅(qū)動程序每次默認(rèn)批量獲取 1000 條(注意,第一次 getMore() 時實(shí)際請求是 101 條),取決于 batchSize[2] 參數(shù)設(shè)置,待這批數(shù)據(jù)處理完成之后,在向 MongoDB Server 執(zhí)行 getMore() 繼續(xù)請求直到游標(biāo)耗盡。
以下為 Node.js 中的兩種使用示例,個人比較推薦 for await of 這種寫法。方法二 while 循環(huán)這種寫法在一個 MongoDB Node.js 驅(qū)動程序版本中存在一個 Bug 下文會介紹。
- const userCursor = await collection.find();
- // 如果沒有返回數(shù)據(jù),需要做一些特殊處理的,可以使用 userCursor.count() 或 userCursor.hasNext()
- if (!await userCursor.count()) {
- // TODO: 提前結(jié)束,做一些其它操作
- return;
- }
- // 方法一:
- for await (const user of userCursor) {
- }
- // 方法二:
- while (await userCursor.hasNext()) {
- const doc = userCursor.next();
- }
例如,數(shù)據(jù)庫集合有 10000 條數(shù)據(jù),每次批量獲取 1000 條,I/O 消耗應(yīng)該也為 10 次。終端鏈接至 MongoDB Server 設(shè)置 db.setProfilingLevel(0, { slowms: 0 })記錄所有的操作日志,之后在打開 MongoDB Server 控制臺日志,執(zhí)行應(yīng)用程序之后會看到如下日志信息,每次 getMore 都指向了同一個游標(biāo) ID getMore: 5098682199385946244。
游標(biāo)讀取結(jié)果.png
如果需要修改 batchSize 結(jié)果的,通過 options 指定 batchSize 屬性或調(diào)用 batchSize 方法都可以。
- collection.find().batchSize(1100)
- // 或以下方法
- collection.find({}, {
- batchSize: 1100
- })
切記不要將 batchSize 設(shè)置為 1,例如,10000 條數(shù)據(jù)每獲取一條數(shù)據(jù),客戶端都將連接服務(wù)器讀取,這將會產(chǎn)生 10000 次網(wǎng)絡(luò) IO,下圖使用 mongostat 監(jiān)控,展示了每秒查詢游標(biāo)時的 getMore 次數(shù)。
游標(biāo)超時
如果一個游標(biāo)在一定時間內(nèi)無人訪問,超時之后會被回收,防止產(chǎn)生內(nèi)存泄漏,啟動時可通過 mongod --setParameter cursorTimeoutMillis=300000 參數(shù)設(shè)置,默認(rèn)超時為 10 分鐘,參見文檔 cursorTimeoutMillis#Default: 600000 (10 minutes)[3]。
例如,總共查詢 10000 條數(shù)據(jù),第一次 getmore() 默認(rèn)批量獲取 1000 條數(shù)據(jù),如果在默認(rèn)的 10 分鐘內(nèi)沒有處理完成這 1000 條數(shù)據(jù),游標(biāo)會被關(guān)閉,待下次執(zhí)行 getmore() 就會報錯 cursor id 4011961159809892672 not found,一般稱之為游標(biāo)超時。
如有遇到游標(biāo)超時,可通過調(diào)整 cursorTimeoutMillis 參數(shù)或減少 batchSize 數(shù)量選擇適合于自己的程序配置,通常默認(rèn)配置是不需要調(diào)整的。例如,在遍歷游標(biāo)數(shù)據(jù)時調(diào)了一個外部接口,由于接口超時導(dǎo)致的游標(biāo)超時這種外部業(yè)務(wù)原因的,應(yīng)先去優(yōu)化業(yè)務(wù)本身,再考慮調(diào)整配置。
為了解決游標(biāo)超時,你可能還見到過 cursor.addCursorFlag('noCursorTimeout', true) 這樣的配置,這會禁用掉游標(biāo)的超時限制,只有等到游標(biāo)耗盡或手動關(guān)閉 cursor.close() 游標(biāo)才可能被釋放,禁用超時時間這種做法,很不推薦使用,每個游標(biāo)都存在額外的內(nèi)存占用消耗,如果因?yàn)槭韬鐾浭謩雨P(guān)閉游標(biāo)導(dǎo)致的 MongoDB Server 內(nèi)存泄漏就得不償失了。
游標(biāo)狀態(tài)
登陸 MongoDB 客戶端,執(zhí)行 db.serverStatus().metrics.cursor 命令,查看當(dāng)前游標(biāo)使用狀態(tài)。如果真的出現(xiàn)游標(biāo)導(dǎo)致的 MongoDB 服務(wù)器內(nèi)存泄漏,以下幾個數(shù)據(jù)指標(biāo),做為運(yùn)維人員在排查問題時,會有幫助。
- timedOut:指 MongoDB Server 進(jìn)程啟動到現(xiàn)在所有的游標(biāo)超時數(shù)量,此指標(biāo)反映了應(yīng)用程序因?yàn)樘幚砗臅r任務(wù) 或 游標(biāo)打開后因?yàn)閳箦e沒有顯示關(guān)閉游標(biāo) 這兩種情況導(dǎo)致的游標(biāo)超時數(shù)量。
- open.noTimeout:為了防止游標(biāo)超時,MongoDB 提供了一個配置 DBQuery.Option.noTimeout[4] 設(shè)置永不超時,但如果處理完畢忘記顯示關(guān)閉游標(biāo),會導(dǎo)致游標(biāo)常駐內(nèi)存,數(shù)量越大內(nèi)存泄漏的風(fēng)險也越大,建議是盡量不要設(shè)置 noTimeout。
- open.pinned:“固定” 打開游標(biāo)的數(shù)量。
- open.total:MongoDB Server 當(dāng)前為客戶端打開的游標(biāo)數(shù)量,當(dāng)有游標(biāo)耗盡,total 的數(shù)量也會不斷的減少。
- {
- "timedOut" : NumberLong(4),
- "open" : {
- "noTimeout" : NumberLong(0),
- "pinned" : NumberLong(0),
- "total" : NumberLong(0)
- }
- }
游標(biāo)與異步迭代器
JavaScript 在 ES6 語法提供了一個功能叫迭代器,定義了一套統(tǒng)一的接口,只要實(shí)現(xiàn)了該接口的數(shù)據(jù)類型,都可使用 for of 關(guān)鍵詞遍歷,例如數(shù)組、Map、Set 類型等,這些類型上有一個方法 Symbol.iterator 返回的就是一個迭代器對象,迭代器對象的 next() 方法返回值包含了 vlaue、done 兩個屬性,如果 done 為 true 表示數(shù)據(jù)已遍歷完成,但 Symbol.iterator 只支持同步的數(shù)據(jù)源。
而我們從數(shù)據(jù)庫集合獲取數(shù)據(jù)涉及到網(wǎng)絡(luò) I/O,這是一個異步的操作,Symbol.iterator 就無法支持了,在ECMAScript 2018 標(biāo)準(zhǔn)中提供了一個新的屬性 Symbol.asyncIterator,這是一個異步迭代器,與 Symbol.iterator 不同的是 Symbol.asyncIterator 的 next() 方法返回的是一個包含 { value, done } 的 Promise 對象,如果一個對象設(shè)置了該屬性,它就是異步可迭代對象,相應(yīng)的我們可使用 for await...of 循環(huán)遍歷數(shù)據(jù)。
下面看下 MonogoDB Node.js 驅(qū)動程序在 v4.2.2 版本中的實(shí)現(xiàn),同樣也提供了 Symbol.asyncIterator 接口,這也就是為什么我們可以使用 for await...of 循環(huán)遍歷。
- // 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, done: false }: { value: undefined, done: true })
- };
- }
- }
容錯處理
在遍歷游標(biāo)的過程中,for 循環(huán)體內(nèi)如果出現(xiàn)一些錯誤導(dǎo)致循環(huán)提前終止,這個時候游標(biāo)并不會被立刻銷毀,可以選擇手動關(guān)閉游標(biāo)或等待超過默認(rèn)的游標(biāo)超時時間后,游標(biāo)也會被銷毀。
如果設(shè)置了 noCursorTimeout 屬性為永不超時,這個時候就一定記得要關(guān)閉游標(biāo),因此在上面也建議盡量不要做這個設(shè)置。
- 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() 方法默認(rèn)不會返回游標(biāo)對象,需要在 find 后顯示調(diào)用 cursor() 方法,且沒有 cursor.count()、cursor.hasNext() 方法支持,對于一些想判斷如果游標(biāo)沒有數(shù)據(jù)做一些特殊處理,處理起來不是很友好。
- const userCursor = await User.find({}).cursor();
- for await (const user of userCursor) {
- }
一個關(guān)于游標(biāo)的 Bug
在 Node.js 群里,一個群友發(fā)來消息使用游標(biāo)遇到了問題,后來也對這個問題做了一些查找和驗(yàn)證,下文會介紹,基于一個特定版本和特定的應(yīng)用場景才會出現(xiàn)這個問題,放在這里也是希望用到的朋友能少踩一個坑。
MongoDB Node.js 驅(qū)動程序在 3.5.4 版本基于游標(biāo)迭代查詢數(shù)據(jù)時,如果用了 limit 限制返回的數(shù)據(jù)條目,并且使用 hasNext(),存在一個 Bug,首先是從返回的游標(biāo)對象取出的 count 數(shù)不對,其次是遍歷出的數(shù)據(jù)條目與實(shí)際 limit count 數(shù)對不上,如果 limit 為奇數(shù)還會收到 MongoError: Cursor is closed 錯誤。
如果需要調(diào)整每一次的 getMore() 數(shù)量,游標(biāo)可以結(jié)合 batchSize 使用。為什么用了游標(biāo)還要使用 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 版本輸出結(jié)果:
- 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