有坑勿踩(二)——关于游标

前言

聊一聊一个最基本的问题,游标的使用。可能你从来没有注意过它,但其实它在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来观察这一过程:

  • 先插入一批数据:
    javascript
    use foo
    for(var i = 0; i < 1000; i++) {
    db.bar.insert({i: i});
    }
  • 强制日志记录所有操作:
    javascript
    db.setProfilingLevel(0, 0)
  • 跟踪日志:
    bash
    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和其驱动本身就已经能够完成高可用和负载均衡,并不需要额外画蛇添足。

发表评论