Chapter 03
索引与 schema 设计
02 章讲了查询和聚合怎么读,也看到 $lookup 是嵌套循环、代价很贵。这章把"建什么索引"和"字段怎么摆"合起来讲——在 MongoDB 它们是同一个决策,而不是关系型里"先建表、再单独调索引"的两步走。
本章你将建立的 schema
- 索引类型全景:single / compound / multikey / text / 2dsphere / wildcard / hashed / partial / sparse / TTL,差异都在"键怎么从文档里取"。
- ESR 规则:复合索引字段顺序 = Equality → Sort → Range,以及为什么这个顺序是机制而非惯例。
- 嵌入 vs 引用的完整决策,加官方设计模式:Bucket / Subset / Computed / Polymorphic / Schema Versioning / Attribute。
- 反模式:无界数组、海量数组的 multikey 爆炸、过多索引、
$lookup滥用,以及 schema validation 怎么守门。
3.1索引类型全景
索引是独立于集合存储的 B-tree,键是字段值、值是文档定位符;各类型的差异在于"键怎么从文档里取"。
没有索引的查询是全集合扫描,引擎逐文档比对。索引把"按字段值找文档"变成 B-tree 上的对数查找。这一点和关系型一致;不一致的是 MongoDB 的字段值会是数组、会是任意嵌套路径,所以"键怎么取"分化出了多种索引类型。
底层机制(比文档深一层):普通 single / compound 索引每个文档贡献一个索引项。multikey 不同——当被索引字段是数组时,索引为数组的每个元素各存一个索引项。所以一个数组字段的索引项数 ≈ 文档数 × 平均数组长度,远多于文档数。还有一条硬限制:不能对两个数组字段建复合索引(parallel arrays 限制),引擎会直接报错——因为两个数组的笛卡尔积会让索引项数爆炸。multikey 不是一种显式声明的类型,而是只要被索引字段含数组值,索引自动变成 multikey。
其余类型各自解决一类"键怎么取或取多少"的问题:partial 只索引满足 partialFilterExpression 的文档,省空间也省写入维护;sparse 只索引含该字段的文档(缺字段的不进索引);TTL 是单字段索引加 expireAfterSeconds,后台线程周期性扫描并删除过期文档——注意是周期删除(默认约 60 秒一轮),不是到点精确即时删;hashed 索引哈希字段值,给分片场景提供均匀分布(见 06 章);wildcard({"$**": 1})给字段名不固定、无法预先枚举的场景,对所有路径建索引;text 做分词全文检索,2dsphere 做地理空间查询。
| 类型 | 用途 | 代价 / 注意 |
|---|---|---|
| single / compound | 等值、范围、排序、覆盖查询 | 每文档一个索引项;compound 受 ESR 顺序约束(§3.2) |
| multikey | 对数组字段建索引、按数组元素过滤 | 索引项 ≈ 文档数 × 数组长度;不能对两个数组字段建复合索引 |
| partial | 只索引满足条件的子集,省空间 | 查询条件不落在 partialFilterExpression 内时用不上该索引 |
| sparse | 只索引含该字段的文档 | 缺字段文档不进索引,相关排序/范围查询会漏掉这些文档 |
| TTL | 按 expireAfterSeconds 自动过期文档 | 后台线程周期删除(非精确即时);只能建在单个 Date 字段上 |
| hashed | 分片时打散写入、均匀分布 | 哈希后丢失顺序,不支持范围查询(见 06 章) |
| wildcard | 字段名不固定、无法预先枚举 | 覆盖面广但更大更慢;不能替代有针对性的 compound 索引 |
| text / 2dsphere | 全文分词检索 / 地理空间查询 | 专用类型;text 每集合至多一个;体积大于普通索引 |
对一个含 50 个标签的数组字段建 multikey 索引,集合有 100 万文档,索引大致有多少项?这对写入意味着什么?
展开答案(先停 10 秒)
索引项数 ≈ 100 万 × 50 = 5000 万项,是文档数的 50 倍。后果在写入侧:每次改动这个数组(增删一个标签)都要在 B-tree 上维护对应的多个索引项,数组越大、写入越慢。这就是"海量数组 + multikey"反模式的机制根源(§3.5 给修复),也是为什么数组字段要控制长度。
3.2ESR 规则:复合索引字段顺序
复合索引字段顺序按 Equality → Sort → Range 排,能让一次索引扫描同时完成过滤、排序、范围三件事。
复合索引能不能被一个查询用上、用得多充分,取决于字段顺序。顺序排错,引擎要么用不上后半段索引,要么被迫在内存里排序(大排序会溢出,02 章讲过 $sort 的内存上限)。ESR 是把顺序选对的规则。
底层机制(比文档深一层):复合索引 {a:1, b:1, c:1} 的 B-tree 先按 a 排序,a 相同的再按 b 排,b 相同的再按 c 排——是一棵有序嵌套的树。这个有序结构决定了三类条件该放哪:
Equality(等值)放最前——等值条件把扫描范围缩成 B-tree 上一段连续区间,选择性最高的过滤要尽早做,后面的字段才只在这一小段上工作。Sort(排序)放中间——等值段内部,索引按 Sort 字段天然有序,引擎直接顺着读出有序结果,避免内存 sort。Range(范围)放最后——范围条件($gt/$lt)在索引上扫描一个连续子区间,放最后才不会打断前面字段的连续性。一个常见错误是把低基数字段(如布尔,只把集合切两半)放最前,那等于浪费了最前位置的选择性。
// 查询:某商户、已支付、按金额降序、金额在某区间
db.orders.find({
merchantId: "M-100", // Equality
status: "paid", // Equality
amount: { $gte: 50, $lt: 500 } // Range
}).sort({ createdAt: -1 }) // Sort
// 对应的 ESR 索引:先 E(两个等值),再 S,最后 R
db.orders.createIndex({
merchantId: 1, // E
status: 1, // E
createdAt: -1, // S
amount: 1 // R
})
// 一次索引扫描即可过滤 + 有序输出 + 区间裁剪,无内存 sort
有人把上例索引建成 { amount: 1, merchantId: 1, status: 1, createdAt: -1 },把范围字段放最前。为什么这会拖慢查询?
展开答案
范围字段 amount 放最前,B-tree 先按一个区间散开,等值条件 merchantId/status 无法把扫描缩成一段连续键——它们散落在 amount 区间内的各处。更糟的是排序:createdAt 落在范围字段之后,索引顺序对它不再单调,引擎只能把结果拉进内存排序。ESR 顺序排错,等值的选择性和排序的免费有序两个好处同时丢失。
3.3嵌入 vs 引用:完整决策
嵌入=一次读命中、但写入会被放大;引用=避免冗余、但读取要二次查询或 $lookup。
01 章表 1.1 给了四问初判(一起读 / 无界 / 共享 / 写入时机)。这里补上反方向:什么时候引用反而更好,以及嵌入的真实代价。把决策落成一棵能照着走的树。
底层机制(比文档深一层):嵌入把"总是一起读"的数据预拼进一个文档,读取一次命中、无 $lookup;代价是更新放大(同一事实冗余在多处,改一处要改多处)和文档膨胀(逼近 16MB、挤占 WiredTiger cache,01/04 章)。引用把数据拆到独立集合,避免冗余、各自独立写;代价是读取要二次查询或走 $lookup 嵌套循环。四个判据里任何一个命中"引用"侧,就倾向引用:总是一起读吗(否→引用)、会无界增长吗(是→引用)、被多实体共享吗(是→引用)、写入时机一致吗(各自独立高频写→引用)。嵌入是默认选项,但要四个条件全部落在安全侧才成立。
什么场景下,明明数据"总是一起读",却仍该选引用?
展开答案
当这块数据无界增长或被多实体共享时,即便它和主体常一起读,也该引用。例如商品详情页总要显示商品所属的"类目"信息——一起读,但同一个类目被成千上万商品共享,嵌入会让类目改名时要更新海量文档。共享判据压过了"一起读",结论是引用类目、读时关联。决策树里"一起读=是"只是放行到下一关,不是直接判嵌入。
3.4官方设计模式
设计模式是把"嵌入 vs 引用"这条原则在常见场景下固化成的可复用解法。
MongoDB 官方把高频建模套路整理成一组模式。逐个看,每个配上"何时用":
Bucket(分桶):把高频产生的小数据按时间窗或固定数量分桶,多条原始记录合并成一个"桶文档"。接 01 §1.3 的 IoT 例子——每秒一条读数若各存一文档会有海量小文档,按"设备 + 小时"分桶成一个文档、内含该小时的读数数组,既控制文档数又让数组有界。何时用:时序、日志、高频小数据。
Subset(子集):把热子集冗余进主文档,其余引用。例如商品文档里冗余存"最近 3 条评价"供详情页直接显示,完整评价列表引用到独立集合按需翻页。何时用:一个大集合里只有一小段是高频读的。
Computed(预计算):把读时要算的聚合结果预先算好存进文档,省去每次读时重算。例如商品文档存 avgRating 和 reviewCount,写评价时增量更新,读时直接取。何时用:读远多于写、且每次读都要做同样的聚合。
Polymorphic(多态):一个集合存多种形态的文档,加一个 type 鉴别字段区分。例如 events 集合里 click/purchase/signup 各有不同字段,靠 type 分流处理。何时用:一组实体有共性也有差异,且常被一起查询。
Schema Versioning(schema 版本):给文档加 schemaVersion 字段,应用层按版本兼容读取、写入时渐进迁移到新版——无需停机改全表。何时用:schema 要演进,但表大到不能一次性迁移。
Attribute(属性):把数量可变、键不固定的属性存成 [{k, v}] 数组,再对 k 和 v 建索引,从而能按任意属性过滤。何时用:实体有大量稀疏、品类相关的可变属性(见本章 challenge)。
| 模式 | 解决的问题 | 一句话机制 |
|---|---|---|
| Bucket | 高频小数据导致海量文档 / 无界数组 | 按时间窗或数量把多条记录合并成一个桶文档 |
| Subset | 大数组拖垮主文档读取 | 热子集冗余进主文档,其余引用到独立集合 |
| Computed | 每次读都重复同一聚合 | 写时预算结果存进文档,读时直接取 |
| Polymorphic | 多形态实体要存一处并一起查 | 同一集合存多种文档 + type 鉴别字段 |
| Schema Versioning | schema 演进但表大到不能整迁 | 加 schemaVersion,应用层按版本渐进迁移 |
| Attribute | 键不固定的可变属性要可过滤 | 存成 [{k,v}] 数组并对 k,v 建索引 |
3.5反模式与 schema validation
反模式几乎都源自同一类错误——把无界或冗余的代价压在了写入和 cache 上;schema validation 在写入时守门。
| 反模式 | 根因 | 修复 |
|---|---|---|
| 无界数组 | 文档无界增长,逼近 16MB、挤占 cache | 分桶(Bucket)或引用到独立集合 |
| 海量数组 + multikey | 索引项 ≈ 文档数 × 数组长度,写放大 | 限制数组长度,或把数组重构为独立文档 |
| 过多索引 | 每次写都要同步维护所有索引 B-tree | 用 $indexStats 找出低命中索引并删除 |
$lookup 当 JOIN 滥用 | 嵌套循环,数据量大时代价陡增 | 把常一起读的数据嵌入,或对 foreignField 建索引 |
| 无 schema 校验,脏数据涌入 | 缺字段、错类型的文档混入集合 | 加 $jsonSchema validator 在写入时拦截 |
schema validation 机制:MongoDB 允许给集合挂一个 $jsonSchema 校验器,在写入时强制结构与类型。两个开关控制严格度:validationLevel 决定校验范围(strict 校验所有写入,moderate 只校验本就合规的文档的更新),validationAction 决定违规处理(error 拒绝写入,warn 仅记日志放行)。校验器既能在 createCollection 时设,也能用 collMod 给已有集合补上。
// 给 orders 集合加 $jsonSchema 校验器(写入时强制结构 + 类型)
db.runCommand({
collMod: "orders",
validator: {
$jsonSchema: {
bsonType: "object",
required: ["merchantId", "status", "amount"],
properties: {
merchantId: { bsonType: "string" },
status: { enum: ["pending", "paid", "refunded"] },
amount: { bsonType: "decimal", minimum: 0 }
}
}
},
validationLevel: "strict", // 校验所有写入
validationAction: "error" // 违规直接拒绝
})
一个集合建了 12 个索引,写入明显变慢,为什么?怎么入手?
展开答案
每次 insert/update 都要同步维护全部 12 棵 B-tree——一次写在存储层放大成 12 倍量级的索引维护工作,写入吞吐随索引数线性下降。读再快也救不回写被拖垮。入手:用 db.collection.aggregate([{$indexStats:{}}]) 看每个索引的实际命中次数,删掉长期零命中或低命中的索引。索引不是越多越好,每一个都在写路径上收税。
§本章 self-check
先合上教程,把答案写在纸上或编辑器里。写完再点开对照——直接点开等于把这一节当再读一遍。
- 一个数组字段建 multikey 索引,怎么估它的索引项数?给出估算公式,并说明为什么它影响写入性能。
- ESR 规则里 Equality、Sort、Range 各自为什么放那个位置?逐字母说清机制依据。
- Bucket 模式解决什么问题?它通过什么手段同时控制文档数和数组长度?
- (设计题)一个"用户 + 用户的收货地址(最多几个)+ 用户的订单(无界)"。收货地址和订单分别该嵌入还是引用?逐项给判据。
答案(先做完再展开)
- 索引项数 ≈ 文档数 × 平均数组长度(multikey 为数组每个元素各存一项)。影响写入:每次改动数组都要在 B-tree 上维护对应的多个索引项,数组越大写入维护越重。
- Equality 放最前:等值把扫描缩成一段连续键,选择性最高的过滤要最早做。Sort 放中间:等值段内索引按 Sort 字段天然有序,顺读即得有序结果,避免内存 sort。Range 放最后:范围在索引上扫一个连续子区间,放最后才不打断前面字段的连续性。
- 解决高频小数据导致的海量小文档与无界数组两个问题。手段:按时间窗或固定数量把多条原始记录合并进一个桶文档——桶的数量受时间窗约束(控文档数),每桶内数组受窗口长度约束(控数组长度)。
- 收货地址 → 嵌入:有界(最多几个)、几乎总和用户一起读、专属该用户、写入时机一致,四关全过。订单 → 引用:无界增长(无界判据命中)、常被独立分页查询、各自高频写入,任一命中即倾向引用,存独立集合按
userId关联。
给"任意多动态属性且要能按属性过滤"的商品设计 schema 和索引
需求:商品有任意多动态属性(颜色、尺寸、材质……),不同品类的属性集合不同;前台要能按任意属性组合过滤(如"红色 + 棉质")。先不查资料,选一个设计模式,写出文档结构,并设计支撑过滤的索引。再权衡它和"每个属性一个字段 + wildcard 索引"的取舍。
提示(卡住再展开)
用 Attribute 模式:把属性存成 attrs: [{k:"color", v:"red"}, {k:"material", v:"cotton"}] 数组,对 { "attrs.k": 1, "attrs.v": 1 } 建一个 multikey 复合索引——一个索引就能服务所有属性的等值过滤({"attrs": {$elemMatch: {k:"color", v:"red"}}})。对比方案是"每个属性一个顶层字段(color/material…)+ wildcard 索引 {"$**":1}":字段方案查询更直观、单属性查询更快,但字段集合随品类膨胀、wildcard 索引更大更慢且难精确控制。Attribute 用一个稳定索引换字段不固定的灵活性,代价是查询语法绕一层 $elemMatch、且 multikey 索引项随属性数增长。选型取决于属性是否真的无法枚举、以及过滤是单属性还是多属性组合为主。