有坑勿踩(二)——關於遊標

前言

聊一聊一個最基本的問題,遊標的使用。可能你從來沒有注意過它,但其實它在MongoDB的使用中是普遍存在的,也存在一些常見的坑需要引起我們的注意。

在寫這個系列文章時,我會假設讀者已經對MongoDB有了最基礎的瞭解,因此一些基本名詞和概念就不做過多的解釋,請自己查閱相關資料。

使用場景

可能你以爲你並沒有經常在使用遊標,但是其實只要在做查詢,幾乎時時刻刻都在用它。本質上所有查詢的數據都是從遊標來的。你說你用toArray()?不存在的,它也是在遍歷遊標然後返回給你一個數組而已。正是因爲這樣,就出現了第一個問題:除非你確定返回數據量有限,否則不要隨便toArray()
這裏說的toArray()包括:

  • shell中的toArray()。例如: var result = db.coll.find().toArray();
  • node中的toArray()。例如:var result = await db.collection("coll").find().toArray();
  • python中的list()。例如:result = list(db.coll.find());
  • Java中的toArray()。例如:DBCursor.toArray();

因爲無論遊標裏有多少數據,toArray()都會給你挖出來放到內存裏,變成數組返回給你。慢不說,內存也佔用了很多。所以在可能的情況下,還是儘可能使用hasNext()/next()來得更好。

遊標主要來自兩個地方:

  • find
  • aggregation

注意二者返回的雖然都是“遊標”,但又是兩種不同的遊標,使用上API也不完全相同,使用的時候請先查閱API(特別是使用NodeJS之類的動態語言的時候不要想當然)。

batchSize與getmore

說完從哪裏來,下面就該說說怎麼用的問題。
可能你已經從什麼地方看到過getmore,比如mongostat的結果中。getmore的作用是從遊標中提取一批數據,具體提取多少則是由batchSize決定。
所以當程序進行查詢的時候,實際上在後臺發生的事情包括:

  1. 驅動在後臺獲取batchSize條數據並自己緩存起來;
  2. 每次程序調用遊標的next()方法時,從這些緩存中提取一條並返回;
  3. batchSize條數據都返回完之後,驅動再次通過getmore獲取batchSize條數據。

我們可以通過shell來觀察這一過程:

  • 先插入一批數據:

    use foo
    for(var i = 0; i < 1000; i++) {
        db.bar.insert({i: i});
    }
  • 強制日誌記錄所有操作:

    db.setProfilingLevel(0, 0)
  • 跟蹤日誌:

    tail -f mongod.log

現在執行一條find語句:

replset:PRIMARY> db.bar.find().batchSize(50);
2018-12-29T16:01:29.587+0800 I COMMAND [conn12] command test.bar appName: "MongoDB Shell" command: find { find: "bar", filter: {}, batchSize: 50.0, \$clusterTime: { clusterTime: Timestamp(1546070474, 1), signature: { hash: BinData(0, 0000000000000000000000000000000000000000), keyId: 0 } }, $db: "test" } planSummary: COLLSCAN cursorid:77199395767 keysExamined:0 docsExamined:50 numYields:0 nreturned:50 reslen:2062 locks:{ Global: { acquireCount: { r: 1 } }, Database: { acquireCount: { r: 1 } }, Collection: { acquireCount: { r: 1 } } } protocol:op_msg 0ms

雖然我們在shell中只輸出了20條結果,但實際上我們已經從這個遊標中獲取了50條數據(日誌中的黑體部分)。所以當我們繼續遍歷這個遊標時是暫時不需要再次從數據庫中取數據的。同時注意我們已經有了一個遊標cursor:77199395767
但當我們第三次遍歷20條數據時,則會出現getmore日誌:

replset:PRIMARY> it
2018-12-29T16:03:46.007+0800 I COMMAND [conn12] command test.bar appName: "MongoDB Shell" command: getMore { getMore: 77199395767, collection: "bar", batchSize: 50.0, \$clusterTime: { clusterTime: Timestamp(1546070594, 1), signature: { hash: BinData(0, 0000000000000000000000000000000000000000), keyId: 0 } }, \$db: "test" } originatingCommand: { find: "bar", filter: {}, batchSize: 50.0, \$clusterTime: { clusterTime: Timestamp(1546070474, 1), signature: { hash: BinData(0, 0000000000000000000000000000000000000000), keyId: 0 } }, \$db: "test" } planSummary: COLLSCAN cursorid:77199395767 keysExamined:0 docsExamined:50 numYields:0 nreturned:50 reslen:2061 locks:{ Global: { acquireCount: { r: 1 } }, Database: { acquireCount: { r: 1 } }, Collection: { acquireCount: { r: 1 } } } protocol:op_msg 0ms
2018-12-29T16:03:46.010+0800 I COMMAND [conn12] command admin.\$cmd appName: "MongoDB Shell" command: replSetGetStatus { replSetGetStatus: 1.0, forShell: 1.0, \$clusterTime: { clusterTime: Timestamp(1546070624, 1), signature: { hash: BinData(0, 0000000000000000000000000000000000000000), keyId: 0 } }, \$db: "admin" } numYields:0 reslen:896 locks:{} protocol:op_msg 0ms

它通過同一個遊標再次提取了50條數據供使用。當我們用完緩存中的數據之前都是不會再看到新的getmore指令的。

遊標超時

上面已經瞭解了遊標與驅動是如何配合工作的,那麼遊標超時是怎麼發生的呢?條件很簡單,2次getmore之間間隔了超過10分鐘,即一個遊標在服務端超過10分鐘無人訪問,則會被回收掉。這時候如果你再針對這個遊標進行getmore,就會得到遊標不存在的錯誤(是的,超時的遊標在數據庫中是不存在的,你得到的錯誤不會是超時,而是遊標不存在。爲了便於理解,我們下面還是稱之爲“遊標超時”)。
那麼假設你通過遊標讀取數據的時候是爲了進行一系列分析處理,那麼下一次getmore在什麼時候發生將取決於你的應用在多長時間內消耗完了當前緩存中的數據。換句話說,你的應用處理得越慢,下一次getmore發生的時間就越晚。很多驅動中batchSize的默認值是1000,這也代表着你的應用必須至少能夠在10分鐘內處理1000條數據,否則就會得到遊標超時錯誤。所以諸如每一條數據需要查詢其他數據庫1次,需要通過RESTful API到互聯網上獲取相關的數據,或者需要進行一系列複雜的運算,這樣的場景下,問題的關鍵其實不在於MongoDB怎麼樣,而在於你的應用到底能夠處理多快。
假設問題還是發生了,你的應用遇到了遊標超時錯誤,怎麼辦呢?你至少可以有以下一些選擇:

  1. 延長遊標超時時間,請參考cursorTimeoutMillis
  2. 加速應用的處理速度,處理得快了,下一次getmore自然就發生得更早;
  3. 不是那麼直觀,但是減小batchSize也可以達到同樣的目的;
  4. 禁用超時時間(noCursorTimeout)——絕對不推薦使用。雖然可以達到目的,你也可以說我會在最後主動關閉遊標的,但事實上總會發生這樣那樣的意外,導致你最終沒有正確關閉遊標,最後服務器上塞滿了遊標的情況也是很常見的。

例外情況

上面已經解釋過,在遊標超時的時候你得到的實際是“遊標不存在”錯誤,而不是超時。那麼反過來是不是也成立呢,“遊標不存在”一定是超時了嗎?離散數學告訴我們,一個命題的逆命題不一定成立。事實上也是如此。“遊標不存在”的另一種可能性是有些用戶熱衷於在MongoDB前面加上負載均衡/自動故障恢復的軟/硬件。我們已經知道遊標是存在於一臺服務器上的,如果你的負載均衡毫無原則地將請求轉發到任意服務器上,getmore同時會因爲找不到遊標而出現“遊標不存在”的錯誤。
事實上MongoDB和其驅動本身就已經能夠完成高可用和負載均衡,並不需要額外畫蛇添足。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章