分片键必须支持范围查询,就不能只用_id哈希分片;哈希分片虽能均匀写入,但使范围查询需广播至所有分片,延迟高、资源浪费。

分片键必须支持范围查询,就不能只用 _id 哈希分片
哈希分片(如 sh.shardCollection("db.col", { _id: "hashed" }))能把写入均匀打散到各分片,但会彻底破坏范围查询能力:任何 { ts: { $gte: ISODate("..."), $lt: ISODate("...") } } 查询都必须广播到所有分片,延迟高、资源浪费。如果你的业务强依赖时间范围、ID区间、地理围栏等查询,哈希分片是硬伤,不是“稍作妥协”,而是直接不可用。
升序分片键(如 { ts: 1 } 或 { createdAt: 1 })天然支持高效范围扫描,MongoDB 能精准路由到目标分片或连续分片段;但写入会持续打在最后一个分片(“热点分片”),尤其在高频插入场景下,容易成为性能瓶颈和单点故障源。
真正兼顾范围查询与写入均衡的方案是复合分片键 + 业务特征预分散
单纯选一个字段永远不够,得把“查询维度”和“分散维度”拆开组合。典型做法是:{ region: 1, ts: 1 } 或 { userId: 1, createdAt: 1 } —— 前者保证同区域数据局部有序,后者让同一用户的时间序列落在同一分片内。
关键在于前导字段(region、userId)要有足够基数且写入分布均匀。如果 userId 是自增整数或 UUID,那它本身就能打散;但如果大量写入集中在少数几个 region(比如 90% 流量来自“us-east-1”),那前导字段就失效了,仍会热点。
- 避免用低基数字段做前导,例如
{ status: 1, ts: 1 }(status 只有 3–5 个值) - 如果业务允许,可对前导字段做轻量级哈希或取模(如
{ regionHash: { $mod: [256, "$regionCode"] }, ts: 1 }),但需确保应用层能反推 - 务必在分片前用真实流量采样验证分布:执行
sh.status()查看各分片 chunk 数量,再查db.col.getShardDistribution()看文档分布是否偏离超 20%
sh.splitAt() 和 sh.moveChunk() 不能替代好分片键设计
有人想“先随便分片,后面手动切分+迁移”,这在中等规模(千万级文档)尚可操作,但一旦集合超 1TB 或每秒写入 >5K,sh.moveChunk() 会持续占用网络与磁盘 IO,拖慢主业务,且迁移失败后 chunk 状态易卡住,需要人工干预清理。
更危险的是:sh.splitAt() 只能按当前分片键值切,如果键本身不带分散性(比如纯 ts),你切出来的 chunk 依然全在最后一个分片上,徒劳无功。
所以分片键选型必须前置——上线前用 1:1 数据结构 + 模拟流量压测,重点观测:sh.status() 中每个 shard 的 chunks 数、docs 数、size 是否线性增长;以及 db.currentOp({ "secs_running": { $gt: 5 } }) 是否频繁出现跨分片查询。
时间字段做分片键时,$lt 和 $gt 查询表现差异很大
以 { ts: 1 } 分片为例:{ ts: { $gte: T1, $lte: T2 } } 能精准定位起止 chunk,通常只查 1–3 个分片;但 { ts: { $lt: T2 } }(如“查所有历史数据”)会从最早 chunk 扫到 T2 所在 chunk,可能横跨全部分片——尤其当集合存在多年冷数据时,这种查询实际退化为全广播。
缓解方法不是换键,而是加约束:强制业务带上另一个高基数过滤条件,例如 { region: "eu-west-1", ts: { $lt: T2 } },让前导字段先缩小范围;或者定期归档老数据(db.col.deleteMany({ ts: { $lt: ... } })),保持活跃时间窗口紧凑。
真正难处理的是“最近 N 条 + 任意时间范围”的混合需求——这时得考虑物化视图或应用层缓存,而不是指望分片键一招鲜。


















