摘要 :使用 Pymongo Cursor 分批查询数据时,偶尔出现 CursorNotFound 异常, 原来是 LoadBalancer HA 机制导致 Cursor 访问到了不同的 mongos 后端。

问题描述

线上某服务偶尔会抛出CursorNotFoundException,导致接口返回 500 错误。 该接口有一个 MongoDB 数据库查询,每当查询结果超过 100 条时,就有一定的概率抛该异常。

报错时的异常堆栈如下:

Traceback (most recent call last):
  File "/usr/local/lib/python2.7/dist-packages/nameko_cacheback.py", line 153, in fetch
    *args, **kwargs)
  File "/usr/local/lib/python2.7/dist-packages/eventlet/timeout.py", line 141, in with_timeout
    return function(*args, **kwds)
  File "./service/xxx_service.py", line 1150, in xxx
    return [data['xxx'] for data in res if 'xxx' in data]
  File "/usr/local/lib/python2.7/dist-packages/pymongo/cursor.py", line 1189, in next
    if len(self.__data) or self._refresh():
  File "/usr/local/lib/python2.7/dist-packages/pyzipkin/patcher/pymongo.py", line 179, in _patched_refresh
    res = __org_refresh__(self, *args, **kwargs)
  File "/usr/local/lib/python2.7/dist-packages/pymongo/cursor.py", line 1126, in _refresh
    self.__send_message(g)
  File "/usr/local/lib/python2.7/dist-packages/pymongo/cursor.py", line 978, in __send_message
    codec_options=self.__codec_options)
  File "/usr/local/lib/python2.7/dist-packages/pymongo/cursor.py", line 1067, in _unpack_response
    return response.unpack_response(cursor_id, codec_options)
  File "/usr/local/lib/python2.7/dist-packages/pymongo/message.py", line 1418, in unpack_response
    self.raw_response(cursor_id)
  File "/usr/local/lib/python2.7/dist-packages/pymongo/message.py", line 1384, in raw_response
    raise CursorNotFound(msg, 43, errobj)
CursorNotFound: Cursor not found, cursor id: 7615467649098

问题定位

Google CursorNotFound 原因

通过网上 Googlepymongo CursorNotFound,mongo 获取数据时,不是一次性把所有数据都读下来, 而是分批按需去获取。

mongodb-cursor.png

Mongodb Cursor交互过程

一般以下情况会触发该异常:

  1. Cursor 默认存活时期为 10 分钟 超过这个时间 mongos 会清理掉这个 Cursor。
  2. Cursor 迭代完了以后,重新开始迭代 当 Cursor 中的数据迭代完以后会自动关闭 Cursor,重新使用会报 CursorNotFound
  3. 使用 LoadBalancer 连接多个 mongos 后端 当多个 mongos 部署在 loadbalancer(lb) 后方,使用 Cursor 从 mongos 多次读取数据的时使用了不同的链接。
  4. 使用域名访问多个 mongos 后端 动态 DNS 返回多个 mongos 节点,使用 Cursor 从 mongos 多次读取数据的时使用了不同的链接。

上面 4 种情况,符合我们的情况的是第 3 条。

验证 LoadBalander 导致 CursorNotFound

为了验证线上服务确实触发了第 3 种情况,先找一个文档数会超过 100 的记录,然后写个脚本周期性的去请求接口。 使用 tcpdump 抓取连接到 lb 的流量包,将结果存到 cap 文件中,然后使用 Wireshark 进行分析。

Wireshark 支持 Mongo 的协议分析:编辑->首选项->Protocols选择MONGO。 Mongo 的协议参看: https://docs.mongodb.com/manual/reference/mongodb-wire-protocol/

某次请求的抓包情况如下:CursorNotFound.png

其中第 212 条响应中含有CursorNotFound错误。可以看到,第一次成功Get More源端口是 42984,而异常的端口是 50031, 显然确实出现了相同的 Cursor 两次请求使用了不同 TCP 连接去访问 lb 及后端 mongos,导致CursorNotFound问题。

问题原因

至于为什么同一个 Cursor 会使用不同的连接去请求数据,这个涉及到 pymongo(当前版本是 v3.7.2)的实现:

client = MongoClient('mongodb://host1,host2,host3/?localThresholdMS=30')
  1. pymongo 支持同时连接多个 mongos(每个 host 对应一个 mongos) 表示一个 MongoClient 客户端可以和多个 mongos 同时建立连接。
  2. 每个 mongos 连接都由一个 server 对象表示,server 对象包含一个连接池 表示一个 MongoClient 客户端可以和每个 mongos 同时建立多个连接,在 pymongo 看来,这些连接都是对等的。
  3. 当使用 find 获取一批时,会先创建一个 Curosr 对象
  4. Cursor 对象会关联到当前网络延迟小的 server 对象
  5. server 对象随机从连接池获取一个 mongos 连接
  6. mongos 端创建并维护 cursor id, 返回一批数据
  7. 当继续枚举下一批数据时,Cursor 使用关联的 server
  8. server 会再随机从连接池选一个 mongos 连接(问题就出现在这里,这里可能选到不同的 mongos)
  9. 如果选到原来的 mongos,则返回正常,否则抛 CursorNotFound 异常

解决方案

  1. 按官方推荐的采用写死多个 mongos 的方案
  2. 采用 LoadBalancer,但是需要支持按客户端来源 IP 分配固定的后端 mongos
  3. 采用域名方式,但是客户端解析多个 IP,并固定使用某个 IP