这是 MongoDB 分片集群写入路径系列的第 6 篇(最后一篇)。
本文回答的问题:用
secondaryPreferred把读取引到 secondary 上,primary 停住期间查询就安全吗?先说结论:不安全。分片集群的 secondary 要经由自己 primary 的写入,才能获取记录“哪些数据属于哪个 shard”的归属清单(路由元数据)。所以在 primary 无法写入的 shard 上,重启后的 secondary 拿不到归属清单,会以StaleConfig拒绝读取。
基准版本:MongoDB 4.2(源码标签r4.2.12)。最新版本未经确认。
分片集群把集合按 chunk(分片键区间单位)划分,放到多个 shard 上。balancer 为了平衡 shard 之间的数据量而迁移 chunk。迁移的顺序是“复制到新 shard → 移交归属 → 从旧 shard 删除”。
在最后的删除完成之前,旧 shard 上还留着归属已经移交出去的文档。这种文档称为孤儿(orphan)文档。例如,假设 store_inventory 集合中包含文档 X 的 chunk 从 shardA 迁移到了 shardB。各时间点的状态如下。
| 时间点 | shardA | shardB | 归属方 |
|---|---|---|---|
| 迁移前 | X | 无 | shardA |
| 复制后、删除前 | X(孤儿) | X | shardB |
| 删除后 | 无 | X | shardB |
如果在中间时间点来了一个询问所有 shard 的查询,两个 shard 上都有 X。如果各 shard 不确认归属就返回,响应中 X 会出现两次。所以 shard 在响应之前必须确认“这个文档是否归我所有”,并过滤掉孤儿文档。
要过滤,节点就必须持有归属清单。归属清单是记录“哪个 chunk 归哪个 shard 所有”的信息。节点把归属清单及其版本放在内存中。与 MongoDB 术语的对应关系如下。
本文此后只使用归属清单这个名称。
mongos 向 shard 发送读取时,会附上自己所知道的 shard version。收到的节点把这个版本与自己的版本比对(collection_sharding_state.cpp:201-278)。3.6 发行说明写道,不只是 primary,secondary 也维护 chunk 元数据,从而让 secondary 读取不会返回孤儿文档。
"Starting in 3.6, all members of a shard replica set, not just the primary, maintain the metadata regarding chunk metadata."
检查结果有三种。
| 节点的状态 | 节点的处理 |
|---|---|
| 版本与 mongos 相同 | 读取并过滤掉孤儿文档后响应 |
| 版本不同 | 拒绝。较旧的一方获取最新的归属清单 |
| 没有归属清单(UNKNOWN) | 拒绝,并去获取归属清单 |
拒绝时使用的错误是 StaleConfig,代码为 13388(error_codes.err:319)。源码把没有归属清单的状态称为 UNKNOWN,并这样定义(collection_sharding_runtime.h:85-86)。
"all attempts to check for shard version match will fail with StaleConfig errors in order to trigger an update."
也就是说,StaleConfig 是“我的归属清单过时了或者没有,拿到新的之后再来”的信号。平时刷新会立即完成,mongos 会重试,所以应用程序看不到这个错误。
primary 和 secondary 获取归属清单的途径不同。primary 直接从 config 服务器获取。secondary 不问 config 服务器,而是经由自己的 primary。4.2 源码中的顺序如下(shard_server_catalog_cache_loader.cpp:268-290, 428-431, 585-591)。
_flushRoutingTableCacheUpdates 命令。源码以旧名称 forceRoutingTableRefresh 发送这个命令。旧名称是为兼容 3.6.0 保留的别名(flush_routing_table_cache_updates_command.cpp:60-63)。时限为 30 秒(Seconds{30})。config.cache.* 集合。waitUntilOpTimeForRead)。这次写入通过复制到达后,secondary 读取本地 config.cache.*,填充内存中的归属清单。secondary 的一次读取要刷新归属清单,必须先完成 primary 的一次写入及其复制。因为第 2 步是写入。
源码注释中没有说明 secondary 为什么不直接询问 config 服务器。本文把这种结构看作是为了让已复制的数据与归属清单保持同一时间点的设计。如果 secondary 只通过归属清单先得知了自己尚未复制到的 chunk 迁移,文档和归属清单就会对不上。通过同一条复制流获取两者,就不会出现这种错位。这种解读是从源码推导出的推论。
归属清单在内存中。重启 mongod 后,内存中的归属清单从空开始(metadata_manager.cpp:246-252)。磁盘上的 config.cache.* 还在,但内存中的归属清单要在第一次刷新时才会被填充。所以重启后的节点对每个集合的第一次读取都会以 StaleConfig 拒绝,并刷新归属清单。
在此基础上叠加 primary 无法写入的情况。进程活着但写入停住的 primary,在本系列第 5 篇中讨论过。把该 shard 的 secondary 分成三种情况比较,如下。
| secondary | 内存中的归属清单 | 读取结果 |
|---|---|---|
| 未重启,已经在读的集合 | 有 | 继续读取 |
| 未重启,第一次读的集合 | 无 | 刷新失败而拒绝 |
| 已重启 | 所有集合都没有 | 每个集合的第一次读取都因刷新失败而拒绝 |
重启后的 secondary 为了刷新而向 primary 发送命令。但 primary 写不了 config.cache.*。secondary 要么撞上 30 秒时限,要么在等待复制时刷新失败。归属清单一直是 UNKNOWN,所以下一次读取也走同样的路。在正常的 primary 立起来之前,这个节点连该 shard 的一个读取都处理不了。
secondaryPreferred 这类 readPreference 是选择读取发往哪个节点的规则。它并不决定那个节点从哪里获取归属清单。即使把读取引到 secondary,归属清单的刷新依然被 primary 的写入绑住。
StaleConfig 是在 mongod 与 mongos 之间往来的错误。mongos 收到这个错误后,会等待刷新再重试。所以应用程序可能看不到原样的 StaleConfig。在 mongos 重试期间,应用程序的请求一直在等待响应。从应用程序一侧看,可能只表现为连接超时或 socket 超时。
这种情况下要找的信号如下。
forceRoutingTableRefresh 或 _flushRoutingTableCacheUpdates 失败。StaleConfig(13388)。this shard no longer contains chunks for <ns>(collection_sharding_state.cpp,基于源码的推论)。用 readConcern: "available" 读取时,节点会跳过 shard version 检查。collection_sharding_state.cpp:210-212 中的 invariant 确认了这个前提。即使没有归属清单,节点也会响应读取。
代价是孤儿文档。3.6 发行说明写道,secondary 的归属清单是阻挡孤儿文档的机制,"available" 时没有这种保护。如果处在上表的中间时间点,文档 X 可能出现两次。
// mongos에 연결한 셸 예시
db.store_inventory.find({ "_id.productId": 1001 })
.readPref("secondaryPreferred")
.readConcern("available")
是否使用,取决于查询结果需要多精确。对于混入重复或已迁走的文档也无妨的界面(例如大致的库存显示),可以选择使用。对于支付·结算这类条数必须准确的查询,不要使用。
readConcern: "available" 只用于可以接受孤儿文档的查询。陷阱:重启 secondary 是常见的“先救活再说”的措施。在这种情况下,它会把能读的 secondary 变成读不了的 secondary。重启后内存中的归属清单会被清空,而要填充这个归属清单,需要停住的 primary 进行写入。
这是未经实际运行验证的步骤。下面的步骤和结果是以源码为依据的预期。
需要准备 MongoDB 4.2 二进制文件。运维人员在本地启动 1 个 config 服务器 replica set、2 个 shard(各为 3 节点 replica set)和 1 个 mongos。
_id hashed 分片键对 store_inventory 集合分片并插入文档。sh.enableSharding("demo")
sh.shardCollection("demo.store_inventory", { _id: "hashed" })
for (let p = 1; p <= 100; p++)
for (let s = 1; s <= 20; s++)
db.getSiblingDB("demo").store_inventory.insertOne({ _id: { productId: p, storeId: s }, qty: 10 })
{ node: "a1" }、{ node: "a2" })。在同一次 reconfig 中把两台 secondary 的 priority 改为 0。这个设置是为了在停住 primary 时不发生选举。find(...).explain() 中的 shard 名称确认。db.store_inventory.find({ _id: { productId: 1, storeId: 1 } })
.readPref("secondary", [{ node: "a1" }]).maxTimeMS(60000)
kill -STOP <pid>。kill -CONT <pid> 唤醒 primary,再次向 a1 发送查询。预期结果如下。
| 步骤 | a1(已重启) | a2(未重启) |
|---|---|---|
| 第 7 步 | 响应迟迟不来后失败。a1 日志中留有刷新失败,mongos 日志中留有 StaleConfig |
立即响应 |
| 第 8 步 | 响应 | 响应 |
这个复现的局限:
SIGSTOP会停住整个进程。heartbeat 应答也会停住,所以 replica set 会把 primary 视为“联系不上”。而引擎陷入 livelock 的 primary 会一边应答 heartbeat,一边只是写不进去(第 5 篇)。虽然用 priority 0 阻止了选举,但从 a1 寻找 primary 的阶段起就与实际情况不同了。所以第 7 步的错误可能不是 30 秒时限,而是“找不到 primary”一类的错误。这个复现用来观察只有重启过的节点会被挡住这一差异。
- 未能确认这种情况下 mongos 返回给应用程序的错误是
StaleConfig还是超时类错误。“在应用程序中可能表现为超时”是一般性的说法。- secondary 不直接询问 config 服务器的原因,源码中没有写明。上面的说明是推论。
- 没有另行确认未分片的集合在重启后是否走同样的路径。
- 没有确认 4.2 之后的版本中 secondary 的刷新路径是否有变化。
- 「动手复现」没有实际运行。