前言
聊一聊一个最基本的问题,游标的使用。可能你从来没有注意过它,但其实它在 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 决定。所以当程序进行查询的时候,实际上在后台发生的事情包括:
驱动在后台获取 batchSize 条数据并自己缓存起来;
每次程序调用游标的 next() 方法时,从这些缓存中提取一条并返回;
当 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 0ms2018-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 怎么样,而在于你的应用到底能够处理多快。假设问题还是发生了,你的应用遇到了游标超时错误,怎么办呢?你至少可以有以下一些选择:
延长游标超时时间,请参考 cursorTimeoutMillis;
加速应用的处理速度,处理得快了,下一次 getmore 自然就发生得更早;
不是那么直观,但是减小 batchSize 也可以达到同样的目的;
禁用超时时间 (noCursorTimeout)——绝对不推荐使用。虽然可以达到目的,你也可以说我会在最后主动关闭游标的,但事实上总会发生这样那样的意外,导致你最终没有正确关闭游标,最后服务器上塞满了游标的情况也是很常见的。
例外情况
上面已经解释过,在游标超时的时候你得到的实际是“游标不存在”错误,而不是超时。那么反过来是不是也成立呢,“游标不存在”一定是超时了吗?离散数学告诉我们,一个命题的逆命题不一定成立。事实上也是如此。“游标不存在”的另一种可能性是有些用户热衷于在 MongoDB 前面加上负载均衡 / 自动故障恢复的软 / 硬件。我们已经知道游标是存在于一台服务器上的,如果你的负载均衡毫无原则地将请求转发到任意服务器上,getmore 同时会因为找不到游标而出现“游标不存在”的错误。事实上 MongoDB 和其驱动本身就已经能够完成高可用和负载均衡,并不需要额外画蛇添足。