分类课程智能体AI
文章
订阅
分类课程AI导师
文章
价格
课程进度
11 / 17
上一节用 Node.js 22 连接纸舟书店下一节纸舟书店的完整交付
自在学

© 2025 - 2026 株洲市自在学教育科技有限公司 版权所有

公网安备湘公网安备43020302000292号 | 湘ICP备2025148919号-1

关于我们隐私政策使用条款

© 2025 - 2026 株洲市自在学教育科技有限公司 版权所有

公网安备湘公网安备43020302000292号湘ICP备2025148919号-1

编程MongoDB 纸舟书店完整课程分片、分片键与水平扩展

分片、分片键与水平扩展

副本集把同一份数据复制到多个成员,主要解决高可用;分片把不同数据范围分布到多个 shard,主要解决单个副本集在容量或吞吐上的边界。两者不是替代关系:生产中的每个 shard 通常仍然是副本集。


知识点:什么时候才需要分片

分片会引入路由、均衡、元数据、跨 shard 查询、跨 shard 事务和更复杂的备份恢复。数据量“未来可能很大”并不足以成为立即分片的理由。

在决定分片前,先依次检查:

  1. 查询和索引是否合理,是否仍有大量无谓扫描。
  2. 文档模型是否导致无界数组、热点文档或过多跨集合操作。
  3. 硬件、存储和单个副本集是否还有明确余量。
  4. 历史数据是否能归档,工作集是否能缩小。
  5. 负载瓶颈究竟是容量、写吞吐、读吞吐还是应用自身。

只有当证据指向单个副本集的实际边界,并且分片键能让主要请求定向路由时,分片的运维成本才有合理回报。

分片不会自动修复没有索引的查询。一个广播到所有 shard 的低效查询,可能把原来的一个问题复制成多个问题。


知识点:四类组件怎样协作

一个分片集群至少包含:

  • mongos:查询路由器。应用连接它,而不是任意挑一个 shard。
  • config server replica set:保存集群元数据和数据块分布。
  • shard:真正保存业务数据;生产 shard 通常是副本集。
  • balancer:根据数据块分布执行迁移,尽量避免 shard 长期失衡。
text
                    应用
                      │
                      ▼
                   mongos
             ┌────────┼────────┐
             │        │        │
             ▼        ▼        ▼
          shard 1  shard 2  config server
          副本集    副本集      副本集

mongos 根据分片键和 config server 中的路由表判断请求应去一个 shard、多个 shard,还是全部 shard。这个选择直接影响延迟和资源消耗。


知识点:分片键是路由设计

分片键不是“选一个看起来唯一的字段”。它会长期影响数据分布、写入热点和查询能否定向。

评估一个候选键时至少看四件事:

维度要问的问题风险示例
基数不同取值是否足够多只有三个状态值,很难均匀分布
频率是否有少数值占据大部分文档一个超级客户形成巨型数据块
单调性新写入是否总落在键空间一端纯递增时间可能形成写热点
查询模式主要查询是否带分片键前缀或完整等值不带键的请求会广播

纸舟书店常按 customerId 查某位客户的订单。把 customerId 做哈希分片键,可以把不同客户较均匀地分散,并让完整等值查询定向到一个 shard;代价是按客户 ID 范围查询不再具有数据邻近性。

如果主要需求是按地区批量处理订单,{ region: 1, customerId: 1 } 这样的范围复合键可能更合适。分片键必须从真实请求和增长方式推导。


知识点:最小集群只用于理解组件

下面建立四个进程:

  • 一个单成员 config server 副本集;
  • 两个单成员 shard 副本集;
  • 一个 mongos。

它能展示路由和数据分布,却没有生产高可用性。生产 config server replica set 和各 shard 都应按故障域规划多个成员,并启用认证、成员间认证、TLS、监控和备份。

我们使用单独的 Compose 项目名 bookstore-sharded,不会改动主课程容器。


实操:声明最小分片集群

先创建并进入本节专用目录:

shell
mkdir paperboat-sharding-lab
cd paperboat-sharding-lab

在目录中创建 compose.yaml:

yaml
services:
  configsvr:
    image: mongo:8.0
    command:
      - mongod
      - --configsvr
      - --replSet
      - configReplSet
      - --port
      - "27019"
      - --bind_ip_all
    volumes:
      - config-data:/data/configdb
 
  shard1:
    image: mongo:8.0
    command:
      - mongod
      - --shardsvr
      - --replSet
      - shard1rs
      - --port
      - "27018"
      - --bind_ip_all
    volumes:
      - shard1-data:/data/db
 
  shard2:
    image: mongo:8.0
    command:
      - mongod
      - --shardsvr
      - --replSet
      - shard2rs
      - --port
      - "27018"
      - --bind_ip_all
    volumes:
      - shard2-data:/data/db
 
  mongos:
    image: mongo:8.0
    command:
      - mongos
      - --configdb
      - configReplSet/configsvr:27019
      - --bind_ip_all
    ports:
      - "127.0.0.1:27217:27017"
    depends_on:
      - configsvr
      - shard1
      - shard2
 
volumes:
  config-data:
  shard1-data:
  shard2-data:

启动四个服务:

shell
docker compose -p bookstore-sharded up -d
text
[+] Running 8/8
 ✔ Network bookstore-sharded_default        Created
 ✔ Volume bookstore-sharded_config-data     Created
 ✔ Volume bookstore-sharded_shard1-data     Created
 ✔ Volume bookstore-sharded_shard2-data     Created
 ✔ Container bookstore-sharded-configsvr-1  Started
 ✔ Container bookstore-sharded-shard1-1     Started
 ✔ Container bookstore-sharded-shard2-1     Started
 ✔ Container bookstore-sharded-mongos-1     Started

此时进程已启动,但三个副本集尚未初始化,mongos 可能暂时等待 config server 就绪。


知识点:先初始化内部副本集,再注册 shard

--configsvr 和 --shardsvr 只声明进程角色,不会自动创建副本集配置。每个副本集都需要 rs.initiate();config server 就绪后 mongos 才能稳定读取元数据。


实操:初始化三个副本集

初始化 config server:

shell
docker compose -p bookstore-sharded exec configsvr \
  mongosh --quiet --port 27019 --eval '
rs.initiate({
  _id: "configReplSet",
  configsvr: true,
  members: [
    { _id: 0, host: "configsvr:27019" }
  ]
})
'
javascript
{ ok: 1 }

初始化两个 shard:

shell
docker compose -p bookstore-sharded exec shard1 \
  mongosh --quiet --port 27018 --eval '
rs.initiate({
  _id: "shard1rs",
  members: [
    { _id: 0, host: "shard1:27018" }
  ]
})
'
javascript
{ ok: 1 }
shell
docker compose -p bookstore-sharded exec shard2 \
  mongosh --quiet --port 27018 --eval '
rs.initiate({
  _id: "shard2rs",
  members: [
    { _id: 0, host: "shard2:27018" }
  ]
})
'
javascript
{ ok: 1 }

结果展示:等待三个 Primary

每组只有一个成员,初始化后都应成为 Primary。下面分别读取状态:

shell
docker compose -p bookstore-sharded exec configsvr \
  mongosh --quiet --port 27019 --eval 'print(rs.status().myState)'
 
docker compose -p bookstore-sharded exec shard1 \
  mongosh --quiet --port 27018 --eval 'print(rs.status().myState)'
 
docker compose -p bookstore-sharded exec shard2 \
  mongosh --quiet --port 27018 --eval 'print(rs.status().myState)'
text
1
1
1

1 表示当前成员是 Primary。若连接过早,可以稍等后重试;不要用固定休眠时间代替最终状态检查。


实操:把两个 shard 注册到集群

现在通过 mongos 执行 sh.addShard()。注册字符串同时包含副本集名和成员地址:

shell
docker compose -p bookstore-sharded exec mongos \
  mongosh --quiet --eval '
printjson(sh.addShard("shard1rs/shard1:27018"));
printjson(sh.addShard("shard2rs/shard2:27018"));
'

结果展示:mongos 接受两个 shard

javascript
{
  shardAdded: 'shard1rs',
  ok: 1
}
{
  shardAdded: 'shard2rs',
  ok: 1
}

读取精简集群状态:

shell
docker compose -p bookstore-sharded exec mongos \
  mongosh --quiet --eval '
printjson(db.adminCommand({ listShards: 1 }).shards.map(s => ({
  id: s._id,
  host: s.host,
  state: s.state
})))
'
javascript
[
  {
    id: 'shard1rs',
    host: 'shard1rs/shard1:27018',
    state: 1
  },
  {
    id: 'shard2rs',
    host: 'shard2rs/shard2:27018',
    state: 1
  }
]

知识点:集合要先有分片键索引

我们为 shard_lab.orders 选择 { customerId: "hashed" }。哈希键会牺牲范围邻近性,换取较均匀的键空间分布。

空集合可以在分片时通过 numInitialChunks 预建多个数据块,让两组 shard 在很小的数据集上也能观察分布。生产数据块数量与策略应根据规模、版本和运维计划决定,不能照搬演示值。


实操:分片订单集合并写入数据

先启用数据库分片,再创建哈希索引和分片集合:

shell
docker compose -p bookstore-sharded exec mongos \
  mongosh --quiet --eval '
sh.enableSharding("shard_lab");
const d = db.getSiblingDB("shard_lab");
d.orders.createIndex({ customerId: "hashed" });
printjson(sh.shardCollection(
  "shard_lab.orders",
  { customerId: "hashed" },
  false,
  { numInitialChunks: 4 }
))
'

预期最后得到:

javascript
{
  collectionsharded: 'shard_lab.orders',
  ok: 1
}

写入 2000 份可重复生成的订单。bulkWrite 分批提交,避免构造一个过大的单次请求:

shell
docker compose -p bookstore-sharded exec mongos \
  mongosh --quiet --eval '
const orders = db.getSiblingDB("shard_lab").orders;
const ops = [];
 
for (let i = 0; i < 2000; i += 1) {
  ops.push({
    insertOne: {
      document: {
        _id: `shard-order-${String(i).padStart(4, "0")}`,
        customerId: `customer-${String(i % 200).padStart(3, "0")}`,
        amount: 50 + (i % 20),
        createdAt: new Date("2026-01-01T00:00:00Z")
      }
    }
  });
}
 
const result = orders.bulkWrite(ops, { ordered: false });
printjson({
  acknowledged: result.isAcknowledged(),
  insertedCount: result.insertedCount
})
'

结果展示:写入经过 mongos 路由

javascript
{
  acknowledged: true,
  insertedCount: 2000
}

mongos 接收的是普通集合写入,驱动不需要为每一篇文档手动选择 shard。路由由分片键值和集群元数据决定。


知识点:数据分布与查询路由是两个验收面

“两个 shard 都有数据”只能证明发生了分布。还要确认主要查询能否定向:

  • 完整的哈希分片键等值条件可以计算目标数据块,通常只访问一个 shard。
  • 不含 customerId 的金额查询无法定位数据块,需要向多个 shard 广播。
  • 对哈希键做范围查询不能利用原始值的连续性,也往往需要访问多个 shard。

实操:查看数据分布

从 config 数据库读取每个 shard 的文档统计:

shell
docker compose -p bookstore-sharded exec mongos \
  mongosh --quiet --eval '
db.getSiblingDB("shard_lab").orders.getShardDistribution();
'

结果展示:两个 shard 都承载数据

getShardDistribution() 会打印每个 shard 的数据量、文档数和数据块数,具体数字可能因哈希分布和均衡时机略有差异:

text
Shard shard1rs at shard1rs/shard1:27018
 data : ...KiB docs : ... chunks : 2
 
Shard shard2rs at shard2rs/shard2:27018
 data : ...KiB docs : ... chunks : 2
 
Totals
 data : ...KiB docs : 2000 chunks : 4

验收重点是总文档数为 2000、总数据块数为 4,并且两个 shard 都有数据。


实操:比较定向查询与广播查询

下面用 explain 提取参与计划的 shard 名称。第一条带完整分片键等值:

shell
docker compose -p bookstore-sharded exec mongos \
  mongosh --quiet --eval '
const exp = db.getSiblingDB("shard_lab").orders
  .find({ customerId: "customer-042" })
  .explain("executionStats");
 
printjson({
  query: "customerId 等值",
  shards: exp.executionStats.executionStages.shards.map(
    s => s.shardName
  ),
  returned: exp.executionStats.nReturned
})
'

结果会只包含一个 shard:

javascript
{
  query: 'customerId 等值',
  shards: [ 'shard2rs' ],
  returned: 10
}

具体是 shard1rs 还是 shard2rs 由哈希结果决定。接着查询金额,不提供分片键:

shell
docker compose -p bookstore-sharded exec mongos \
  mongosh --quiet --eval '
const exp = db.getSiblingDB("shard_lab").orders
  .find({ amount: { $gte: 65 } })
  .explain("executionStats");
 
printjson({
  query: "amount 范围",
  shards: exp.executionStats.executionStages.shards.map(
    s => s.shardName
  ),
  returned: exp.executionStats.nReturned
})
'

结果展示:不带分片键会访问两边

javascript
{
  query: 'amount 范围',
  shards: [ 'shard1rs', 'shard2rs' ],
  returned: 500
}

这就是 targeted query 与 scatter-gather query 的差别。广播查询不是一律禁止,但高频低延迟接口若长期广播,就说明分片键与访问模式可能不匹配。


知识点:范围键与哈希键没有绝对赢家

需求范围分片更有利哈希分片更有利
相邻键范围扫描是否
单调递增写入均匀分散容易形成一端热点通常更均匀
完整键等值查询可定向可定向
区域化数据放置可配合 zone ranges原始值邻近性被打散

复合分片键可以把两种目标组合起来,但字段顺序会决定路由能力。设计时应拿真实查询样本做 analyzeShardKey、基数与频率分析,再在接近生产分布的数据上验证。

MongoDB 支持 refine shard key 和 resharding 等演进手段,但变更仍会消耗资源并影响运维计划。不要把“以后能改”当作跳过前期分析的理由。


知识点:分片后的事务与唯一性更昂贵

如果一次订单事务涉及的数据落在不同 shard,协调成本会高于单 shard 事务。好的模型会尽量让一个业务操作访问同一分片键范围,例如把客户订单操作围绕 customerId 组织。

唯一索引也受分片键约束。对分片集合创建集群范围内的唯一约束时,唯一索引通常必须以分片键为前缀,否则各 shard 无法只凭自身数据保证全局唯一。订单号、租户 ID 和客户 ID 的组合要在建模阶段一起考虑。


实操:清理最小分片集群

先确认本节的 shard_lab 数据不再需要。下面按 Compose 项目名删除四个容器、默认网络和三个数据卷:

shell
docker compose -p bookstore-sharded down -v
text
[+] Running 8/8
 ✔ Container bookstore-sharded-mongos-1     Removed
 ✔ Container bookstore-sharded-configsvr-1  Removed
 ✔ Container bookstore-sharded-shard1-1     Removed
 ✔ Container bookstore-sharded-shard2-1     Removed
 ✔ Volume bookstore-sharded_config-data     Removed
 ✔ Volume bookstore-sharded_shard1-data     Removed
 ✔ Volume bookstore-sharded_shard2-data     Removed
 ✔ Network bookstore-sharded_default        Removed

结果展示:确认没有残留服务

shell
docker compose -p bookstore-sharded ps -a
text
NAME      IMAGE     COMMAND   SERVICE   CREATED   STATUS   PORTS

标题下没有服务,说明分片实验组已经删除。主课程容器 paperboat-mongo 仍由第 17 节完成最终验收和清理。

最后删除本节的 Compose 文件与空目录:

shell
cd ..
rm -f paperboat-sharding-lab/compose.yaml
rmdir paperboat-sharding-lab

rmdir 失败说明目录不为空。先列出残留文件并判断用途,不要为了省一步检查而扩大删除范围。

你已经完成了最小但完整的分片链路:初始化 config server 与两个 shard,把它们注册给 mongos,创建哈希分片集合,写入 2000 篇文档,并用 explain 区分定向与广播查询。


小测

1
使用 customerId 哈希分片键时,哪类查询最可能只访问一个 shard?
2
选择分片键时应评估哪些因素?
  • 知识点:什么时候才需要分片
  • 知识点:四类组件怎样协作
  • 知识点:分片键是路由设计
  • 知识点:最小集群只用于理解组件
  • 实操:声明最小分片集群
  • 知识点:先初始化内部副本集,再注册 shard
  • 实操:初始化三个副本集
    • 结果展示:等待三个 Primary
  • 实操:把两个 shard 注册到集群
    • 结果展示:mongos 接受两个 shard
  • 知识点:集合要先有分片键索引
  • 实操:分片订单集合并写入数据
    • 结果展示:写入经过 mongos 路由
  • 知识点:数据分布与查询路由是两个验收面
  • 实操:查看数据分布
    • 结果展示:两个 shard 都承载数据
  • 实操:比较定向查询与广播查询
    • 结果展示:不带分片键会访问两边
  • 知识点:范围键与哈希键没有绝对赢家
  • 知识点:分片后的事务与唯一性更昂贵
  • 实操:清理最小分片集群
    • 结果展示:确认没有残留服务
  • 小测

目录

  • 知识点:什么时候才需要分片
  • 知识点:四类组件怎样协作
  • 知识点:分片键是路由设计
  • 知识点:最小集群只用于理解组件
  • 实操:声明最小分片集群
  • 知识点:先初始化内部副本集,再注册 shard
  • 实操:初始化三个副本集
    • 结果展示:等待三个 Primary
  • 实操:把两个 shard 注册到集群
    • 结果展示:mongos 接受两个 shard
  • 知识点:集合要先有分片键索引
  • 实操:分片订单集合并写入数据
    • 结果展示:写入经过 mongos 路由
  • 知识点:数据分布与查询路由是两个验收面
  • 实操:查看数据分布
    • 结果展示:两个 shard 都承载数据
  • 实操:比较定向查询与广播查询
    • 结果展示:不带分片键会访问两边
  • 知识点:范围键与哈希键没有绝对赢家
  • 知识点:分片后的事务与唯一性更昂贵
  • 实操:清理最小分片集群
    • 结果展示:确认没有残留服务
  • 小测