数据库基础体系 · 第 45/139 篇。文章以各产品官方稳定版本的公开语义为准;示例会明确引擎、事务与部署边界。
MongoDB 索引与副本集:查询计划、分片、选举和生产运维
MongoDB 的性能与高可用问题,通常不是某一个参数导致的,而是多个机制共同作用的结果:
- 索引决定查询是否需要扫描大量文档;
- 查询规划器决定在多个候选索引中选择哪一个;
- 副本集通过 oplog 复制数据,并在主节点故障时重新选举;
- 分片把数据和请求分布到多个副本集;
- 读写关注级别决定客户端愿意接受多强的数据确认;
- 运维操作则决定这些机制在故障、扩容、备份和恢复时是否仍然可控。
理解这些机制时,必须区分三个层次:
- 逻辑数据模型:文档如何组织,哪些字段被嵌入或引用;
- 单副本集内的查询与复制:索引、查询计划、oplog、选举;
- 分片集群层面的路由与故障处理: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。
其成本可以粗略表示为:
其中:
- 是需要检查的文档数;
- 是读取文档或数据页的成本;
- 是执行过滤条件的成本。
索引的目标不是“让所有查询都快”,而是把候选文档数量从 降低到一个更小的 :
如果索引选择性很差, 仍然接近 ,索引可能没有收益,甚至因为索引遍历和随机读取而更慢。
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 })
索引访问过程大致是:
- 定位
tenantId = "t001"的索引范围; - 在该范围内定位
createdAt >= ...; - 按索引顺序读取结果;
- 如果查询字段都在索引中,可以避免回表读取文档。
索引并不是把文档复制一份,而是保存键值与文档定位信息。因此索引会带来:
- 额外磁盘空间;
- 写入时维护索引的 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 }
推导过程如下:
tenantId和status是等值约束;createdAt是范围约束;amount试图支持排序;- 但范围字段之后的排序字段是否能完全避免排序,要看实际查询条件、索引边界和执行器能否利用该顺序,不能只套公式保证;
- 如果必须稳定地支持
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" })
但数组会带来两个重要边界:
- 一个文档可能对应多个索引键;
- 对多个数组字段建立复合索引会受到多键索引限制,不能任意产生数组元素的笛卡尔组合。
因此,数组字段的索引设计必须结合文档结构,而不能只把所有查询字段机械地放入一个复合索引。
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_MERGE、SHARDING_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 不在索引中,可能需要读取文档。
覆盖查询并不是“永远不读磁盘”的保证:
- 查询条件和投影必须符合索引可提供的字段;
- 多键索引、数组、表达式和特殊类型会增加限制;
- 优化器仍可能基于成本选择其他计划;
- 覆盖查询减少的是文档读取,不代表索引遍历没有成本。
七、查询计划缓存与索引变更
相同形态的查询可能复用查询计划。计划选择会受到:
- 查询结构;
- 参数值分布;
- 索引集合;
- 统计和历史执行表现;
- 数据量变化。
因此,新增或删除索引后,不应只执行一次查询就判断结果。应关注:
- 新计划是否成为 winning plan;
totalKeysExamined / nReturned是否下降;- 高选择性值和低选择性值是否都表现合理;
- 计划是否在高并发下造成锁、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;
- 是否有节点进入
RECOVERING、STARTUP2或其他非正常状态。
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 写入;STARTUP、STARTUP2:启动或初始同步阶段;RECOVERING:恢复中,通常不应接收普通业务读;ARBITER:仲裁节点,不保存数据;REMOVED、UNKNOWN:从当前视角看不可用或不可达。
检查状态:
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
多数派为:
只有一个节点存活时,它不能获得多数派,因此不会安全地自选为 primary。这样可以避免网络分区时出现两个 primary,即 split-brain。
如果五个投票成员中三个互相可见,理论上可以继续选举:
但“节点更多”并不自动意味着更好。投票成员必须分布在故障域中,否则机架、可用区或网络故障可能同时带走多数票。
3. 一次典型故障路径
假设:
A = primary
B = secondary
C = secondary
发生以下步骤:
- A 进程崩溃或网络隔离;
- B、C 的心跳检测到 A 不可达;
- B 或 C 进入选举流程;
- 某个候选者请求其他投票成员支持;
- 获得多数票后成为新的 primary;
- 客户端驱动通过服务器发现机制更新拓扑;
- 原 A 恢复后,若发现已有更高任期的 primary,则不能继续作为旧 primary 写入;
- 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()
}
这个例子表达的是:
- 只有订单状态仍为
CREATED时才更新; - 只有库存足够时才扣减;
- 两次更新在事务提交前都不对外作为已提交结果;
- 使用多数派写关注降低提交结果在故障中的不确定性;
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. 写入性能的基本推导
如果一个集合有 个索引,一次写入通常需要:
写入文档
+ 更新索引 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
})
理由:
tenantId、status是等值条件;createdAt提供排序顺序;- 查询可以在一个较窄的租户和状态范围内按时间倒序读取;
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 退位,验证:
- 查询和写入是否收到临时错误;
- 驱动是否重新发现 primary;
- 重试是否造成重复订单;
- 订单状态和库存更新是否仍然满足业务约束;
- 选举结束后
rs.status()是否恢复为一个 primary 和若干 secondary。
这条链路把索引、查询计划、分片路由、读写关注和选举行为连接起来,任何一层单独正确,都不能替代端到端验证。
MongoDB 的索引解决的是局部访问路径问题,副本集解决的是数据复制和成员故障问题,分片解决的是数据分布和横向扩展问题。生产系统真正需要验证的是它们组合后的行为:
查询条件
-> 索引扫描
-> shard 路由
-> shard 内执行
-> 副本集成员选择
-> 读写关注确认
-> 故障时重试与恢复
只有把这条数据流和故障路径都讲清楚,才能判断一个索引是否有效、一个分片键是否合理,以及一次“写入成功”究竟代表了多强的数据保证。
系列导航与关联阅读
- 系列入口:数据库完整学习路线:从关系模型、事务索引到分布式与向量检索
- 上一篇:MongoDB 文档建模:嵌入与引用、Schema、事务和一致性
- 下一篇:ClickHouse 列式建模:MergeTree、排序键、分区和数据跳过
- 延伸:数据库复制、分片与高可用:一致性、路由、故障转移和扩容
官方资料
本文依据数据库官方文档重新梳理;正文、示例与生产检查清单由 WR BLOG 编写。

评论
0 条讨论