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 分布元数据。
// 连接 mongos,切换到 config 数据库
use config
// 查看某个分片集合的 Chunk 分布
var chunks = db.chunks.find({ ns: "local_life.orders_shard" })
.sort({ "min.userId": 1 }).limit(10).toArray()
print("=== Chunk 路由表(前 10 个)===")
chunks.forEach(function(c) {
print("分片键范围:", JSON.stringify(c.min), "→", JSON.stringify(c.max))
print("所在分片:", c.shard)
print("版本:", JSON.stringify(c.version))
print("历史:", c.history ? c.history.length + " 次迁移" : "无迁移")
print("---")
})
// 查看 Chunk 总数
print("总 Chunk 数:", db.chunks.countDocuments({ ns: "local_life.orders_shard" }))
步骤二:观察 StaleConfig 错误
目标:模拟查询条件包含正在迁移的 Chunk 范围,观察 StaleConfig 触发。
// 在 mongos 连接的 mongosh 中
use local_life
// 强制 mongos 使用过期的路由缓存(通过直接查询正在迁移的 Chunk 范围)
// 正常情况下,重试会成功且 MongoDB 自动恢复路由
try {
var result = db.orders_shard.find({
userId: "U_SOMEWHERE" // 假设这个 userId 的 Chunk 正在迁移
}).toArray()
print("查询成功:", result.length, "条")
} catch(e) {
if (e.code === 13388 || e.message.includes("StaleConfig")) {
print("捕获 StaleConfig(路由缓存过期)——这是瞬态,重试即可")
} else {
print("其他错误:", e.message)
}
}
// 在生产代码中(Spring Boot Driver):
// Driver 默认启用了 retryReads=true(MongoDB 3.6+)
// 遇到 StaleConfig 时 Driver 自动刷新路由并重试
// 开发通常不需要手动处理 StaleConfig
步骤三:查看 mongos 的 Catalog Cache 状态
目标:查看 mongos 的路由缓存命中率和刷新频率。
// 查看 mongos 连接池状态(mongos 视角)
use admin
// 查看 mongos 的 serverStatus
var status = db.serverStatus()
// Catalog Cache 相关指标
if (status.shardingStatistics) {
var shardStats = status.shardingStatistics
print("=== mongos 路由统计 ===")
print("Catalog Cache 刷新次数:", shardStats.catalogCache?.numIncrementalRefreshesStarted || "N/A")
print("Catalog Cache 失效次数:", shardStats.catalogCache?.numFullRefreshesStarted || "N/A")
print("StaleConfig 错误数:", shardStats.countStaleConfigErrors || "N/A")
print("Targeted 查询数:", shardStats.countTargetedOps || "N/A")
print("Scatter-Gather 查询数:", shardStats.countScatterGatherOps || "N/A")
}
步骤四:源码追踪——mongos 的路由决策
// 源码关键路径(GDB 断点,mongos 进程)
// 注意:追踪 mongos 而非 mongod
// 1. 查询路由入口
// 文件:src/mongo/s/commands/cluster_find_cmd.cpp
// 函数:ClusterFindCmd::Invocation::run()
// 作用:mongos 接收到客户端 find 命令 → 解析分片键 → 路由决策
// 2. 路由表查询
// 文件:src/mongo/s/catalog_cache.cpp
// 函数:CatalogCache::getShardForQuery()
// 作用:根据分片键值范围 → 查找对应的 Chunk → 确定目标分片
// 3. 版本校验
// 文件:src/mongo/db/s/sharding_state.h
// ChunkVersion 校验逻辑:mongos 发送请求时携带版本号,
// 分片端在 ShardingState 中校验版本一致性
// GDB 断点(连接 mongos 进程而非 mongod):
// (gdb) break CatalogCache::getShardForQuery
// (gdb) break ClusterFindCmd::run
步骤五:追踪 Chunk 迁移对路由的影响
// 模拟 Chunk 迁移期间的数据分布变化
// 1. 迁移前——查看一个 Chunk 的位置
use config
var targetChunk = db.chunks.findOne({
ns: "local_life.orders_shard",
"min.userId": { $exists: true }
})
print("迁移前所在分片:", targetChunk.shard)
// 2. 手动触发 Chunk 迁移(在 mongos 执行)
// sh.moveChunk("local_life.orders_shard",
// { userId: targetChunk.min.userId },
// "shard-2")
// 注意:手动 moveChunk 期间目标范围会被短暂锁定
// 3. 迁移后——同一个 Chunk 已经在不同的分片
// var afterChunk = db.chunks.findOne({
// ns: "local_life.orders_shard",
// "min.userId": targetChunk.min.userId
// })
// 4. Chunk 的 history 数组中记录了迁移历史
步骤六:分片键变更——reshapeCollection 的路由影响
// MongoDB 5.0+ 的 reshardCollection 操作期间:
// - 旧分片集合和新分片集合并存(双写阶段)
// - mongos 自动判断查询应该走旧集合还是新集合
// - 切换完成后旧集合被删除
// 查询当前是否有正在进行的 reshard 操作
use config
var reshardOps = db.reshardingOperations.find({ state: "committed" }).toArray()
print("进行中的 reshard:", reshardOps.length, "个")
reshardOps.forEach(function(op) {
print(" 集合:", op.ns)
print(" 状态:", op.state)
print(" 是否捐赠完成:", op.donorShards ? "是" : "否")
})
可能遇到的坑:
- mongos 的 Catalog Cache 默认 30 秒刷新——如果启用
enableFinerGrainedCatalogCacheRefresh(默认开启),有 Chunk 变更时会立即失效对应的缓存条目,延迟 < 1 秒 - 如果 Config Server 负载过高导致 Catalog Cache 刷新慢——会导致大量查询等待路由更新
- Balancer 迁移 Chunk 时该范围存在短暂不可用窗口(几毫秒到几秒)
3.3 完整代码清单
| 文件 | 用途 |
|---|---|
mongodb-lab/sharding/chunk-routing.js |
Chunk 路由表分析 |
mongodb-lab/sharding/stale-config-simulate.js |
StaleConfig 模拟 |
mongodb-lab/sharding/mongos-stats.js |
mongos 路由统计 |
debug-scripts/mongos-trace.gdb |
mongos 路由 GDB 断点 |
3.4 测试验证
// 连接 mongos
// 1. 验证 Chunk 路由表可读
use config
var chunkCount = db.chunks.countDocuments({ ns: /orders/ })
print("Chunk 路由条目:", chunkCount, chunkCount > 0 ? "PASS" : "FAIL")
// 2. 验证 Targeted 查询
use local_life
var exp = db.orders_shard.find({ userId: "U_500" }).explain()
var shards = exp.queryPlanner.winningPlan.shards
print("Targeted:", shards ? shards.length : 1,
(!shards || shards.length === 1) ? "PASS" : "FAIL")
// 3. 验证 StaleConfig 重试(Driver 层面自动处理)
print("StaleConfig 自动重试: Driver 默认启用 retryReads=true")
// 4. 验证 Catalog Cache 统计可读
var ss = db.serverStatus()
print("路由统计:", ss.shardingStatistics ? "PASS" : "FAIL")
print("\n=== 分片路由验证完成 ===")
4. 项目总结
4.1 mongos 路由机制速查
| 概念 | 作用 | 关键源码 |
|---|---|---|
| Catalog Cache | 内存中的 Chunk 路由表 | src/mongo/s/catalog_cache.cpp |
| ChunkVersion | Chunk 版本号,迁移/分裂时递增 | src/mongo/s/chunk_version.h |
| ShardVersion | 分片维护的版本号 | src/mongo/db/s/sharding_state.h |
| StaleConfig | 路由过期错误,触发缓存刷新 | 自动重试(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 实战修炼与源码剖析

微信公众号: 架构师日常笔记 欢迎关注!
浙公网安备 33010602011771号