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 做地理空间查询。

表 3.1 · 索引类型 → 用途 → 代价/注意
类型用途代价 / 注意
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)在索引上扫描一个连续子区间,放最后才不会打断前面字段的连续性。一个常见错误是把低基数字段(如布尔,只把集合切两半)放最前,那等于浪费了最前位置的选择性。

esr-example mongosh
// 查询:某商户、已支付、按金额降序、金额在某区间
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
复合索引 B-tree 的有序键空间 全部索引键(按 a → b → c 有序) Equality 等值 → 缩成一段连续键 Sort 段内天然有序 Range 末端扫子区间
图 3.1ESR 把一次索引扫描拆成三段协作。注意:Equality 放最前不是惯例而是机制——它把扫描缩成一段连续键,因此后面的 Sort 与 Range 只在这一小段上工作。顺序换了,这个连续性就断了。
想一想

有人把上例索引建成 { 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.2嵌入 vs 引用决策树。注意:四个判断里任何一个命中"引用"就倾向引用;嵌入是默认终点,但只有"总是一起读、有界、不共享"三关全过才到得了。任一关失守,路径就拐向引用。
想一想

什么场景下,明明数据"总是一起读",却仍该选引用?

展开答案

当这块数据无界增长或被多实体共享时,即便它和主体常一起读,也该引用。例如商品详情页总要显示商品所属的"类目"信息——一起读,但同一个类目被成千上万商品共享,嵌入会让类目改名时要更新海量文档。共享判据压过了"一起读",结论是引用类目、读时关联。决策树里"一起读=是"只是放行到下一关,不是直接判嵌入。

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 Versioningschema 演进但表大到不能整迁加 schemaVersion,应用层按版本渐进迁移
Attribute键不固定的可变属性要可过滤存成 [{k,v}] 数组并对 k,v 建索引

3.5反模式与 schema validation

反模式几乎都源自同一类错误——把无界或冗余的代价压在了写入和 cache 上;schema validation 在写入时守门。

表 3.2 · 反模式 → 根因 → 修复
反模式根因修复
无界数组文档无界增长,逼近 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 给已有集合补上。

schema-validator mongosh
// 给 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

先合上教程,把答案写在纸上或编辑器里。写完再点开对照——直接点开等于把这一节当再读一遍。

  1. 一个数组字段建 multikey 索引,怎么估它的索引项数?给出估算公式,并说明为什么它影响写入性能。
  2. ESR 规则里 Equality、Sort、Range 各自为什么放那个位置?逐字母说清机制依据。
  3. Bucket 模式解决什么问题?它通过什么手段同时控制文档数和数组长度?
  4. (设计题)一个"用户 + 用户的收货地址(最多几个)+ 用户的订单(无界)"。收货地址和订单分别该嵌入还是引用?逐项给判据。
答案(先做完再展开)
  1. 索引项数 ≈ 文档数 × 平均数组长度(multikey 为数组每个元素各存一项)。影响写入:每次改动数组都要在 B-tree 上维护对应的多个索引项,数组越大写入维护越重。
  2. Equality 放最前:等值把扫描缩成一段连续键,选择性最高的过滤要最早做。Sort 放中间:等值段内索引按 Sort 字段天然有序,顺读即得有序结果,避免内存 sort。Range 放最后:范围在索引上扫一个连续子区间,放最后才不打断前面字段的连续性。
  3. 解决高频小数据导致的海量小文档与无界数组两个问题。手段:按时间窗或固定数量把多条原始记录合并进一个桶文档——桶的数量受时间窗约束(控文档数),每桶内数组受窗口长度约束(控数组长度)。
  4. 收货地址 → 嵌入:有界(最多几个)、几乎总和用户一起读、专属该用户、写入时机一致,四关全过。订单 → 引用:无界增长(无界判据命中)、常被独立分页查询、各自高频写入,任一命中即倾向引用,存独立集合按 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 索引项随属性数增长。选型取决于属性是否真的无法枚举、以及过滤是单属性还是多属性组合为主。