当单台 MongoDB 服务器的存储或写入能力达到瓶颈时,需要使用分片(Sharding )进行水平扩展。分片将数据分布在多个服务器上,每个分片是一个独立的复制集。通过分片,MongoDB 可以在多台机器之间分布读写负载,解决单节点内存、磁盘和吞吐量上限的问题,是支撑海量数据场景的核心架构手段。
1. 分片架构
分片集群由三类核心组件组成:Config Server、mongos 路由层和多个 Shard 节点。Config Server 负责持久化存储集群元数据,包括集合与分片键的映射关系、每个 chunk 的范围、分片节点列表等。Mongos 是无状态路由进程,接收客户端请求后,从 Config Server 获取最新的路由表(chunk 分布信息),并将查询或写入请求转发到对应的 Shard 节点。Shard 是实际存储数据的 mongod 进程,为了保证高可用,通常每个 Shard 都以三节点副本集的形式部署。
分片集群整体的请求处理流程如下:客户端连接到任意一台 mongos,mongos 根据请求中的分片键信息定位目标 chunk,如果需要跨分片查询则由 mongos 汇总结果返回。由于 mongos 无状态,可以独立横向部署多个实例,实现负载均衡与高可用。
Client
│
┌──────────┴──────────┐
│ mongos (路由) │
│ (可部署多个) │
└──────────┬──────────┘
│
┌──────────┴──────────┐
│ Config Servers │
│ (复制集,3节点) │
└──────────┬──────────┘
│
┌──────────────┼──────────────┐
│ │ │
┌─────▼────┐ ┌─────▼────┐ ┌─────▼────┐
│ Shard A │ │ Shard B │ │ Shard C │
│(Replica) │ │(Replica) │ │(Replica) │
└──────────┘ └──────────┘ └──────────┘
2. 分片键选择
2.1 分片键类型
| 类型 | 特点 | 适用场景 |
|---|---|---|
| Range | 按范围分片 | 范围查询频繁(如时间) |
| Hashed | 哈希均匀分布 | 写密集型,避免热点 |
| Zone | 数据路由到指定区域 | 多数据中心,合规要求 |
选用范围分片键时,相邻键值的数据会存储在相邻的 chunk 中,能高效支持范围查询;但如果写入键值是单调递增的,则所有新写入都会落在最后一个 chunk 上,造成严重的写入热点。哈希分片键通过对键值计算哈希值来实现均匀分布,可以有效打散写入热点,但牺牲了范围查询性能,因为相邻值会被打散到不同分片上。Zone 策略则允许管理员按照业务维度将数据固定到指定分片或其数据中心,满足数据本地化或合规隔离的需求。
2.2 分片键设计原则
分片键的设计直接决定了数据分布的均匀度与查询性能,是集群设计中最关键的决策之一。评估一个分片键是否合适,需要从三个维度入手:基数(Cardinality)、均匀性(Distribution)和查询隔离度(Query Isolation)。基数表示分片键可能取值的个数,基数越高,数据越容易拆分;均匀性保证各个分片上的数据量相对平衡;查询隔离度指的是查询条件中包含分片键时 mongos 可以精确定位到少量分片,避免全集群广播查询。
// 好的分片键特征:
// 1. 高基数(cardinality)- 大量不同值
// 2. 均匀分布 - 避免热点
// 3. 查询频率高 - 常用查询条件
// ❌ 差的分片键:
// status 字段只有两个取值(open/closed),导致数据集中在两个 chunk,无法有效分散
// 并且后续扩容也难以再次均衡
sh.enableSharding("mydb");
sh.shardCollection("mydb.orders", { status: 1 });
// ✅ 好的分片键:
// userId 具有高基数且不单调递增,配合 _id 可以进一步打散大块数据
sh.shardCollection("mydb.orders", { userId: 1, _id: 1 });
// 以 userId 为主分片键(高基数),_id 作为细化,保证同一用户数据按 _id 顺序存储,
// 支持基于 userId 的精确定位查询
2.3 分片后查询路由
分片集群中的查询效率很大程度上取决于查询语句是否能命中分片键。当查询条件中包含分片键时,mongos 可以直接通过路由表定位到目标分片和目标 chunk,这种查询方式称为 Targeted Query。如果查询条件中不包含分片键,mongos 不得不将所有查询请求广播到每一个分片节点,由各分片并行查询后汇总结果,即 Scatter-Gather 查询。Scatter-Gather 查询会消耗大量网络带宽和计算资源,应尽量避免。
// 包含分片键的查询 → mongos 路由到指定分片(Targeted)
db.orders.find({ userId: "user123" });
// 不包含分片键的查询 → Scatter-Gather 到所有分片
// 性能下降,尽量避免;如果业务上必须这样做,建议结合索引覆盖查询来减少回表
// 并且在 $match 阶段尽量过滤掉无关文档
db.events.find({ status: "completed" });
2.4 单调递增片键热点问题
在电商、日志和物联网等场景中,常常使用时间戳或自增 ID 作为主键或分片键。这类单调递增的字段会导致所有新文档都写入到最后一个 chunk 中,而最后一个 chunk 驻留在同一个分片上,造成严重的写入热点,该分片的磁盘、CPU 和网络带宽很快成为瓶颈。
解决单调递增片键热点问题的常见方法有三种:
一是改用哈希分片键,将递增键通过哈希函数打散,使写入流量均匀分布到所有分片;二是使用复合分片键,将时间戳字段与另一个高基数字段组合,例如 { region: 1, createdAt: 1 },不同 region 的数据分布在不同 chunk 上;三是在应用层对时间戳做突变处理,例如在时间戳前拼接一个随机前缀或取模值,使其分布更加离散。
// 方案一:将单调递增字段改为 Hashed 分片键
sh.shardCollection("mydb.logs", { logId: "hashed" });
// 适合写入密集型且对范围查询要求不高的日志场景
// 方案二:使用复合分片键,将热点打散到不同前缀下
sh.shardCollection("mydb.logs", { appId: 1, createdAt: 1 });
// 不同 appId 的数据分布在不同 chunk 上,有效缓解同一时间的写入热点
// 方案三:应用层引入离散前缀
// 假设应用层写入时生成 documentId = `${regionPrefix}_${autoIncrementId}`
// 在查询时解析出文档 ID 的格式,保证查询条件也包含前缀
2.5 复合片键设计实战
复合片键常用于在单一字段无法满足同时兼顾均匀分布和查询隔离度的场景。复合片键的排序属性非常重要,其顺序决定了 chunk 的边界划分规则。例如 { userId: 1, _id: 1 } 表示先按 userId 分块,同一 userId 下再按 _id 细分。这种设计特别适合以用户为中心的 SaaS 应用,因为绝大多数查询都以 userId 为入口条件,同时 userId 的高基数保证了数据分布均匀。
// 实战场景:SaaS 订单系统
// 绝大部分查询按 userId 过滤,同时希望对订单按 _id 排序翻页
sh.enableSharding("ecommerce");
// 创建复合片键,userId 在前实现查询隔离,_id 在后实现同一用户内部的排序连续
sh.shardCollection("ecommerce.orders", { userId: 1, _id: 1 });
// 验证查询是否为 Targeted Query
// 以下查询只路由到包含 userId "u123" 的分片
db.orders.find({ userId: "u123" }).sort({ _id: 1 }).limit(20);
// 如果查询中需要按 status 筛选且不包含 userId,则退化为 Scatter-Gather
// 对于高频此类查询,应考虑增加二级索引或调整片键策略
3. Chunk 与 Balancer
3.1 Chunk 分裂与迁移机制
MongoDB 以 chunk 为单位对分片集群中的数据进行逻辑切分和物理迁移。每个 chunk 是某个分片键范围内的连续数据块,默认大小上限为 64MB。当 chunk 中的数据量增长超过阈值时,mongod 会自动触发 split 操作,将该 chunk 从中点拆分为两个更小的 chunk,从而避免单个分片的数据过度膨胀。
Balancer 是运行在 Primary Config Server 上的后台进程,其职责是定期扫描各分片的 chunk 数量差异。当发现某些分片承载的 chunk 数量超过设定阈值时,Balancer 会触发 moveChunk 操作,将超载分片上的 chunk 迁移到负载较轻的分片上。整个迁移过程对应用透明,但会短暂锁定涉及的 chunk,因此大量并发的 moveChunk 可能对集群性能产生影响。
// Chunk 是分片内数据的逻辑单元(默认 64MB)
// Balancer 自动在分片间迁移 Chunk 以保持负载均衡
// 查看分片状态,输出中包含每个 collection 的 chunk 分布与分片键信息
sh.status();
// 手动移动 Chunk:将包含指定分片键值的数据块迁移到目标分片
sh.moveChunk("mydb.orders", { userId: "user123" }, "shard0002");
// 设置 Chunk 大小(单位 MB),生产环境通常默认值即可,小文件场景可适当缩小
use config;
db.settings.updateOne(
{ _id: "chunksize" },
{ $set: { value: 32 } }, // 32MB
{ upsert: true }
);
// 关闭 Balancer(维护时)
sh.stopBalancer();
// 执行维护操作后重新开启 Balancer
sh.startBalancer();
3.2 Balancer 窗口与性能调优
虽然 Balancer 可以自动均衡负载,但在业务高峰期运行大量的 chunk 迁移可能占用额外的网络带宽和磁盘 I/O,影响业务请求的延迟。因此生产环境通常建议配置 Balancer 的运行时间窗口,只允许在业务低峰期执行迁移任务。
具体操作是在 Config Server 的 settings 集合中为 Balancer 指定 start 和 stop 时间。配置完成后,Balancer 在非窗口时间不会发起 moveChunk,从而保障白天的查询和写入性能。
// 仅在每日凌晨 1 点到 5 点允许 Balancer 运行
use config;
db.settings.updateOne(
{ _id: "balancer" },
{
$set: {
window: {
start: "01:00",
stop: "05:00"
}
}
},
{ upsert: true }
);
// 验证窗口配置是否生效
sh.getBalancerWindow();
// 查看当前是否正在迁移 chunk
sh.isBalancerRunning();
3.3 Jumbo Chunk 的处理
在某些场景下,chunk 内的单个文档体积非常大(接近 chunk 大小),或者分片键的基数极低导致无法再拆分,这时 chunk 就会超过配置大小却无法分裂,这类 chunk 被称为 Jumbo Chunk。Jumbo Chunk 会破坏集群的负载均衡,因为 Balancer 不会迁移这类超大 chunk。
处理 Jumbo Chunk 的首要方法是检查片键设计,确保片键具有足够的基数。如果确实是由超大文档引起,可以通过归档或拆分文档的方式减少单个 chunk 的数据量。对于已经无法拆分的 Jumbo Chunk,管理员可以手动下发 splitAt 或 splitFind 命令来尝试强制拆分。
// 查看是否存在 Jumbo Chunk
use config;
db.chunks.find({ "jumbo": true }).forEach(printjson);
// 手动强制拆分 Jumbo Chunk(需要提供 chunk 中存在的任意一个分片键值)
sh.splitAt("mydb.orders", { userId: "user123" });
// 如果分片键基数足够但仍无法拆分,考虑修改 chunk 大小阈值,让系统有更多余地
use config;
db.settings.updateOne(
{ _id: "chunksize" },
{ $set: { value: 128 } },
{ upsert: true }
);
4. Zone 策略
Zone 策略允许管理员按照自定义范围将数据分配到指定的分片或分片组,常用于多数据中心部署、冷热数据分离以及数据合规隔离等场景。在配置 Zone 之前,需要先为每个分片通过 addShardToZone 绑定一个或多个 zone 标签,然后通过 updateZoneKeyRange 定义分片键与 zone 的对应关系。
当 Balancer 运行时,它会确保符合某个 zone 范围的数据只存储在带有对应标签的分片上。需要注意的是,zone 范围必须是连续且不重叠的,否则 Config Server 会拒绝配置。
// 将特定范围的数据固定到指定分片(多数据中心场景)
sh.addShardToZone("shard000", "EU");
sh.addShardToZone("shard001", "US");
// 定义 zone 范围:欧洲地区用户数据只存储在 EU 标签的分片上
sh.updateZoneKeyRange(
"mydb.users",
{ region: "EU", _id: MinKey },
{ region: "EU", _id: MaxKey },
"EU"
);
// 验证 zone 配置
sh.status();
5. 分片扩容
当集群整体存储空间接近上限,或者现有分片的写入负载持续处于高位时,需要对分片集群进行扩容。扩容的核心是新增一个分片并加入集群,随后 Balancer 会自动将现有分片上的部分 chunk 迁移到新分片,直到各分片之间的 chunk 数量大致均衡。
1. 准备新分片服务器并启动 mongod,建议以三节点副本集形式部署
2. 将新分片加入集群: sh.addShard("shard4/rs0.example.com:27017")
其中 shard4 为副本集名称,rs0.example.com:27017 为主节点地址
3. Balancer 自动开始迁移 Chunk 到新分片,可通过 db.currentOp() 观察迁移进度
4. 监控迁移进度与均衡情况: sh.status()
// 新增分片到集群
sh.addShard("shard4/rs0.newnode.com:27017");
// 实时查看 chunk 迁移操作的进度
use admin;
db.currentOp({ "type": "op", "ns": /migrate/ });
// 检查各分片的 chunk 数量差异
sh.status();
5.1 分片缩容与数据迁移
在某些场景下,例如业务规模缩减或硬件更新换代,需要从集群中移除某个分片。MongoDB 提供了 removeShard 命令来安全下线分片。该命令不会立即删除数据,而是先将目标分片上的所有 chunk 逐步迁移到其他分片,待迁移完成后才真正从集群中移除该分片。这个过程是后台渐进式的,可以在不影响业务运行的情况下执行。
移除过程中,可以通过 sh.status() 和 db.currentOp() 持续监控迁移进度。需要注意的是,在迁移完成之前,目标分片上的数据仍然存在且可读可写。
// 开始移除 shard4,系统会自动迁移其上的 chunk 到其他分片
sh.removeShard("shard4");
// 查看移除进度与剩余 chunk 数
sh.status();
// 如果 shard4 上存在不可迁移的数据(如 Jumbo Chunk),需要手动处理后再继续
use config;
db.chunks.find({ shard: "shard4", jumbo: true }).forEach(printjson);
5.2 Resharding 与重新分片
分片键一旦确定,通常不建议轻易更改,但在业务需求发生重大变化时,重新分片成为必要。MongoDB 4.4 引入了 refineCollectionShardKey,允许在现有复合片键的尾部追加额外字段,而无需迁移数据。MongoDB 5.0+ 则进一步推出了 resharding 功能,支持在不中断业务的情况下,将现有分片集合的数据迁移到新的片键定义下,整个过程通过变更流和逐 chunk 拷贝实现。
// 对已有集合追加片键字段(仅支持尾部追加,不触发数据迁移)
sh.refineCollectionShardKey("mydb.orders", { userId: 1, _id: 1, region: 1 });
// 在 MongoDB 5.0+ 环境中执行完整 resharding(需要停止对该集合的部分 DDL 操作)
// 以下命令会开启一个后台 resharding 任务
sh.reshardCollection("mydb.orders", { region: 1, userId: 1 });
// 查看 resharding 进度(字段名称和输出格式因版本差异有所不同)
db.adminCommand({ getParameter: 1, reshardingOperations: 1 });
6. 分片限制
启用分片后,部分 MongoDB 功能会受到限制或行为发生变化。运维人员需要提前了解这些限制,以便在设计阶段规避潜在问题。
| 功能 | 未分片 | 分片后 |
|---|---|---|
| $lookup | 支持 | 仅支持非分片集合作为被关联方 |
| 唯一索引 | 支持 | 必须包含分片键 |
| $text 搜索 | 支持 | 不支持,需借助 Atlas Search 或 Elasticsearch 实现 |
| 多文档事务 | 支持 | 4.2+ 支持,但涉及的集合必须位于同一分片 |
| $graphLookup | 支持 | 不支持(跨分片递归查询困难) |
在分片环境下使用 $lookup 时,如果目标集合也是被分片的,查询将报错。唯一索引的约束范围也必须包含分片键前缀,例如 { userId: 1, orderId: 1 } 可以作为唯一索引,但单纯的 { orderId: 1 } 不允许。对于全文检索需求,建议将非结构化文本数据同步到 Elasticsearch 或 MongoDB Atlas Search 中完成搜索,避免使用原生 $text。
7. 查询性能优化
7.1 Targeted 与 Scatter-Gather 查询
分片集群中查询性能的优化核心在于尽量减少 Scatter-Gather 查询的比例,提升 Targeted 查询的比例。Targeted 查询指的是 mongos 能够根据查询条件中的分片键值精确计算出需要访问的分片,从而避免全网广播;Scatter-Gather 则需要将请求发送到所有分片,再由 mongos 做结果合并与排序,延迟和负载都会成倍增长。
此外,聚合操作中的 $sort 阶段如果能利用片键的有序性,mongos 就可以避免在内存中进行大规模排序。例如,当片键为 { userId: 1, _id: 1 } 且用户查询条件包含 userId 并按 _id 升序排序时,每个分片内部的数据天然有序,mongos 只需合并各分片的流输出即可。
// 理想场景:Targeted 查询,只命中单一分片
db.orders.find({ userId: "u123" }).sort({ _id: 1 }).limit(10);
// 不佳场景:Scatter-Gather,所有分片都需要扫描
// 以下查询未命中 userId,mongos 必须广播到所有分片
db.orders.find({ status: "shipped" }).sort({ createdAt: -1 });
// 改进策略:如果按 status 查询非常频繁,考虑将 status 加入片键前缀
// 或使用应用层缓存降低此类查询对数据库的直接压力
7.2 跨分片聚合与优化
MongoDB 的分片聚合管道允许部分操作在各个分片上先行执行(如 $match、$project),mongos 只需合并结果即可,这种方式称为 pushed-down aggregation。然而一些需要全局视野的操作(如 $group 中不包含分片键、$sort 不符合片键顺序)则无法在分片上并行完成,需要将所有中间结果集中到 mongos 的内存中进行二次计算。
为了优化跨分片聚合性能,开发者应尽量在管道第一阶段放置带有分片键的 $match 条件,缩小数据扫描范围。对于需要在 mongos 上执行排序的查询,应确保 sort 内存限制足够,否则查询会报错或被拆分执行。
// 高效的跨分片聚合:先用分片键过滤,再聚合
// 假设片键为 { userId: 1 }
db.orders.aggregate([
// 第一阶段:利用分片键定位到具体分片,只采集 u123 的数据
{ $match: { userId: "u123", createdAt: { $gte: ISODate("2026-01-01") } } },
// 第二阶段:在同分片内按 status 分组计数
{ $group: { _id: "$status", count: { $sum: 1 } } },
// 第三阶段:排序(数据量已在分片内大幅缩小)
{ $sort: { count: -1 } }
]);
8. 生产监控与故障排查
分片集群的运维复杂度远高于单节点或副本集,系统管理员需要建立完善的监控体系来实时掌握各组件的健康状态。推荐采用 Prometheus 配合 mongodb_exporter 来采集各个 mongod、mongos 和 Config Server 的运行指标。关键监控项包括:各分片的 chunk 总数、每秒写入与查询量、连接数、缓存命中率、WiredTiger 事务锁等待时间以及 Balancer 的迁移频率和耗时。
MongoDB Atlas 提供了自动扩展(Auto-Scaling)功能,可以根据负载自动增加或减少分片节点,但在自建集群中,运维人员需要根据监控指标手动触发扩容。二者各有利弊:自建集群灵活性更高,但需要投入更多人力;Atlas 托管集群省心但成本相对更高。
# prometheus.yml 示例:采集 mongodb_exporter 暴露的指标
scrape_configs:
- job_name: 'mongodb_sharded'
static_configs:
- targets:
- 'mongos-1:9001'
- 'mongos-2:9001'
- 'shard1-primary:9001'
- 'shard2-primary:9001'
metrics_path: /metrics
在生产环境的日常运维中,常见的故障场景及其排查思路如下:
| 故障现象 | 排查方法 |
|---|---|
| 写入延迟突增 | 检查是否存在 Jumbo Chunk、热点分片或 Balancer 正在大量迁移 |
| 查询响应缓慢 | 查看查询计划,判断是否为 Scatter-Gather,并检查慢查询日志 |
| Balancer 不工作 | 确认 Config Server 主节点正常,检查 Balancer 窗口设置与锁状态 |
| 分片间数据不均 | 检查片键基数是否过低,确认 Balancer 未被人为关闭 |
| mongos 元数据不同步 | 重启 mongos 或等待其主动从 Config Server 刷新路由表 |
对于 Balancer 不迁移的场景,常见的根因包括 Config Server 主节点不可用、Balancer 时间窗口限制、以及存在 Jumbo Chunk 导致迁移失败。管理员可以先使用 sh.status() 查看各分片 chunk 数量差异,再结合 db.currentOp 确认是否有活跃的迁移任务正在进行。
// 检查 Balancer 是否被锁定
use config;
db.locks.find({ _id: "balancer" }).pretty();
// 检查当前活跃迁移操作的数据量与耗时
use admin;
db.currentOp({ "msg": /^migrate/ });
延伸阅读
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。