异常描述
在自己环境中运行流畅毫无瑕疵的程序部署到客户环境后间歇性打脸,出现以下问题:
- 当请求的数据量较小时,业务正常
- 当一次性请求的数据量变大后,出现"pymongo.errors.CursorNotFound: Cursor not found"错误,且问题稳定复现
简化后的代码如下:
page_size = n # 该参数由前端传递,当该参数取值大于100时,便报错
cursor = db.test_collection.find({'date': 20200101}).limit(page_size)
data_list = list(cursor)
解决方案尝试
Google了一把,同行们的建议基本可以总结为如下两个:
- 设置no_cursor_timeout=True
- 设置batch_size为更小的值
cursor = db.test_collection.find({'date': 20200101}, no_cursor_timeout=True)
# 或者
cursor = collection.find({'date': 20200101}, batch_size=10)
上图为官方文档对find方法的说明:https://api.mongodb.com/python/3.4.0/api/pymongo/collection.html?highlight=find#pymongo.collection.Collection.find
find方法的返回结果时一个游标(Cursor),而非查询结果本身。对于默认的 Cursor(NON_TAILABLE) 来说,当程序遍历Cursor时,客户端会批量从服务器端拉取数据。每次拉取的 Doc 数量由 batch_size 参数指定,默认为101。另外每次拉取的数据量不能超过16MB。也就是说,每次拉取的数据量由 batch_size 和 16MB 中较小的一个值决定。
默认情况下,服务端会在Cursor空闲(没有数据拉取操作)10分钟后或者Cursor到达末尾后自动关闭Cursor。所以,我们通常很少主动调用 cursor.close 方法关闭Cursor。如果想改变服务端的这一行为,只需要在查询是设置 no_cursor_timeout 为 True,此时服务器将不再会自动关闭 Cursor,客户端必须主动关闭Cursor。我认为这么做其实挺危险的,一旦程序控制流存在bug或者进程不断意外退出,都会使得服务端的Cursor持续堆积,耗尽服务器资源,业务大范围异常,甚至拖垮数据库,获得仅次于删库跑路的成就。
MongoDB 关于 Cursor 的说明:https://docs.mongodb.com/manual/tutorial/iterate-a-cursor
上述两个参数调整并尝试了各种组合之后,问题依然没有得到解决。
问题最终定位
不买关子了,最终问题确定为客户端与服务端版本不匹配。客户端使用的是 pymongo 3.4.0,而 MongoDB 的版本为 4.0.1。引发该问题的根本原因是Session和Transaction特性对Cursor产生了不兼容影响。
MongoDB 3.6 引入了Session概念,为跨文档事务做准备。MongoDB 4.0 在Session的基础上支持事务功能,实现跨文档ACID特性。4.0版本规定在 Session 内创建的 Cursor 不能在 Session 外调用其 getMore 方法,在 Session 外创建的 Cursor 不能在 Session 内调用其 getMore 方法。
MongoDB官方文档对此有说明:https://docs.mongodb.com/manual/release-notes/4.0/
经过上述分析,问题的产生的原因是当请求的数据量比较大时,客户端需要多次调用 Cursor 的 getMore 方法获取数据,然而每次调用都属于不同的 Session,所以服务端就会抛出 "Cursor Not Found" 的异常。
最终解决方案
- 升级客户端版本:这是我们最终选择的方案,将 pymongo 的版本升级到 3.11.0。这个方案有两个方面的隐患。其一是可能会影响现有环境的服务,因为现有环境中 MongoDB 的版本是 3.4 且升级成本很高,所以必须做足测试;其二是其他语言编写的服务在客户这里也可能存在相同的问题。
- 降低MongoDB服务版本:这应该是最妥帖的方法了,一个复杂的系统部署时应该锁定所有模块的版本,以免出现莫名其妙的问题。由于客户的系统已经上线,不方便回退,所以没有采用。
- 采用小批量多批次分页请求数据:这种方法的思路是手动维护一个游标,比如 Doc 的id,查询时根据 id 将 Doc 排序,先找到符合条件的最小 id,然后从最小 id 开始一次获取若干条 Doc,下次将最小 id 设置为本批次的最大 id,直到查询结果为空。该方案存在查询被反复执行、服务端压力增大的问题。
- 设置较大的 batch_size:这是我们在客户现场采取的临时解决方案,因为升级客户端版本所需的周期比较长。将该参数设置成 1000000 以后,程序可以一次性从服务端拉回所有结果,不再需要调用 Cursor 的 getMore 方法,问题得到临时解决。
- 使用 EXHAUST 类型的 Cursor:这样可以让 MongoDB的服务端将数据推送到客户端,从而避免调用 Cursor 的 getMore 方法。这种方案的问题是,如果数据量过大,可能会影响到客户端和网络的整体性能。
- 在 Session 外创建 Cursor:pymongo 3.4.0 版本似乎还没有办法做到这一点,如果后续找到了,再补充。如果哪位同学知道怎么做,可以留言赐教。
本文暂时没有评论,来添加一个吧(●'◡'●)