数据库基础体系 · 第 45/139 篇。文章以各产品官方稳定版本的公开语义为准;示例会明确引擎、事务与部署边界。

MongoDB 索引与副本集:查询计划、分片、选举和生产运维

MongoDB 的性能与高可用问题,通常不是某一个参数导致的,而是多个机制共同作用的结果:

  • 索引决定查询是否需要扫描大量文档;
  • 查询规划器决定在多个候选索引中选择哪一个;
  • 副本集通过 oplog 复制数据,并在主节点故障时重新选举;
  • 分片把数据和请求分布到多个副本集;
  • 读写关注级别决定客户端愿意接受多强的数据确认;
  • 运维操作则决定这些机制在故障、扩容、备份和恢复时是否仍然可控。

理解这些机制时,必须区分三个层次:

  1. 逻辑数据模型:文档如何组织,哪些字段被嵌入或引用;
  2. 单副本集内的查询与复制:索引、查询计划、oplog、选举;
  3. 分片集群层面的路由与故障处理:mongos、配置服务器、分片副本集和 chunk。

下文以 MongoDB 公开稳定语义为准。命令示例使用 mongosh,输出中的时间、节点名和数量仅用于说明机制。


一、先建立整体架构

1. 单机、复制集和分片集群

一个 MongoDB 部署可以是:

mongosh
  |
mongod(单机)

也可以是副本集:

客户端
  |
  +-- primary
  +-- secondary
  +-- secondary

副本集(replica set)是一组维护同一数据集的 mongod 实例。通常只有一个节点在任意时刻承担主节点角色,写入先提交到 primary,再通过 oplog 复制到 secondary。

分片集群则在其外层增加路由和元数据组件:

客户端
  |
mongos
  |
  +-- 配置服务器副本集(Config Server Replica Set)
  +-- shard 1:副本集
  +-- shard 2:副本集
  +-- shard 3:副本集

其中:

  • mongos:无状态路由进程,根据分片元数据把请求发送到一个或多个 shard;
  • Config Server Replica Set:保存数据库、集合、分片键、chunk 范围等集群元数据;
  • shard:实际存储数据,通常本身也是副本集;
  • chunk:分片数据的逻辑范围单位,balancer 可以在 shard 之间迁移 chunk。

因此,“分片”和“副本集”解决的是不同问题:

  • 副本集主要解决复制、故障转移和高可用;
  • 分片主要解决数据容量、写入吞吐和跨节点扩展;
  • 分片通常依赖副本集提供每个 shard 的高可用,但分片不会自动解决错误的数据模型或错误的查询模式。

二、索引究竟解决什么问题

1. 没有索引时的查询

假设集合 orders 中有一千万条订单:

db.orders.find({
  tenantId: "t001",
  status: "PAID"
})

如果没有可用索引,执行器通常需要检查大量甚至全部文档,判断每条文档是否满足条件。这类访问路径称为 COLLSCAN,即 collection scan。

其成本可以粗略表示为:

CscanN(cread+cpredicate)C_{\text{scan}} \approx N \cdot (c_{\text{read}} + c_{\text{predicate}})

其中:

  • NN 是需要检查的文档数;
  • creadc_{\text{read}} 是读取文档或数据页的成本;
  • cpredicatec_{\text{predicate}} 是执行过滤条件的成本。

索引的目标不是“让所有查询都快”,而是把候选文档数量从 NN 降低到一个更小的 KK

CindexCindex traversal+KcfetchC_{\text{index}} \approx C_{\text{index traversal}} + K \cdot c_{\text{fetch}}

如果索引选择性很差,KK 仍然接近 NN,索引可能没有收益,甚至因为索引遍历和随机读取而更慢。

2. B-tree 索引的直觉

MongoDB 的普通索引可以理解为按键有序组织的索引结构。对于:

{ tenantId: 1, createdAt: -1 }

索引先按 tenantId 排序,再在相同 tenantId 内按 createdAt 降序排列。

因此以下查询可以有效利用该索引:

db.orders.find({
  tenantId: "t001",
  createdAt: { $gte: ISODate("2025-01-01") }
}).sort({ createdAt: -1 })

索引访问过程大致是:

  1. 定位 tenantId = "t001" 的索引范围;
  2. 在该范围内定位 createdAt >= ...
  3. 按索引顺序读取结果;
  4. 如果查询字段都在索引中,可以避免回表读取文档。

索引并不是把文档复制一份,而是保存键值与文档定位信息。因此索引会带来:

  • 额外磁盘空间;
  • 写入时维护索引的 CPU 和 I/O;
  • 建索引和删除索引的运维成本;
  • 内存或缓存压力。

3. 复合索引与前缀规则

对于索引:

{ tenantId: 1, status: 1, createdAt: -1 }

其有序关系首先由 tenantId 决定,其次由 status,最后由 createdAt 决定。

它天然适合:

{ tenantId: "t001" }

{ tenantId: "t001", status: "PAID" }

{ tenantId: "t001", status: "PAID" }
sort({ createdAt: -1 })

但不一定适合只查询:

{ status: "PAID" }

因为 status 不是索引前缀。索引中虽然存在 status,但不同 tenantId 的值交错排列,执行器通常无法直接定位一个连续的小范围。

这不是绝对的“能用”或“不能用”。查询规划器可能仍然选择该索引进行扫描,但此时它可能需要扫描大量索引项,效果接近低效索引扫描。


三、复合索引的顺序:从查询约束推导

一种常用的索引设计方法是 ESR:

  • E(Equality):等值条件;
  • S(Sort):排序字段;
  • R(Range):范围条件。

例如查询:

db.orders.find({
  tenantId: "t001",
  status: "PAID",
  createdAt: {
    $gte: ISODate("2025-01-01"),
    $lt: ISODate("2025-02-01")
  }
}).sort({ amount: -1 })

可以考虑:

{ tenantId: 1, status: 1, createdAt: 1, amount: -1 }

推导过程如下:

  1. tenantIdstatus 是等值约束;
  2. createdAt 是范围约束;
  3. amount 试图支持排序;
  4. 但范围字段之后的排序字段是否能完全避免排序,要看实际查询条件、索引边界和执行器能否利用该顺序,不能只套公式保证;
  5. 如果必须稳定地支持 amount 排序,可能需要重新评估查询形态与索引顺序,例如把排序字段放在范围字段之前,但这又可能扩大扫描范围。

更准确的原则是:索引顺序应使查询扫描的索引区间尽量小,并尽量让结果按目标顺序产生。

一个常见反例是:

{ createdAt: 1, tenantId: 1 }

用于:

{
  tenantId: "t001",
  createdAt: { $gte: start, $lt: end }
}

虽然两个字段都出现了,但第一个字段是范围,导致索引前缀覆盖了很大的时间范围;tenantId 位于范围之后,不能像前置等值字段那样有效缩小连续索引区间。


四、常见索引类型及其边界

1. 唯一索引

db.users.createIndex(
  { email: 1 },
  { unique: true }
)

唯一索引要求索引键不重复。创建前如果已有重复值,建索引会失败。

注意:

  • 唯一性是由索引键定义的,不是由应用层 find 后再 insert 保证的;
  • 并发写入时,唯一索引是数据库级约束;
  • 如果字段缺失、为 null、使用稀疏或部分索引,实际约束范围要结合索引选项理解;
  • 多字段唯一索引约束的是整个键组合,而不是每个字段分别唯一。

例如:

db.members.createIndex(
  { tenantId: 1, username: 1 },
  { unique: true }
)

表达的是“同一个租户内用户名唯一”,而不是全局用户名唯一。

2. 部分索引与稀疏索引

部分索引只为满足过滤条件的文档建立索引:

db.orders.createIndex(
  { tenantId: 1, createdAt: -1 },
  {
    partialFilterExpression: {
      status: "OPEN"
    }
  }
)

该索引只包含 status = "OPEN" 的文档。

查询若想安全使用该索引,查询条件必须能够证明目标文档属于部分索引覆盖范围,例如:

db.orders.find({
  tenantId: "t001",
  status: "OPEN"
}).sort({ createdAt: -1 })

但以下查询不能直接假设该索引覆盖全部结果:

db.orders.find({
  tenantId: "t001"
})

因为其中也可能存在 status != "OPEN" 的文档,而部分索引没有这些文档。

稀疏索引则主要根据字段是否存在决定是否加入索引。它与部分索引都可能改变缺失字段和 null 值的语义,不能仅根据“索引更小”就替换使用。

3. 多键索引

当索引字段是数组时,MongoDB 会为数组元素建立多键索引。例如:

{
  _id: 1,
  tags: ["db", "mongodb"]
}

索引:

db.posts.createIndex({ tags: 1 })

可以支持:

db.posts.find({ tags: "mongodb" })

但数组会带来两个重要边界:

  1. 一个文档可能对应多个索引键;
  2. 对多个数组字段建立复合索引会受到多键索引限制,不能任意产生数组元素的笛卡尔组合。

因此,数组字段的索引设计必须结合文档结构,而不能只把所有查询字段机械地放入一个复合索引。

4. 文本、地理空间、TTL 和哈希索引

这些索引不是普通 B-tree 的简单替代:

  • 文本索引用于文本搜索,但不等价于专业搜索引擎;
  • 地理空间索引用于地理位置查询;
  • TTL 索引按时间自动删除文档,删除由后台机制异步执行,不是精确到某一秒的定时任务;
  • 哈希索引按字段哈希值组织,适合某些等值访问和散列分布场景,但不支持按原始字段顺序进行范围访问。

TTL 示例:

db.sessions.createIndex(
  { expiresAt: 1 },
  { expireAfterSeconds: 0 }
)

含义是:当 expiresAt 到达后,该文档具备被 TTL 后台删除的条件。它不是事务性的“到点立即删除”,也不能用来表达业务级的精确过期触发器。


五、使用 explain() 理解查询计划

1. 查询计划的核心概念

查询规划器会为一个查询生成候选计划,比较其执行表现,然后选择一个 winning plan。常见阶段包括:

  • COLLSCAN:扫描集合;
  • IXSCAN:扫描索引;
  • FETCH:根据索引定位后读取完整文档;
  • SORT:内存或其他执行阶段中进行排序;
  • LIMIT:限制输出数量;
  • SHARD_MERGESHARDING_FILTER 等分片相关阶段。

示例:

db.orders.find({
  tenantId: "t001",
  status: "PAID"
}).sort({
  createdAt: -1
}).explain("executionStats")

需要重点查看:

{
  queryPlanner: {
    winningPlan: { ... },
    rejectedPlans: [ ... ]
  },
  executionStats: {
    nReturned: 20,
    totalKeysExamined: 20,
    totalDocsExamined: 20,
    executionTimeMillis: 3
  }
}

这些字段的直觉是:

  • nReturned:最终返回的文档数;
  • totalKeysExamined:扫描的索引键数;
  • totalDocsExamined:读取并检查的文档数;
  • executionTimeMillis:一次执行的观测耗时,不是永久性能保证。

理想情况下,返回 20 条结果只需要检查几十个索引键和文档。但如果:

nReturned = 20
totalKeysExamined = 5,000,000
totalDocsExamined = 5,000,000

说明索引选择性、索引顺序或查询条件存在问题,即使耗时暂时不高,也可能在数据增长后恶化。

2. COLLSCAN 不一定是错误

例如:

db.orders.find({ status: "PAID" }).explain("executionStats")

如果 90% 的订单都是 PAID,为这个条件建立索引未必有意义。索引不能消除大量结果本身,执行器可能选择集合扫描。

因此不能只看到 COLLSCAN 就断定查询错误,也不能只看到 IXSCAN 就断定查询高效。应该结合:

  • 返回行数;
  • 扫描键数;
  • 扫描文档数;
  • 是否发生排序;
  • 结果集大小;
  • 数据分布和实际并发。

3. 排序与内存边界

如果索引顺序不能提供目标排序,计划中可能出现 SORT

db.orders.find({
  tenantId: "t001"
}).sort({
  amount: -1
})

如果只有:

{ tenantId: 1, createdAt: -1 }

该索引可以快速找到租户数据,但不能直接按 amount 产生结果,执行器还需要排序。

大量排序可能触发内存限制。某些聚合或排序操作可以使用磁盘,但这会增加 I/O,且不能把它当作无限扩展内存的手段。生产环境应优先通过查询和索引减少排序输入,而不是默认打开磁盘使用。

4. 索引筛选与强制索引

可以使用 hint() 进行诊断:

db.orders.find({
  tenantId: "t001",
  status: "PAID"
}).hint({
  tenantId: 1,
  status: 1,
  createdAt: -1
}).explain("executionStats")

hint() 适合验证“如果使用该索引会怎样”,不适合轻率地永久写入业务代码。原因是:

  • 数据分布会变化;
  • 选择性会变化;
  • 索引可能被删除;
  • 查询条件变化后,强制索引可能变成灾难;
  • 分片环境中还要考虑路由和 shard key。

对于索引变更,应先在具有代表性数据和负载的环境中测试,再观察生产中的 explain、慢查询和资源曲线。


六、覆盖查询、投影与文档读取

如果一个查询的过滤和返回字段都能由索引提供,可能形成 covered query,从而减少文档读取:

db.users.createIndex({
  tenantId: 1,
  email: 1
})

db.users.find(
  { tenantId: "t001", email: "a@example.com" },
  { _id: 0, email: 1 }
).explain("executionStats")

这里显式排除 _id 很重要,因为 _id 默认会被返回;如果 _id 不在索引中,可能需要读取文档。

覆盖查询并不是“永远不读磁盘”的保证:

  • 查询条件和投影必须符合索引可提供的字段;
  • 多键索引、数组、表达式和特殊类型会增加限制;
  • 优化器仍可能基于成本选择其他计划;
  • 覆盖查询减少的是文档读取,不代表索引遍历没有成本。

七、查询计划缓存与索引变更

相同形态的查询可能复用查询计划。计划选择会受到:

  • 查询结构;
  • 参数值分布;
  • 索引集合;
  • 统计和历史执行表现;
  • 数据量变化。

因此,新增或删除索引后,不应只执行一次查询就判断结果。应关注:

  1. 新计划是否成为 winning plan;
  2. totalKeysExamined / nReturned 是否下降;
  3. 高选择性值和低选择性值是否都表现合理;
  4. 计划是否在高并发下造成锁、CPU 或 I/O 压力。

MongoDB 支持隐藏索引,用于在不立即删除索引的情况下测试其对规划的影响。隐藏索引不会被普通查询规划使用,但仍然会承受写入维护成本。验证完成后,应明确决定保留还是删除,而不是长期保留“可能以后有用”的索引。


八、副本集:oplog、复制和提交

1. oplog 是什么

副本集中的 primary 将写操作记录为 oplog 条目。secondary 按顺序拉取并应用这些操作。

逻辑流程可以表示为:

客户端写入
  |
primary 校验并应用
  |
写入 primary 的 oplog
  |
secondary 拉取 oplog
  |
secondary 应用操作

oplog 是一个循环上限集合。当新操作不断写入时,旧操作最终会被覆盖。副本集的 oplog window 是当前最旧 oplog 到最新 oplog 之间可供追赶的时间窗口。

如果某个 secondary 离线时间超过 oplog window,它可能无法继续增量追赶,需要重新同步。生产上应监控:

  • secondary 延迟;
  • oplog window;
  • 是否出现 rollback;
  • 是否有节点进入 RECOVERINGSTARTUP2 或其他非正常状态。

2. 写关注级别

客户端写入可以指定 write concern:

db.orders.insertOne(
  {
    _id: "o1001",
    tenantId: "t001",
    status: "PAID"
  },
  {
    writeConcern: {
      w: "majority",
      wtimeout: 5000
    }
  }
)

含义是:

  • w: "majority":等待满足多数派确认;
  • wtimeout:等待超过指定时间后返回超时错误。

必须区分“客户端收到成功”和“所有节点都已完成应用”:

  • w: 1 通常只等待 primary 确认;
  • w: "majority" 等待多数派确认,但少数节点可能仍有复制延迟;
  • wtimeout 不是自动回滚,也不表示写入一定失败。超时意味着客户端没有在规定时间内获得要求的确认,应用需要设计重试和幂等性。

重试写入时,使用稳定业务键、唯一索引或 MongoDB 的重试写入语义,避免客户端无法判断结果时产生重复业务记录。

3. 读关注与读偏好

读关注(read concern)回答“允许读取到多强的确认状态”,读偏好(read preference)回答“优先从哪个成员读取”。

例如:

db.orders.find({
  tenantId: "t001"
}).readConcern("majority")

而:

db.getMongo().setReadPref("secondaryPreferred")

表示优先从 secondary 读取,但不等于读取必然是最新数据。

常见误解是:

从 primary 读就一定能读到刚刚写入的数据。

在同一客户端会话、网络拓扑和故障切换条件下,读写顺序还受到会话、读偏好、读关注和重试行为影响。若业务需要跨操作的因果顺序,可使用会话的因果一致性语义;若业务要求严格线性化读取,则应使用相应的读关注和部署条件,并承担更高延迟与可用性代价。

从 secondary 读取的核心取舍是:

降低 primary 读压力
        +
提高读吞吐
        -
可能读到复制延迟数据
        -
故障切换期间路由更复杂

因此,订单支付状态、库存扣减结果等通常不能简单地全部切换到 secondary 读取。


九、副本集选举:状态、触发和故障路径

1. 角色不是永久属性

副本集成员包含角色和状态。常见状态包括:

  • PRIMARY:当前主节点;
  • SECONDARY:复制数据但不承担普通 primary 写入;
  • STARTUPSTARTUP2:启动或初始同步阶段;
  • RECOVERING:恢复中,通常不应接收普通业务读;
  • ARBITER:仲裁节点,不保存数据;
  • REMOVEDUNKNOWN:从当前视角看不可用或不可达。

检查状态:

rs.status()

或者:

db.adminCommand({
  replSetGetStatus: 1
})

典型输出结构类似:

{
  set: "rs0",
  members: [
    {
      name: "mongo1:27017",
      stateStr: "PRIMARY",
      optimeDate: ISODate("...")
    },
    {
      name: "mongo2:27017",
      stateStr: "SECONDARY",
      optimeDate: ISODate("...")
    }
  ]
}

这里的 stateStr 是观察结果,不是客户端应该永久依赖的固定节点角色。故障转移后角色会变化。

2. 选举为什么需要多数派

假设有三个投票成员:

A、B、C

多数派为:

32+1=2\left\lfloor \frac{3}{2} \right\rfloor + 1 = 2

只有一个节点存活时,它不能获得多数派,因此不会安全地自选为 primary。这样可以避免网络分区时出现两个 primary,即 split-brain。

如果五个投票成员中三个互相可见,理论上可以继续选举:

52+1=3\left\lfloor \frac{5}{2} \right\rfloor + 1 = 3

但“节点更多”并不自动意味着更好。投票成员必须分布在故障域中,否则机架、可用区或网络故障可能同时带走多数票。

3. 一次典型故障路径

假设:

A = primary
B = secondary
C = secondary

发生以下步骤:

  1. A 进程崩溃或网络隔离;
  2. B、C 的心跳检测到 A 不可达;
  3. B 或 C 进入选举流程;
  4. 某个候选者请求其他投票成员支持;
  5. 获得多数票后成为新的 primary;
  6. 客户端驱动通过服务器发现机制更新拓扑;
  7. 原 A 恢复后,若发现已有更高任期的 primary,则不能继续作为旧 primary 写入;
  8. A 重新追赶 oplog,通常作为 secondary 加入。

选举期间会出现一个不可写窗口。应用必须正确处理:

  • NotWritablePrimary
  • 网络断开;
  • 超时;
  • 重试写入;
  • 事务提交重试。

选举不是瞬时完成的固定动作。实际时间受心跳、选举超时、网络、节点负载和驱动发现影响,不能用一个硬编码的“几秒后必然恢复”作为业务正确性假设。

4. priority、votes 和 hidden 节点

副本集成员配置可以控制:

  • priority:参与成为 primary 的倾向;
  • votes:是否参与投票;
  • hidden:是否向普通客户端暴露;
  • priority: 0:通常用于不希望其成为 primary 的成员;
  • delayed member:故意延迟复制,用于某些恢复场景。

这些配置必须放在故障域和运维目标中理解。例如,一个隐藏节点即使保存完整数据,也不代表应用可以自动从它读取;一个 priority: 0 节点仍可能参与数据复制和读取,除非另有配置。

5. 仲裁节点的边界

仲裁节点(arbiter)只参与投票,不保存数据。

在两个数据节点加一个仲裁节点的拓扑中:

数据节点 A
数据节点 B
仲裁节点 C

虽然投票数是奇数,但当 A、B 之间发生网络隔离时,仲裁节点的位置和可见性会决定哪一侧拥有多数票。仲裁节点不能提供数据冗余,也不能用于承担读请求。对于生产系统,优先考虑把数据节点分布到独立故障域,而不是仅依靠仲裁节点解决票数问题。


十、回滚、脑裂和“成功写入”的边界

1. 回滚为什么发生

假设 A 是 primary,但由于网络分区,少数派客户端仍能与 A 通信。A 接受了一些没有复制到多数派的写入:

A:接受 x、y、z
B、C:没有收到 x、y、z

之后 B、C 形成多数派并选出 B 为新 primary。A 重新连接后,A 上那些不属于新主链的写入可能被回滚。

因此:

  • w: 1 的成功不等于写入已经被多数派保留;
  • w: "majority" 通常用于降低这类写入丢失风险;
  • 应用不能把“收到一次成功响应”简单等同于“在所有故障模型下永久存在”。

回滚后的数据通常会被写入 rollback 文件或相关诊断信息,生产上应根据业务需要审计和补偿,而不是只重新启动节点。

2. 为什么不能简单关闭选举或忽略写关注

如果应用使用较弱的写关注并允许从滞后 secondary 读取,就可能出现:

客户端写成功
客户端立刻从 secondary 读取
读取不到刚才的文档

这不一定是数据丢失,而可能是复制延迟。若业务把“读不到”解释为“写入失败”,就会产生重复创建、重复扣款或状态回退。

解决方案不是统一使用最强级别,而是让每类操作明确选择:

  • 创建后必须立即读回:优先同一会话或 primary;
  • 报表和搜索:可以接受 secondary 延迟;
  • 支付状态和库存:使用足够强的读写关注,并设计幂等;
  • 跨服务事件:使用持久化事件记录或变更流等机制,而不是依赖偶然的读后写顺序。

十一、分片:数据如何被路由

1. 分片键

分片键(shard key)是 MongoDB 用来决定文档分布位置的字段或字段组合。

例如:

{ tenantId: 1, orderId: 1 }

分片键必须根据真实访问模式选择,因为它同时影响:

  • 数据分布;
  • 写入热点;
  • 查询是否能定向到少数 shard;
  • chunk 迁移;
  • 扩容后的均衡;
  • 某些唯一约束和事务范围。

分片键不是普通索引的同义词。它决定的是“数据如何分布”,而索引决定的是“某个 shard 内如何查找”。

2. 基于范围的分片

范围分片把分片键空间切成连续区间:

[-∞, 1000)
[1000, 2000)
[2000, 3000)

例如按递增的 createdAt 或自增 ID 分片,最新数据可能持续进入同一个范围,形成单 shard 热点。

范围分片的优点是:

  • 范围查询可以定向到少数 shard;
  • 某些排序和时间窗口访问更自然。

缺点是:

  • 单调递增键容易造成写入热点;
  • 数据分布依赖键值分布;
  • 热点范围需要进一步拆分或重新设计键。

3. 哈希分片

哈希分片先对分片键计算哈希,再按哈希空间分布:

{ userId: "u1001" }
       |
       v
hash(userId)
       |
       v
某个 shard

它通常能把等值写入更均匀地散布到多个 shard,降低单调键热点。

但代价是范围查询失去原始键顺序。例如:

{ userId: { $gte: "u1000", $lt: "u2000" } }

难以只定位一个连续哈希区间,可能需要访问多个 shard。

因此:

等值访问 + 均匀写入:哈希通常更有利
范围访问 + 局部性:范围分片通常更有利

这不是绝对规则,必须由业务查询和数据分布决定。


十二、mongos 如何处理查询

当集合按:

{ tenantId: 1, orderId: 1 }

分片时,mongos 会根据查询中是否包含分片键信息决定路由。

1. 定向查询

db.orders.find({
  tenantId: "t001",
  orderId: "o1001"
})

如果元数据能够确定该分片键值对应的 shard,mongos 可以只把请求发送到目标 shard。这称为 targeted operation。

2. 广播查询

db.orders.find({
  status: "PAID"
})

如果查询没有分片键约束,mongos 可能把请求发送到所有相关 shard,再合并结果。这称为 scatter-gather。

数据流可以表示为:

mongos
  |----> shard 1:局部查询
  |----> shard 2:局部查询
  |----> shard 3:局部查询
        |
        v
      mongos 合并

即使每个 shard 内部都有高效索引,跨 shard 的网络请求、结果合并和排序仍然存在。若要求全局排序:

db.orders.find({
  status: "PAID"
}).sort({
  createdAt: -1
}).limit(20)

mongos 可能需要从多个 shard 获取各自的候选结果,再执行归并。limit 可以减少每个 shard 传输的结果量,但不能把一次广播查询变成定向查询。

3. 分片键索引与查询索引的关系

分片键通常需要对应索引前缀,但业务查询仍然需要额外索引。例如:

{ tenantId: 1, createdAt: -1 }
{ tenantId: 1, status: 1, createdAt: -1 }

第一个可能用于租户内按时间查询,第二个用于租户内按状态和时间查询。

不要只因为“已经按 shard key 分片”就认为 shard 内部查询不需要索引。分片键解决路由,复合索引解决 shard 内部访问路径。


十三、分片集合的高可用结构

一个典型生产分片集群是:

mongos A、mongos B
       |
       +-- Config Server RS
       +-- Shard 1 RS
       +-- Shard 2 RS

1. mongos 不是数据节点

mongos 通常是无状态的,可以部署多个实例。客户端应连接多个 mongos,或者使用驱动支持的连接串和服务发现机制,避免单个 mongos 成为故障点。

2. 配置服务器不能忽略

配置服务器保存分片元数据。配置服务器故障不会等价于“某个业务 shard 故障”:

  • 已建立的路由可能在短时间内仍可工作;
  • 元数据变更、chunk 迁移和部分管理操作会受影响;
  • 配置服务器本身应按副本集高可用方式部署。

3. 每个 shard 通常是副本集

如果 shard 只是单个 mongod,分片集群仍可能存在单点故障。分片和副本集应该分别验证:

集群是否能找到正确 shard?
每个 shard 是否能在成员故障后选出 primary?
mongos 是否能发现新 primary?
配置服务器是否可用?

十四、跨分片事务和一致性取舍

MongoDB 支持事务,但事务的边界必须明确:

  • 单文档操作本身具有原子性;
  • 副本集内可以使用多文档事务;
  • 分片集群支持跨分片事务,但协调成本更高;
  • 事务并不意味着任意长事务都适合生产。

一个副本集事务示例:

const session = db.getMongo().startSession({
  readConcern: { level: "snapshot" },
  writeConcern: { w: "majority" }
})

const orders = session.getDatabase("shop").orders
const inventory = session.getDatabase("shop").inventory

try {
  session.startTransaction()

  orders.updateOne(
    { _id: "o1001", status: "CREATED" },
    { $set: { status: "PAID" } }
  )

  inventory.updateOne(
    { sku: "sku-1", stock: { $gte: 1 } },
    { $inc: { stock: -1 } }
  )

  session.commitTransaction()
} catch (e) {
  try {
    session.abortTransaction()
  } catch (_) {}
  throw e
} finally {
  session.endSession()
}

这个例子表达的是:

  1. 只有订单状态仍为 CREATED 时才更新;
  2. 只有库存足够时才扣减;
  3. 两次更新在事务提交前都不对外作为已提交结果;
  4. 使用多数派写关注降低提交结果在故障中的不确定性;
  5. commitTransaction() 失败时不能机械地再次执行任意业务操作,应用应根据驱动提供的错误标签和重试语义处理。

事务的代价包括:

  • 持有更长时间的资源;
  • 增加冲突和等待;
  • 增加复制、协调和提交开销;
  • 长事务可能阻碍缓存回收或影响系统稳定性;
  • 跨 shard 事务需要 mongos 和多个 shard 协调。

如果业务可以通过文档建模把强一致更新收敛到单文档,通常比跨文档、跨 shard 事务更简单。


十五、嵌入与引用如何影响索引和高可用

MongoDB 文档建模不能脱离访问模式。

1. 嵌入适合一起读取和一起更新的数据

例如订单直接嵌入少量订单项:

{
  _id: "o1001",
  tenantId: "t001",
  status: "PAID",
  items: [
    { sku: "sku-1", quantity: 2 },
    { sku: "sku-2", quantity: 1 }
  ]
}

优点是读取订单及其项目通常只需要一次文档读取,也可以在单文档原子操作中更新。

但要注意:

  • 文档大小有上限;
  • 无限增长的数组会导致文档膨胀;
  • 数组上的索引可能变成多键索引;
  • 频繁修改巨大文档会放大写入成本。

2. 引用适合独立增长或独立访问的数据

订单项数量很多、需要单独分页和查询时,可以拆成独立集合:

{
  _id: "item-1",
  orderId: "o1001",
  sku: "sku-1",
  quantity: 2
}

此时必须为访问模式建立索引:

db.orderItems.createIndex({
  orderId: 1,
  _id: 1
})

代价是读取订单及订单项需要多次查询,跨集合一致更新可能需要事务或业务补偿。

3. 分片下的建模影响更大

如果订单按 tenantId 分片,而订单项按 orderId 分片,那么一次读取订单及其订单项可能跨 shard。若数据访问天然以租户为边界,可以考虑让相关集合使用可协同路由的分片键设计。

因此,嵌入、引用、事务和分片键不是四个独立选择,而是同一个访问模型问题的不同层面。


十六、生产索引运维

1. 创建和核验索引

创建索引:

db.orders.createIndex(
  { tenantId: 1, status: 1, createdAt: -1 },
  { name: "orders_tenant_status_createdAt" }
)

查看索引:

db.orders.getIndexes()

删除索引:

db.orders.dropIndex("orders_tenant_status_createdAt")

删除前应确认:

  • 是否还有查询计划使用它;
  • 是否有隐藏或特殊用途;
  • 是否由分片、唯一约束或应用初始化逻辑依赖;
  • 删除后是否会导致集合扫描或排序;
  • 是否已经准备恢复方案。

索引创建不是免费操作。大集合建索引可能消耗显著 CPU、磁盘和 I/O,并影响业务负载。生产环境要根据官方版本行为和部署方式验证建索引期间的资源、锁和故障处理表现,不能把“在线建索引”理解成“对业务完全无影响”。

2. 重复索引和冗余索引

以下两个索引:

{ tenantId: 1 }
{ tenantId: 1, status: 1, createdAt: -1 }

第二个索引在某些查询上可以利用 tenantId 前缀,但不代表它在所有场景中都完全替代第一个:

  • 第二个索引更大;
  • 读取前缀查询时可能需要扫描更大的索引页;
  • 覆盖字段和排序能力不同;
  • 查询规划器会基于成本选择。

是否删除冗余索引应依据真实查询计划和负载测量,而不是仅凭字段前缀做静态判断。

3. 写入性能的基本推导

如果一个集合有 mm 个索引,一次写入通常需要:

写入文档
+ 更新索引 1
+ 更新索引 2
+ ...
+ 更新索引 m

因此索引越多,读查询的候选路径可能越丰富,但写入维护成本也越高。对于高写入集合,应特别警惕:

  • 为偶发查询建立大量索引;
  • 为低选择性字段建立单字段索引;
  • 建立无法覆盖真实排序和过滤组合的复合索引;
  • 长期保留不再使用的索引。

十七、副本集生产运维与诊断

1. 查看成员状态

rs.status()

重点观察:

  • 当前 primary;
  • 成员状态;
  • optimeDate
  • 最近心跳错误;
  • lastHeartbeatMessage
  • 是否存在延迟、恢复或同步异常。

查看本节点连接到哪个 primary:

db.hello()

不要在应用中把某个固定主机名永久写死为 primary。驱动应使用副本集连接串或服务发现能力,由驱动根据拓扑变化重新选择节点。

2. 查看复制延迟

可以比较 primary 和 secondary 的操作时间。延迟不是一个单一故障类型,原因可能包括:

  • secondary 磁盘慢;
  • CPU 不足;
  • 索引维护压力;
  • 网络带宽不足;
  • 大批量写入;
  • 初始同步;
  • 长时间阻塞或资源竞争。

如果 secondary 延迟持续增长,最终可能超过 oplog window,导致需要重新同步。

3. 慢查询诊断

可以使用数据库 profiler 或日志中的慢查询记录。诊断时应把一次慢查询拆成:

客户端等待时间
+ mongos 路由时间
+ shard 内排队时间
+ 查询执行时间
+ 网络传输时间

在分片环境中,只看 mongos 侧总耗时可能无法判断是哪个 shard 慢。应结合:

  • mongos 日志;
  • 各 shard 的 explain
  • 服务器 CPU、磁盘和内存;
  • 是否发生 scatter-gather;
  • 是否有单个 shard 热点;
  • 是否正在迁移 chunk。

4. 故障转移演练

可以在维护窗口执行受控的主节点切换,例如:

rs.stepDown(60)

这会请求当前 primary 在指定时间内退位,触发选举。生产执行前要确认:

  • 驱动使用副本集拓扑发现;
  • 应用能处理选举期间的网络错误;
  • 写入是否具备幂等性;
  • 事务和提交重试是否正确;
  • 连接池是否能重新建立连接;
  • 监控是否能区分短暂选举和持续故障。

不能只看“新 primary 出现了”,还要验证业务层的写入、读取、重试和告警行为。

5. 备份与恢复验证

副本集不是备份。副本集中的误删、错误更新和恶意写入会被复制到其他成员。

生产备份要考虑:

  • 逻辑备份或物理备份方式;
  • 副本集和分片集群的备份一致性;
  • oplog 或时间点恢复能力;
  • 加密、权限和备份介质安全;
  • 恢复到独立环境的验证;
  • 恢复时间目标(RTO)和恢复点目标(RPO)。

一次备份命令成功,不等于能够恢复业务。必须定期执行恢复演练,并验证索引、用户权限、分片元数据和应用连接方式。


十八、常见误解与失败表现

误解一:有索引就不会扫描很多数据

失败表现:

nReturned = 10
totalKeysExamined = 2,000,000
totalDocsExamined = 2,000,000

原因可能是:

  • 索引选择性差;
  • 复合索引顺序错误;
  • 查询没有使用索引前缀;
  • 排序和过滤组合不匹配;
  • 数据分布已经变化。

修复应从实际 explain("executionStats") 出发,而不是盲目新增索引。

误解二:secondary 读可以免费扩展容量

secondary 读会引入复制延迟和故障切换语义。更严重的是,如果业务读写混用不同节点,可能看到状态暂时倒退:

primary:订单状态 PAID
secondary:仍然看到 CREATED

这是复制延迟带来的可见性差异,不是索引问题。

误解三:分片后任何查询都会并行变快

没有分片键条件的查询可能广播到所有 shard。shard 数量增多后,查询可能产生更多网络请求和合并工作,延迟不一定下降。

分片扩展的是数据和部分吞吐能力,不会自动优化查询条件。

误解四:三节点副本集可以容忍任意两个节点故障

三节点副本集需要多数派。两个节点故障后,剩一个节点,没有多数派,通常不能继续选举 primary。它可能保留数据,但可用性和写入能力会受到影响。

误解五:事务可以替代正确建模

事务可以保证一组操作的原子提交,但不能消除:

  • 长事务的资源占用;
  • 跨 shard 协调成本;
  • 热点文档冲突;
  • 重试和幂等问题;
  • 不合理数据访问模式。

如果数据总是一起读写,嵌入通常比每次使用跨集合事务更直接;如果数据独立增长,则应接受引用和事务或最终一致性的代价。


十九、从一个完整查询到生产验证

假设业务需求是:

查询租户 t001 中最近创建的 20 个已支付订单。

第一步:明确查询

db.orders.find({
  tenantId: "t001",
  status: "PAID"
}).sort({
  createdAt: -1
}).limit(20)

第二步:建立候选索引

db.orders.createIndex({
  tenantId: 1,
  status: 1,
  createdAt: -1
})

理由:

  1. tenantIdstatus 是等值条件;
  2. createdAt 提供排序顺序;
  3. 查询可以在一个较窄的租户和状态范围内按时间倒序读取;
  4. limit(20) 允许执行器在找到足够结果后尽早停止。

第三步:执行计划验证

db.orders.find({
  tenantId: "t001",
  status: "PAID"
}).sort({
  createdAt: -1
}).limit(20).explain("executionStats")

预期关注:

winningPlan 中存在 IXSCAN
没有大规模 SORT
nReturned = 20
totalKeysExamined 接近 20 或有限范围
totalDocsExamined 与返回量同量级

如果 status = "PAID" 占租户数据的绝大多数,扫描键数仍可能较大。此时可以评估:

  • 是否需要只保留最近时间窗口;
  • 是否可以使用部分索引;
  • 是否将订单按租户分片;
  • 是否需要专门的搜索或报表模型。

第四步:分片环境验证

如果集合按 tenantId 分片,查询包含完整 shard key 前缀,通常更有机会定向到一个 shard。若只按:

{ status: "PAID" }

则可能广播。

第五步:副本集读取验证

如果业务要求刚刚完成支付后立即查到订单,不应无条件使用 secondaryPreferred。可以让这类读走 primary,报表类查询再考虑 secondary。

第六步:故障验证

在测试环境模拟 primary 退位,验证:

  1. 查询和写入是否收到临时错误;
  2. 驱动是否重新发现 primary;
  3. 重试是否造成重复订单;
  4. 订单状态和库存更新是否仍然满足业务约束;
  5. 选举结束后 rs.status() 是否恢复为一个 primary 和若干 secondary。

这条链路把索引、查询计划、分片路由、读写关注和选举行为连接起来,任何一层单独正确,都不能替代端到端验证。


MongoDB 的索引解决的是局部访问路径问题,副本集解决的是数据复制和成员故障问题,分片解决的是数据分布和横向扩展问题。生产系统真正需要验证的是它们组合后的行为:

查询条件
  -> 索引扫描
  -> shard 路由
  -> shard 内执行
  -> 副本集成员选择
  -> 读写关注确认
  -> 故障时重试与恢复

只有把这条数据流和故障路径都讲清楚,才能判断一个索引是否有效、一个分片键是否合理,以及一次“写入成功”究竟代表了多强的数据保证。


系列导航与关联阅读

官方资料

本文依据数据库官方文档重新梳理;正文、示例与生产检查清单由 WR BLOG 编写。