上个月排查了一个线上 MongoDB 分片集群的问题某个分片的磁盘使用率持续逼近上限写入延迟时不时飙升而其他分片却很清闲。一开始我以为是均衡器调度异常翻了一圈监控也没发现什么配置问题。后来把集合的元数据拉出来对了一遍才猛然反应过来——问题出在分片键上。当年选键的时候只想着字段不能为空、值不能太少完全没考虑这个字段跟实际写入模式合不合。这个教训让我决定把分片键选择这件事彻底梳理清楚。1. 分片键的底层逻辑数据分布是怎么被键决定的很多人对分片键有个误解觉得它只是个路由字段选一个看着顺眼的就行。实际上分片键是分片集群里数据放置的唯一依据它决定了每一个文档最终落到哪个分片、以什么粒度被迁移、查询时要去访问哪几个分片。这一节我们先把它背后的机制讲透。1.1 分片集群的数据路由机制一个标准的 MongoDB 分片集群由三部分组成mongos 路由节点、config server 元数据节点、以及若干个 shard 数据分片。当你向 mongos 发起读写请求时mongos 不会真的把所有数据扫一遍而是根据集合的分片键快速定位目标。定位的核心单位叫chunk。集合的数据按照分片键的值域被切分成一段一段的连续区间每一段就是一个 chunkchunk 的元数据比如键值范围、属于哪个分片保存在 config server 上。mongos 收到请求时先解析请求里携带的分片键值然后在 chunk 元数据里找到对应的区间再把请求转发给持有该 chunk 的分片。这个过程很像图书馆的索书号整个书架按分类号分成很多区间你要找某本书先看它的分类号落在哪个区间直接去那个书架拿而不是走遍每一排。所以分片键的第一个硬性要求是每个文档都必须包含分片键字段且写入时该字段必须有确定值。如果一个集合的分片键是 user_id你插入一条没有 user_id 的文档mongos 直接拒绝写入。1.2 范围分片与哈希分片两种截然不同的分布策略MongoDB 提供两种分片方式它们的差异直接决定了数据倾斜和查询效率。范围分片是默认方式。它按照分片键值的字典序或数值大小把数据切成连续区间例如 [1, 100)、[100, 200) 各是一个 chunk。这种方式的优点是支持高效的区间查询比如查 user_id 在 100 到 200 之间的所有文档mongos 只需要精确路由到对应 chunk。缺点是——如果写入的分片键值持续递增时间、自增ID新数据永远落在最右边的区间导致所有写入都集中到同一个分片。哈希分片则是在分片键值上先做一次 MD5 或类似的哈希计算再用哈希结果决定 chunk 归属。哈希结果的分布近似均匀写入会分散到各个分片天然避免了单调递增带来的热点。代价是区间查询失效哪怕你想查 user_id 在 1 到 10 的文档mongos 也没法做范围判断只能把请求广播到所有分片各自扫描后再合并。用一张表概括对比项范围分片哈希分片写入分散性取决于键值是否单调均匀分散抗热点强范围查询精确路由效率高广播全分片效率低点查等值查询精确路由精确路由适用场景读写均衡、区间分析多日志、IoT、写入密集2. 选键之前先看懂四件事基数、写分散、查询定向、单调性网上关于分片键的建议大多是选基数高的字段别用自增ID这些话没错但不够。选一个有充足理由的分片键至少要从四个维度逐一审查候选字段任何一条不满足都可能在数据规模上来之后暴露问题。2.1 基数字典厚不厚决定分布天花板基数指的是一个字段的去重取值数量。比如 gender 字段只有male/female/unknown三个取值基数就是 3user_id 有 1 亿个不同取值基数就是 1 亿。为什么基数如此重要因为分片的移动单元是 chunk而 chunk 的切分边界必须在分片键的实际取值上进行。如果某个键值下的数据量非常大chunk 在该值附近来回切割最终这些数据会被约束在很少的几个 chunk 里无法继续split均衡器想挪也挪不动。我习惯用键值数据量来做估算集合的总文档数 ÷ 分片键基数 平均每个键值下有多少文档。如果这个数字到了千万甚至亿级就得警惕了。一般建议分片键基数至少是分片数的 10 到 50 倍以上且每个键值对应的文档量不要过分悬殊。基数只有几十的字段不管其他指标多优秀都不能做分片键。2.2 写分散与查询定向读写路径的左右手写分散衡量的是写入请求在分片间的分布是否均匀。理想状态是无论并发多高每个分片接收的写入量大致相当。判断方法很简单观察一条写入所携带的分片键值如果随着时间变化新值的出现位置能均匀覆盖所有 chunk那就是好现象。查询定向衡量的是读取请求能否只访问最少的分片。最优情况是单条查询尤其是高频点查通过分片键精确定位到一个 chunk次优是定位到同一分片上的少量 chunk最差的是查询条件里没有分片键mongos 只能把请求发到所有分片这种被称为scatter-gather 全片广播。写分散和查询定向往往是矛盾的为了写分散你希望键值哈希均匀为了查询定向你希望业务查询字段能被直接用来路由。所以选键的第一步不是列字段而是把读写路径分开来量化。2.3 单调性绝大多数热点事故的元凶单调递增字段自增主键、时间戳、序列号做分片键几乎是分片集群运维里最常见的翻车姿势。原理很直接新写入的文档其分片键值永远比之前的大于是永远落在最大的那个 chunk 里。范围分片下前面的 chunk 写满后被均衡器迁移到其他分片但新分裂出来的尾部 chunk 依然留在原分片。数据继续追加尾部 chunk 继续增大直到触发 split再分裂出一个新的尾部 chunk。整个过程中当前活跃写入的分片始终只有一个。更麻烦的是即使你用了哈希分片如果对单调字段直接做哈希同一秒内写入的文档哈希值依然集中在当前时间窗口的哈希范围内虽然比范围分片分散得多但热点窗口还是存在的。实践中对于时间序列数据通常的解法是复合分片键后面第五节细讲。3. 四类最容易踩的分片键陷阱症状、根因与识别方法纸上谈兵没有用我把这几年代维生涯里真实遇到的错误选型案例做一个分类拆解。每个案例都包含了可观察到的症状你可以直接对照自己的集群去排查。3.1 陷阱一用时间字段做分片键——假均匀的诱惑某个IoT平台每台设备每 5 秒上报一次状态数据日增 2 亿条。当时选分片键开发觉得timestamp最自然每天一个 chunk查询按时间范围走也顺理成章。刚开始集群很平稳数据量到了数百亿之后开始出问题最新一天的 chunk 永远在同一个分片上那个分片磁盘持续告警其他分片的 chunk 却是几周前的旧数据几乎不再写入。这种问题的本质就是单调性陷阱。识别方法也简单——观察集群的写入 QPS 在分片间的分布如果长时间只有一个分片的写入曲线在上涨其他分片纹丝不动十有八九是单调键在做怪。如果业务就是按时间范围查询怎么办方案是用复合分片键{ device_id: 1, timestamp: 1 }先用设备ID把数据打散到不同分片再在同一设备内部按时间排序。这样既保住了单设备的范围查询能力又解决了时间键的单点热点。3.2 陷阱二低基数字段——chunk 数量不够分的尴尬一个多租户 SaaS 系统选tenant_id作为分片键理由是查某个租户的所有数据很快。初期只有 3 个租户3 个分片一人一个完美。但租户增长到 20 个之后问题来了——大租户的数据量是小租户的上千倍大租户所在的 chunk 疯狂分裂但小租户的 chunk 几年都保持原样。在范围分片下chunk 的切分只能发生在字段的实际值之间。tenant_id7的数据量是tenant_id3的百倍但它们的 chunk 不可能合并也无法再按 7 这个值继续细分。最终结果就是大租户占用的 chunk 数量多到失衡均衡器试图把大租户的 chunk 挪到其他分片但由于这些 chunk 体积巨大一次迁移要传很久期间还可能触发 Jumbo chunk 问题见第六节。识别方法执行db.collection.getShardDistribution()观察每个分片的 chunk 数、文档数、平均文档大小。如果发现某个分片的文档数高出其他分片数倍且这些文档的分片键值只有少数几个基本可以断定是低基数字段搭配大键值数据量导致的倾斜。3.3 陷阱三复合分片键的字段顺序错误复合分片键不是把几个字段随便拼在一起就行。分片键的字段顺序决定了数据组织的层级。{ tenant_id: 1, order_id: 1 }和{ order_id: 1, tenant_id: 1 }是两种完全不同的数据分布。前者意味着先按租户分组租户内部再按订单排列如果一个租户的订单量巨大它的数据依然会集中在一小块区域内依然存在大租户热点后者意味着先按订单打散再按租户排列虽然单个订单不会造成热点但按租户查询时往往需要跨多个 chunk 甚至多个分片。踩过这个坑之后我的建议是复合分片键的第一个字段一定要选高基数、且能代表主要查询入口的字段第二个字段用来补充排序或范围查询需求。如果第一个字段本身就是低基数复合分片键解决不了倾斜问题。3.4 陷阱四只考虑写入均匀、忽略查询模式一个用户中心服务存储 5 亿用户信息选了user_id的哈希分片写入分布确实漂亮每个分片的 QPS 都差不多。但业务方有个高频查询根据邮箱查找用户。这个查询不带 user_idmongos 只能广播到所有分片每个分片做全集合扫描单次查询加起来要耗费巨量 IO慢查询日志里全是这条语句。这事的本质是哈希分片只帮你解决了写入分布但如果你不能把高频查询也压制到单个分片上整体性能依然不可控。好的分片键应该是写入分散和查询定向的交集而不是单独优化某一项。针对这个场景要么增加一个按邮箱字段的冗余集合user_id → email → user_id 的倒排表要么在业务层维护映射关系先把邮箱转成 user_id再走分片键查询。始终记住分片键路由是 MongoDB 分布式查询性能的天花板其他索引优化都是在分片内部的局部优化。4. 一套可落地的选型方法论从工作负载特征反推分片键既然踩坑姿势如此多样那到底该怎么选下面这套流程是我在其他项目里反复使用过的每一步都有明确的操作方法和判断标准可以直接抄。4.1 第一步量化业务读写特征选型之前先回答三个问题这个集合的写入量和读取量比例大概是多少高频查询每分钟 QPS 前 20的条件分别有哪些哪些条件能精确命中到单条或少量文档数据增长形态是持续追加日志型还是随机插入业务型我一般会在测试环境跑几天慢查询日志统计一下各个查询条件的出现频率和平均耗时用这些数据给查询模式排序。另一个很实用的手段是看 oplog 里的写操作占比以及读写 QPS 的黄金时段曲线来判断业务偏写还是偏读。4.2 第二步为候选字段打分整理出所有有潜力做分片键的字段按四个维度打分维度权重建议打分标准基数40%值数量在千万级以上得满分百万级得 7 分十万以下扣分写分散25%新写入的键值能否均匀落盘单调递增直接 0 分查询定向25%高频查询是否包含该字段包含越多满分单调性10%字段值随时间递增得 0 分随机分布或业务含义分布式得满分权重不是死的如果你服务的核心场景是纯写入日志查询定向的权重可以下调如果是电商订单查询为主查询定向的权重就该拉高。4.3 第三步按场景套用推荐组合我总结了四种最典型场景的推荐做法场景 A日志类写多读少查询少且无固定条件。推荐方案用业务自增ID、设备ID等唯一字段做哈希分片或者用复合键{ 唯一ID: hashed }。重点要抗写入热点查询基本都是全文检索或离线分析不在乎 scatter-gather。场景 B多租户系统隔离查询为主。推荐方案如果每个租户的数据量都不小百万级以上用{ tenant_id: 1, 高基数字段: 1 }做范围分片。先用租户定向到有限分片再按高基数字段分散写入。注意大租户要额外监控必要时对大租户单独做散列子键。场景 C业务主键点查为主比如用户表、订单表。推荐方案用业务主键user_id、order_id做哈希分片如果查询带主键可以做到精确路由。如果有查某用户的所有订单这种二级范围需求改成复合键{ user_id: 1, order_time: 1 }。场景 D既有写均匀、又有点查同时还要做范围统计。这种最麻烦通常的做法是双写两个集合一个按哈希键分片支撑点查和写入扩展一个按时间或维度分片支撑范围统计通过应用层或 TTL 同步。分片键选型的本质是在冲突目标里找平衡点想一个集合满足所有查询往往会把所有目标都牺牲掉。5. 分片键与索引、chunk 调优的联动关系分片键决定了数据放哪但数据放好了之后能不能持续稳定还取决于索引设计、chunk 分裂、均衡迁移等配套工程。这些环节跟分片键的选择互相制约稍有不慎会把一个合理选型拖垮。5.1 分片键索引没有它的路由寸步难行MongoDB 要求sh.shardCollection()执行时集合必须已在分片键上建立了索引。默认情况下如果分片键是_id索引已经存在如果选了其他字段要先建索引再分片或者用sh.shardCollection(namespace, key, true)让 MongoDB 自动创建唯一索引。但注意分片键索引不替代普通查询索引。比如分片键是device_id按device_id timestamp查数据mongos 会根据 device_id 精确路由到分片但分片内部还是要靠device_id timestamp的复合索引来快速定位文档。只建分片键索引、不建业务查询索引依然是全分片扫描。对于哈希分片还有一个细节哈希索引只支持等值查询不支持范围查询。如果业务里有对哈希分片键的范围扫描需求需要在普通字段上另行建立范围索引。5.2 chunk 大小、分裂与 Jumbo chunk 的连锁反应chunkSize 默认 128MB。chunk 达到阈值后触发 split把数据一分为二。split 产生的元数据变化会交给均衡器均衡器再把 chunk 从高负载分片迁移到低负载分片。这些机制本身没问题但坏的分片键会导致两个负面连锁反应chunk 频繁分裂低基数字段无法分裂导致 chunk 不可再分单调递增字段导致尾部 chunk 反复分裂config server 上的元数据急剧膨胀mongos 的路由缓存频繁失效。Jumbo chunk当一个 chunk 超过 chunkSize 上限但内部键值已经没有可切分的边界时该 chunk 无法 split成为 Jumbo chunk。Jumbo chunk 无法被均衡器移动等于这一坨数据永远钉在那个分片上了。它最常见的成因就是分片键基数低、单键值数据量过大。遇到 Jumbo chunk没有完美的在线处理方法。官方提供的cleanupOrphaned只能清孤儿文档对真正的 Jumbo chunk 只能拆分或重建集合。这也是为什么低基数字段做分片键要特别谨慎——chunk 一旦变大后续运维手段非常有限。5.3 线上必须盯的四个分片键健康指标选完分片键不是结束后续监控才是关键。我个人在运维分片集群时固定看这几项db.printShardingStatus()或sh.status()查看每个分片的 chunk 分布是否均衡chunk 数量偏差超过 20% 就要排查。mongostat 的分片 QPS观察写入请求在每个分片上的分布长时间单分片 QPS 偏高优先怀疑单调键。getShardDistribution()查看每个分片的数据量占比这个命令输出里能看到每个分片的文档数、chunk 数和平均文档大小是定位倾斜最直接的依据。config.chunks 表直接查询 chunk 的 max/min 键值结合写入模式判断是否存在某个键值下塞了海量文档的情况。我见过很多团队在集群刚上线时认真选了分片键之后几年再也没回头看过这些指标直到磁盘告警才想起检查。分片键的健康不是一劳永逸的数据增长会不断放大它的隐性缺陷。6. 选错了怎么办止损方案与重分片的实操路径最后说一个每个人都可能面对的问题生产环境的分片键已经跑了一两年业务数据量大到不可能重建但线上热点已经出现怎么办6.1 为什么 MongoDB 不支持修改分片键这个问题官方 FAQ 解释得很清楚分片键是集合的物理组织方式所有 chunk 的边界、路由元数据、索引结构都围绕它构建。允许修改分片键等于要全量重新切分数据过程中还无法保证一致性。MongoDB 从 5.0 开始引入了resharding功能允许在线修改分片键但它仍在 beta 阶段生产使用要非常谨慎。我在自己负责的环境里只在测试集群上验证过生产环境不推荐贸然上去。6.2 大数据量下三种可落地的止损方案方案一新建集合 双写迁移。适合数据可以忍受短期延迟的场景。业务上写操作同时写入旧集合和新集合通过定时任务把旧集合的历史数据分批迁移到新集合数据追平后切换读写。这个方案代码改造量大但不需要停机风险可控。选新集合的分片键时务必把前面第四节的方法论完整走一遍。方案二dump/restore 全量重建。适合能接受几个小时维护窗口的场景。用mongodump导出数据mongorestore --sharded导入到新集合同时保留旧集合用于回滚。这个方案最简单但大数据量下导出导入非常耗时且需要充足的磁盘空间。方案三利用 MongoDB Atlas / Ops Manager 的 Live Migration。如果你用的是官方托管服务或部署了 Ops Manager可以通过在线迁移工具把集合迁移到新的分片集群。本质上也是新集群建好后同步增量但官方工具做了不少一致性校验省去自己写双写的精力。具体选哪个取决于业务对停机时间的容忍度。我的经验是很多团队不愿意为一次分片键修正安排维护窗口宁可天天被热点告警折磨。但分片键问题是拖得越久、改造成本越高的典型等数据量再翻一倍方案一的双写追赶就很难跑完了。6.3 我的几点实操体会把这些年的分片键选型经验浓缩成几句话供你参考选分片键之前先把你能拿到的最长时间段的慢查询日志拉出来看一遍搞清楚业务到底是怎么读写数据的。宁可多花一周做读写路径分析也不要为了看起来均匀拍脑袋定字段。复合分片键是救命的工具{ 高基数字段: 1, 时间字段: 1 }的写法兼顾了写入分散和范围查询绝大部分既要又要的场景都能用它兜底。监控不能省分片键的健康状态就藏在 chunk 分布曲线里定时跑一下sh.status()只需要几秒钟但能提前数周发现问题。最后如果你现在正在设计一个新的分片集群我强烈建议你把分片键的决策过程写成文档记录当时考虑了哪些候选字段、为什么最终选了这个、预期的数据分布是什么样的。等半年之后再回来看这份文档能帮你判断当时的选型是否真的站得住脚——毕竟分片键的代价往往要等到数据量足够大时才会显现。