第37章:MongoDB分片路由源码——mongos 如何找到数据?

[复制链接]
发表于 2026-8-22 23:33:48 | 显示全部楼层 |阅读模式

马上注册,结交更多好友,享用更多功能,让你轻松玩转社区。

您需要 登录 才可以下载或查看,没有账号?立即注册

×
1. 项目配景

业务场景:本地生存电商的分片集群中出现了一个诡异问题——用户查询自己的订单列表时,偶尔会报 StaleConfig 错误。开发加了重试逻辑后,重试成功但耗时翻倍。DBA 查了 Config Server 的状态一切正常,Chunk 分布也平衡。排查了整整 3 小时才发现——用户在查询订单时用的条件是 {userId: "xxx", createdAt: {$gte: ...}},分片键是 {userId: "hashed"}。按 userId 哈希查是 Targeted Query(正确路由到 1 个分片),但如果查询条件中还有 createdAt 范围——mongos 需要从 Chunk 路由表中找到对应的分片。问题在于,这个用户的订单跨越了两个 Chunk(因为 Chunk 分裂发生在 userId 哈希值的范围内),而一个 Chunk 正在被 Balancer 迁移——导致了 StaleConfig。
痛点:分片集群的查询路由不是简单的"查一下 Config Server 拿到分片地址就完事"。mongos 维护了一套 Catalog Cache(路由表缓存),Chunk 的迁移和分裂都在及时更新路由信息——如果客户端的路由缓存过期,就会遇到 StaleConfig 错误。理解 mongos 的路由机制、Catalog Cache 的革新逻辑、ChunkVersion 和 ShardVersion 的校验机制——是解决分片集群"诡异"问题的关键。
2. 项目设计

小胖(拿着错误日志):大师!用户查订单时不时报 StaleConfig 错,但重试又好了。什么情况?
大师:StaleConfig 是 mongos 的路由缓存过期了——它以为某个 orderNo 在 shard-1 上,实际已经被 Balancer 迁移到 shard-2 上了。这个错误是"设计内"的——mongos 不需要你手动干预,Driver 会自动革新路由缓存然后重试。
小胖:路由缓存是什么?mongos 不是每次查询都去 Config Server 问吗?
大师:如果每次查询都问 Config Server——Config Server 就成了瓶颈。mongos 的做法是把 Config Server 的 Chunk 路由表(哪个分片键范围在哪个分片上)缓存到本地内存中——这就是 Catalog Cache。默认每 30 秒自动增量革新一次。
技能映射:mongos 中的 CatalogCache 类维护了一个内存中的 Chunk 分布映射。查询到来时——mongos 根据查询条件中的分片键值,在路由表中找到对应的 Chunk → 确定目标分片 → 将请求转发过去。
小白:那 ChunkVersion 和 ShardVersion 又是干什么的?
大师:它们是 mongos 和分片之间的"版本校验"机制。每次 Chunk 迁移都会更新版本号。mongos 带着它认为的 ChunkVersion 向目标分片发送请求——分片检查这个版本是否与自己实际拥有的数据版本一致。如果不一致(阐明 Chunk 已经被迁移走了),分片返回 StaleConfig 错误,mongos 触发 Catalog Cache 革新。
技能映射:ChunkVersion = Chunk 的版本标识。Chunk 分裂、迁移时递增。ShardVersion = 每个分片维护的本分片上所有 Chunk 的最高版本。版本不匹配 = 路由缓存过期。
小胖:那 Scatter-Gather 呢?不带分片键的查询怎么做到的?
大师:mongos 发现查询条件不包含分片键——无法定位到具体分片——就把查询广播到所有分片。每个分片独立执行查询并返回结果,mongos 在内存中归并、排序、limit 截断,再返回。这就是 Scatter-Gather——性能约为 Targeted 查询的 N 分之一(N=分片数)。
技能映射:Scatter-Gather 的归并排序阶段在 mongos 的 ClusterFind::run() 中完成——mongos 维护了一个多路归并的 cursor,从每个分片的结果流中 merge。
小胖:那为什么我那个查询明显是 Targeted(有 userId),还会触发 StaleConfig?
大师:因为你的 userId 对应的 Chunk 正在被 Balancer 迁移——迁移期间的 Chunk 范围在源分片上还有一个"影子"(标记为 draining)。mongos 的路由缓存里存的还是旧的源分片地址——请求到源分片后,源分片发现这个 Chunk 已经迁走,返回 StaleConfig。这是Chunk 迁移期间的瞬态——重试 + 路由革新后应该能规复正常。如果连续报 StaleConfig,阐明迁移卡住了。
大师(总结):mongos 路由的三个核心——Catalog Cache 缓存 Chunk 路由表,ChunkVersion 校验路由是否过期,Scatter-Gather 处理无分片键的查询。StaleConfig 是瞬态且可重试的——这是 mongos 的正常防御机制,不是 bug。
3. 项目实战

3.1 环境准备

需要分片集群环境(第 25 章搭建)。连接 mongos 进行操作。
3.2 分步实现

步调一:观察 Chunk 路由表
目标:直接查看 Config Server 上存储的 Chunk 分布元数据。
  1. // 连接 mongos,切换到 config 数据库
  2. use config
  3. // 查看某个分片集合的 Chunk 分布
  4. var chunks = db.chunks.find({ ns: "local_life.orders_shard" })
  5.   .sort({ "min.userId": 1 }).limit(10).toArray()
  6. print("=== Chunk 路由表(前 10 个)===")
  7. chunks.forEach(function(c) {
  8.   print("分片键范围:", JSON.stringify(c.min), "→", JSON.stringify(c.max))
  9.   print("所在分片:", c.shard)
  10.   print("版本:", JSON.stringify(c.version))
  11.   print("历史:", c.history ? c.history.length + " 次迁移" : "无迁移")
  12.   print("---")
  13. })
  14. // 查看 Chunk 总数
  15. print("总 Chunk 数:", db.chunks.countDocuments({ ns: "local_life.orders_shard" }))
复制代码
步调二:观察 StaleConfig 错误
目标:模拟查询条件包含正在迁移的 Chunk 范围,观察 StaleConfig 触发。
  1. // 在 mongos 连接的 mongosh 中
  2. use local_life
  3. // 强制 mongos 使用过期的路由缓存(通过直接查询正在迁移的 Chunk 范围)
  4. // 正常情况下,重试会成功且 MongoDB 自动恢复路由
  5. try {
  6.   var result = db.orders_shard.find({
  7.     userId: "U_SOMEWHERE"   // 假设这个 userId 的 Chunk 正在迁移
  8.   }).toArray()
  9.   print("查询成功:", result.length, "条")
  10. } catch(e) {
  11.   if (e.code === 13388 || e.message.includes("StaleConfig")) {
  12.     print("捕获 StaleConfig(路由缓存过期)——这是瞬态,重试即可")
  13.   } else {
  14.     print("其他错误:", e.message)
  15.   }
  16. }
  17. // 在生产代码中(Spring Boot Driver):
  18. // Driver 默认启用了 retryReads=true(MongoDB 3.6+)
  19. // 遇到 StaleConfig 时 Driver 自动刷新路由并重试
  20. // 开发通常不需要手动处理 StaleConfig
复制代码
步调三:查看 mongos 的 Catalog Cache 状态
目标:查看 mongos 的路由缓存命中率和革新频率。
  1. // 查看 mongos 连接池状态(mongos 视角)
  2. use admin
  3. // 查看 mongos 的 serverStatus
  4. var status = db.serverStatus()
  5. // Catalog Cache 相关指标
  6. if (status.shardingStatistics) {
  7.   var shardStats = status.shardingStatistics
  8.   print("=== mongos 路由统计 ===")
  9.   print("Catalog Cache 刷新次数:", shardStats.catalogCache?.numIncrementalRefreshesStarted || "N/A")
  10.   print("Catalog Cache 失效次数:", shardStats.catalogCache?.numFullRefreshesStarted || "N/A")
  11.   print("StaleConfig 错误数:", shardStats.countStaleConfigErrors || "N/A")
  12.   print("Targeted 查询数:", shardStats.countTargetedOps || "N/A")
  13.   print("Scatter-Gather 查询数:", shardStats.countScatterGatherOps || "N/A")
  14. }
复制代码
步调四:源码追踪——mongos 的路由决议
  1. // 源码关键路径(GDB 断点,mongos 进程)
  2. // 注意:追踪 mongos 而非 mongod
  3. // 1. 查询路由入口
  4. // 文件:src/mongo/s/commands/cluster_find_cmd.cpp
  5. // 函数:ClusterFindCmd::Invocation::run()
  6. // 作用:mongos 接收到客户端 find 命令 → 解析分片键 → 路由决策
  7. // 2. 路由表查询
  8. // 文件:src/mongo/s/catalog_cache.cpp
  9. // 函数:CatalogCache::getShardForQuery()
  10. // 作用:根据分片键值范围 → 查找对应的 Chunk → 确定目标分片
  11. // 3. 版本校验
  12. // 文件:src/mongo/db/s/sharding_state.h
  13. // ChunkVersion 校验逻辑:mongos 发送请求时携带版本号,
  14. // 分片端在 ShardingState 中校验版本一致性
  15. // GDB 断点(连接 mongos 进程而非 mongod):
  16. // (gdb) break CatalogCache::getShardForQuery
  17. // (gdb) break ClusterFindCmd::run
复制代码
步调五:追踪 Chunk 迁移对路由的影响
  1. // 模拟 Chunk 迁移期间的数据分布变化
  2. // 1. 迁移前——查看一个 Chunk 的位置
  3. use config
  4. var targetChunk = db.chunks.findOne({
  5.   ns: "local_life.orders_shard",
  6.   "min.userId": { $exists: true }
  7. })
  8. print("迁移前所在分片:", targetChunk.shard)
  9. // 2. 手动触发 Chunk 迁移(在 mongos 执行)
  10. // sh.moveChunk("local_life.orders_shard",
  11. //              { userId: targetChunk.min.userId },
  12. //              "shard-2")
  13. // 注意:手动 moveChunk 期间目标范围会被短暂锁定
  14. // 3. 迁移后——同一个 Chunk 已经在不同的分片
  15. // var afterChunk = db.chunks.findOne({
  16. //   ns: "local_life.orders_shard",
  17. //   "min.userId": targetChunk.min.userId
  18. // })
  19. // 4. Chunk 的 history 数组中记录了迁移历史
复制代码
步调六:分片键变更——reshapeCollection 的路由影响
  1. // MongoDB 5.0+ 的 reshardCollection 操作期间:
  2. // - 旧分片集合和新分片集合并存(双写阶段)
  3. // - mongos 自动判断查询应该走旧集合还是新集合
  4. // - 切换完成后旧集合被删除
  5. // 查询当前是否有正在进行的 reshard 操作
  6. use config
  7. var reshardOps = db.reshardingOperations.find({ state: "committed" }).toArray()
  8. print("进行中的 reshard:", reshardOps.length, "个")
  9. reshardOps.forEach(function(op) {
  10.   print("  集合:", op.ns)
  11.   print("  状态:", op.state)
  12.   print("  是否捐赠完成:", op.donorShards ? "是" : "否")
  13. })
复制代码
可能遇到的坑:

  • mongos 的 Catalog Cache 默认 30 秒革新——如果启用 enableFinerGrainedCatalogCacheRefresh(默认开启),有 Chunk 变更时会立即失效对应的缓存条目,延迟 < 1 秒
  • 如果 Config Server 负载过高导致 Catalog Cache 革新慢——会导致大量查询等待路由更新
  • Balancer 迁移 Chunk 时该范围存在短暂不可用窗口(几毫秒到几秒)
3.3 完整代码清单

文件用途mongodb-lab/sharding/chunk-routing.jsChunk 路由表分析mongodb-lab/sharding/stale-config-simulate.jsStaleConfig 模拟mongodb-lab/sharding/mongos-stats.jsmongos 路由统计debug-scripts/mongos-trace.gdbmongos 路由 GDB 断点3.4 测试验证
  1. // 连接 mongos
  2. // 1. 验证 Chunk 路由表可读
  3. use config
  4. var chunkCount = db.chunks.countDocuments({ ns: /orders/ })
  5. print("Chunk 路由条目:", chunkCount, chunkCount > 0 ? "PASS" : "FAIL")
  6. // 2. 验证 Targeted 查询
  7. use local_life
  8. var exp = db.orders_shard.find({ userId: "U_500" }).explain()
  9. var shards = exp.queryPlanner.winningPlan.shards
  10. print("Targeted:", shards ? shards.length : 1,
  11.        (!shards || shards.length === 1) ? "PASS" : "FAIL")
  12. // 3. 验证 StaleConfig 重试(Driver 层面自动处理)
  13. print("StaleConfig 自动重试: Driver 默认启用 retryReads=true")
  14. // 4. 验证 Catalog Cache 统计可读
  15. var ss = db.serverStatus()
  16. print("路由统计:", ss.shardingStatistics ? "PASS" : "FAIL")
  17. print("\n=== 分片路由验证完成 ===")
复制代码
4. 项目总结

4.1 mongos 路由机制速查

概念作用关键源码Catalog Cache内存中的 Chunk 路由表src/mongo/s/catalog_cache.cppChunkVersionChunk 版本号,迁移/分裂时递增src/mongo/s/chunk_version.hShardVersion分片维护的版本号src/mongo/db/s/sharding_state.hStaleConfig路由过期错误,触发缓存革新自动重试(Driver 层)Targeted Query含分片键的单分片正确路由ClusterFindCmd::run()Scatter-Gather无分片键的全分片广播mongos 多路归并4.2 适用场景

分片路由知识适用:

  • StaleConfig 错误的频率分析——判断 Chunk 迁移是否正常。
  • Scatter-Gather 查询优化——为高频查询加入分片键条件。
  • Catalog Cache 革新延迟排查——Config Server 负载过高导致。
  • reshardCollection 期间的查询举动理解。
4.3 注意事项

注意事项阐明StaleConfig 是正常举动不是 bug,Driver 自动重试,不应抑制此错误Catalog Cache 革新不是瞬时Chunk 变更到 mongos 感知之间有短暂延迟mongos 是轻量级无状态可以部署多个 mongos 做负载平衡,每个 mongos 独立维护缓存Config Server 不可用 = 集群不可用mongos 无法革新 Catalog Cache → 全部路由失效4.4 常见踩坑经验

故障案例一:mongos 数量不足导致路由延迟
某团队用 1 个 mongos 承载 500 个应用 Pod 的连接——每个连接每 30 秒触发一次 Catalog Cache 革新(500 次的并发革新把 Config Server 打满),路由延迟从 1ms 飙升到 200ms。解决:部署 5 个 mongos + Load Balance,每个 mongos 承载 100 个连接,分担革新压力。
故障案例二:StaleConfig 循环导致无限重试
某次 Chunk 迁移因为网络错误失败,Balancer 不停重试——Chunk 在 shard-1 和 shard-2 之间"弹跳"。mongos 的 Catalog Cache 每个周期被失效——导致应用中 StaleConfig 错误率飙升到 5%。解决:先停掉 Balancer,手动完成 Chunk 迁移,确认稳定后重启 Balancer。
故障案例三:高级聚合在 mongos 上的归并开销
某聚合管道 $group → $sort → $limit 在分片集群中——每个分片在本地执行 $group 产生中间结果,mongos 再做一次全局 $group 归并各分片的中间结果——这导致 mongos 的内存消耗与分片数成正比。当 N=8 个分片时,mongos 的归并阶段 OOM。解决:将聚合结果改为先 $merge 到临时集合(在各分片本地完成),再从临时集合按分片键查询。
4.5 思考题


  • 如果 Config Server 的 3 个节点中 2 个宕机,只剩 1 个——mongos 还能正常路由吗?写入和读取分别会怎样?
  • 为什么 hashed 分片键的 Chunk 分裂点不需要手动指定(如 splitAt),而 ranged 分片键有时需要手动预分裂?
(答案将在第 38 章末尾揭晓)

上一章思考题答案:

  • 网络分区中——Primary+1 Secondary 侧有 2 票(尽管 3 个节点,但大多数 = ceil(3/2) = 2)——2 >= 2,这一侧的 2 个节点可以选举出 Primary。另一边单独 1 个 Secondary 只有 1 票 < 2,无法选举——既不能选自己也不能选 Primary(因为无法与 Primary 通信确认它仍健康)。新 Primary 在 (Primary+Secondary) 一侧当选,而另一侧的 Secondary 因为无法与新 Primary 通信——数据同步中断直到网络规复。
  • Oplog 的 h 哈希字段是 Oplog 条目的"内容指纹"——它是 Oplog 操作内容的 SHA1 哈希。当 Secondary 应用 Oplog 时——在应用之前计算当前文档状态的哈希,如果与应用 Oplog 之后的状态一致,阐明这条 Oplog 已经被应用过(幂等检测),跳过执行。这确保了在发生 Partial Apply 后重连可以从同一条重新开始而不会产生副作用。
延伸阅读与资源

NumPy 从入门到生产落地:全链路实战指南(科学计算/向量化)
Redis 8 实战精讲:从 CRUD 到源码,构建高可用缓存体系
Redis 实战修炼与原理进阶
Python 3实战精进:从脚本到高并发订单引擎
python入门:Rquests从菜鸟脚本到企业级SDK的网络实战圣经
Milvus向量数据库实战修炼:从 0 到 1精通向量检索与生产落地
MongoDB 实战进阶与内核修炼
后端工程师的 AI 转型第一课:Ollama 与私有化大模型实战
10倍开发者的 Dify 魔法书:从零构建全栈 AI 应用
后端工程师转型AI第一课-Ollama 与私有化大模型实战
大型语言模型(LLM) vLLM 高性能推理落地实战
Agent开发之LlamaIndex 实战修炼与源码进阶
大语言模型Transformers 实战修炼与源码剖析
回复

使用道具 举报

登录后关闭弹窗

登录参与点评抽奖  加入IT实名职场社区
去登录
快速回复 返回顶部 返回列表