MongoDB索引与复合索引




2022-04-11

blog_main_img

MongoDB 索引不是“字段慢了就建一个”的按钮。业务复杂以后,真正难的是判断一条查询到底长什么样:哪些字段是等值过滤,哪些字段要排序,哪些字段是范围筛选,是否要分页,是否要覆盖查询,是否在分片集群里通过 `mongos` 路由。

先把索引想成“有序目录”

MongoDB 索引保存了集合里一部分字段的有序结构。查询条件能沿着这个有序结构走,就不用从头扫完整个集合。

没索引的查询像这样:

翻完整个集合 -> 检查每份文档 -> 找出符合条件的结果

有合适索引的查询像这样:

走索引范围 -> 定位候选文档 -> 必要时回表取字段

所以看查询性能时,不要只问“有没有索引”,而要问:

这个查询能不能沿着索引顺序走得足够窄
排序能不能直接用索引完成
投影字段能不能被索引覆盖
扫描的 key 和返回的文档比例是否健康

单字段索引解决不了复杂列表页

很多后台列表都长这样:

filter_doc = {
    "tenant_id": "t_001",
    "shop_id": "s_008",
    "deleted": False,
    "status": {"$in": ["paid", "shipped"]},
    "amount": {"$gte": 100, "$lte": 500},
}

sort_doc = [
    ("priority_score", -1),
    ("order_seq", -1),
]

如果你分别建:

orders.create_index("tenant_id")
orders.create_index("shop_id")
orders.create_index("status")
orders.create_index("amount")

看起来字段都被照顾了,但查询不一定舒服。MongoDB 可能只选一个索引,也可能做索引交集,但复杂过滤加排序时,单字段索引很难同时满足“过滤 + 排序 + 分页”。

这种场景应该先分析查询形状,再设计复合索引。

ESR:复合索引的第一把尺子

MongoDB 官方有一条很实用的复合索引设计准则:ESR。

Equality:等值过滤
Sort:排序字段
Range:范围过滤

它的含义是:复合索引里,通常先放等值过滤字段,再放排序字段,最后放范围字段。

MongoDB ESR 复合索引

对于订单后台列表,可以考虑:

from pymongo import ASCENDING, DESCENDING


orders.create_index(
    [
        ("tenant_id", ASCENDING),
        ("shop_id", ASCENDING),
        ("deleted", ASCENDING),
        ("status", ASCENDING),
        ("priority_score", DESCENDING),
        ("order_seq", DESCENDING),
        ("amount", ASCENDING),
    ],
    name="idx_order_list_esr",
)

对应查询:

cursor = (
    orders.find(
        {
            "tenant_id": "t_001",
            "shop_id": "s_008",
            "deleted": False,
            "status": {"$in": ["paid", "shipped"]},
            "amount": {"$gte": 100, "$lte": 500},
        },
        {
            "_id": 0,
            "order_id": 1,
            "status": 1,
            "amount": 1,
            "priority_score": 1,
            "order_seq": 1,
        },
    )
    .sort([("priority_score", -1), ("order_seq", -1)])
    .limit(30)
)

等值字段先把范围压窄,排序字段接着保证结果顺序,金额范围放后面做收尾筛选。

为什么范围字段别太早出现

假设你把索引写成:

orders.create_index(
    [
        ("tenant_id", 1),
        ("amount", 1),
        ("priority_score", -1),
        ("order_seq", -1),
    ],
    name="idx_bad_range_before_sort",
)

如果查询里 amount 是范围条件,后面的 priority_score 排序就很难继续保持全局有序。MongoDB 可能需要额外 SORT。

你可以把它理解成:

一旦索引扫描进入范围段,后面的字段顺序优势会明显变弱

所以列表页里,排序体验很重要时,经常把范围条件放在排序字段后面。范围过滤可能多扫一点,但能避免大范围内存排序。

索引设计不是绝对公式。如果范围条件选择性极强,而排序结果很少,也可以把范围放前面。关键是用 explain() 验证。

索引前缀:复合索引不是每一段都同样有用

复合索引:

orders.create_index(
    [
        ("tenant_id", 1),
        ("shop_id", 1),
        ("status", 1),
        ("priority_score", -1),
    ],
    name="idx_tenant_shop_status_score",
)

它天然支持这些前缀:

tenant_id
tenant_id + shop_id
tenant_id + shop_id + status
tenant_id + shop_id + status + priority_score

但不等于能高效支持:

shop_id
status
shop_id + status
priority_score

复合索引不是把字段全塞进去就万事大吉。最左前缀能不能被用上,很关键。

排序方向:全正全反通常都行,混合方向要对齐

索引:

orders.create_index(
    [
        ("tenant_id", 1),
        ("priority_score", -1),
        ("order_seq", -1),
    ],
    name="idx_sort_desc",
)

可以支持:

orders.find({"tenant_id": "t_001"}).sort([
    ("priority_score", -1),
    ("order_seq", -1),
])

也通常可以反向走索引:

orders.find({"tenant_id": "t_001"}).sort([
    ("priority_score", 1),
    ("order_seq", 1),
])

但混合方向要谨慎:

orders.find({"tenant_id": "t_001"}).sort([
    ("priority_score", -1),
    ("order_seq", 1),
])

如果业务真的需要混合方向排序,索引方向要按实际排序写。排序方向不是装饰,它会影响索引能不能直接提供顺序。

explain():别靠感觉判断索引

PyMongo 里可以这样看执行计划:

plan = (
    orders.find(
        {
            "tenant_id": "t_001",
            "shop_id": "s_008",
            "deleted": False,
            "status": "paid",
        }
    )
    .sort([("priority_score", -1), ("order_seq", -1)])
    .limit(30)
    .explain()
)

print(plan["queryPlanner"]["winningPlan"])

重点看这些东西:

winningPlan:最终选择的计划
IXSCAN:是否走索引扫描
COLLSCAN:是否集合扫描
SORT:是否额外排序
FETCH:是否回表取文档
indexName:命中的索引名

MongoDB explain 扫描对比

更细一点,可以看执行统计:

plan = orders.find(
    {"tenant_id": "t_001", "status": "paid"}
).explain("executionStats")

stats = plan["executionStats"]

print("returned:", stats["nReturned"])
print("docs:", stats["totalDocsExamined"])
print("keys:", stats["totalKeysExamined"])

一个常见健康信号是:totalKeysExaminedtotalDocsExamined 不要远大于 nReturned。如果扫描了很多 key,最后只返回很少结果,说明索引选择性或顺序可能不合适。

覆盖查询:能不回表就不回表

如果查询的过滤字段和返回字段都在索引里,MongoDB 可以不读取完整文档,这就是覆盖查询。

索引:

orders.create_index(
    [
        ("tenant_id", 1),
        ("status", 1),
        ("priority_score", -1),
        ("order_seq", -1),
        ("order_id", 1),
        ("amount", 1),
    ],
    name="idx_order_list_cover",
)

查询:

cursor = orders.find(
    {
        "tenant_id": "t_001",
        "status": "paid",
    },
    {
        "_id": 0,
        "order_id": 1,
        "amount": 1,
        "priority_score": 1,
        "order_seq": 1,
    },
).sort([("priority_score", -1), ("order_seq", -1)])

注意 _id 默认会返回。如果 _id 不在索引里,又没显式排除,就可能需要回表。所以覆盖查询经常会写:

{"_id": 0, "order_id": 1, "amount": 1}

覆盖查询适合列表页、下拉选择、轻量查询。不要为了覆盖把几十个字段都塞进索引,否则写入和存储成本会很难看。

软删除字段要不要放进索引

很多业务都有:

{"deleted": False}

如果几乎所有查询都带这个条件,而且已删除数据占比不低,可以把它放进复合索引:

orders.create_index(
    [
        ("tenant_id", 1),
        ("deleted", 1),
        ("status", 1),
        ("priority_score", -1),
    ],
    name="idx_not_deleted_list",
)

如果 deleted=False 占绝大多数,单独放在很前面未必有强选择性。这时更适合考虑 partial index:

orders.create_index(
    [
        ("tenant_id", 1),
        ("status", 1),
        ("priority_score", -1),
        ("order_seq", -1),
    ],
    name="idx_active_order_list",
    partialFilterExpression={"deleted": False},
)

这个索引只包含 deleted=False 的文档,索引更小,写入成本也更低。

前提是查询必须包含能匹配 partial filter 的条件:

orders.find({
    "tenant_id": "t_001",
    "deleted": False,
    "status": "paid",
})

否则 MongoDB 不会随便使用这个部分索引。

多键索引:数组字段很强,也很容易放大

数组字段建索引会变成 multikey index。

orders.create_index(
    [
        ("tenant_id", 1),
        ("tags", 1),
        ("status", 1),
    ],
    name="idx_tags_status",
)

文档:

{
  "tenant_id": "t_001",
  "tags": ["vip", "coupon", "risk"],
  "status": "paid"
}

索引里会为数组元素展开多个索引项。查询标签很方便:

orders.find({
    "tenant_id": "t_001",
    "tags": "vip",
    "status": "paid",
})

但数组越大,索引项越多,写入成本越高。更麻烦的是,一个复合 multikey index 里如果涉及多个数组字段,会遇到限制和不可控的组合膨胀。

建议:

数组字段适合少量标签和可控集合
不要把大数组当索引字段
复合索引里尽量只放一个数组字段
数组字段后面的排序收益要谨慎验证

$or 查询:每个分支都要被照顾

业务搜索经常写:

query = {
    "tenant_id": "t_001",
    "$or": [
        {"order_id": "O10001"},
        {"buyer_id": "U88"},
        {"phone": "13800000000"},
    ],
}

这种查询不能只建一个大杂烩索引就完事。更常见的是给每个分支准备合适索引:

orders.create_index([("tenant_id", 1), ("order_id", 1)])
orders.create_index([("tenant_id", 1), ("buyer_id", 1)])
orders.create_index([("tenant_id", 1), ("phone", 1)])

$or 的每个分支都可能走自己的索引。如果某个分支没有合适索引,整体计划可能变差。

模糊搜索:普通索引不是万能

下面这种前缀查询,索引有机会发挥作用:

users.find({"name": {"$regex": "^alice"}})

但这种包含查询通常很难靠普通 B-tree 索引跑快:

users.find({"name": {"$regex": "lice"}})

如果业务是站内搜索、分词、相关性排序,不要指望普通索引硬撑。应该考虑 MongoDB 的文本索引、Atlas Search,或者专门搜索引擎。

普通索引适合精确匹配、范围、排序、前缀类查询,不适合一切字符串搜索。

复杂业务案例:订单后台列表

需求:

按租户隔离
按店铺过滤
只看未删除
状态多选
金额范围
按优先级和流水号倒序
翻下一页
返回轻量字段

索引:

orders.create_index(
    [
        ("tenant_id", 1),
        ("shop_id", 1),
        ("deleted", 1),
        ("status", 1),
        ("priority_score", -1),
        ("order_seq", -1),
        ("amount", 1),
        ("order_id", 1),
        ("buyer_id", 1),
    ],
    name="idx_admin_order_page",
)

查询:

query = {
    "tenant_id": "t_001",
    "shop_id": "s_008",
    "deleted": False,
    "status": {"$in": ["paid", "shipped"]},
    "amount": {"$gte": 100, "$lte": 500},
}

projection = {
    "_id": 0,
    "order_id": 1,
    "buyer_id": 1,
    "status": 1,
    "amount": 1,
    "priority_score": 1,
    "order_seq": 1,
}

cursor = (
    orders.find(query, projection)
    .sort([("priority_score", -1), ("order_seq", -1)])
    .limit(30)
)

为什么这样排:

tenant_id、shop_id、deleted、status:等值或近似等值过滤
priority_score、order_seq:排序字段
amount:范围字段
order_id、buyer_id:列表投影字段,争取覆盖查询

如果金额范围过滤特别强,而排序并不重要,可以做另一条索引专门给金额查询:

orders.create_index(
    [
        ("tenant_id", 1),
        ("shop_id", 1),
        ("deleted", 1),
        ("amount", 1),
        ("status", 1),
    ],
    name="idx_amount_filter",
)

一个业务列表有两条索引不奇怪。真正要避免的是为每个筛选项都建一条单字段索引,最后查询还是不快。

翻页:深分页不要只会 skip

skip 越深,代价越明显:

orders.find(query).sort([("order_seq", -1)]).skip(50000).limit(30)

更适合高频列表的是游标翻页:

last_priority = 820
last_seq = 982233

query = {
    "tenant_id": "t_001",
    "shop_id": "s_008",
    "deleted": False,
    "$or": [
        {"priority_score": {"$lt": last_priority}},
        {
            "priority_score": last_priority,
            "order_seq": {"$lt": last_seq},
        },
    ],
}

cursor = (
    orders.find(query, projection)
    .sort([("priority_score", -1), ("order_seq", -1)])
    .limit(30)
)

配套索引:

orders.create_index(
    [
        ("tenant_id", 1),
        ("shop_id", 1),
        ("deleted", 1),
        ("priority_score", -1),
        ("order_seq", -1),
    ],
    name="idx_cursor_page",
)

游标翻页要求排序字段稳定,最好带唯一兜底字段,比如 order_seq_id

唯一约束:业务唯一键别靠代码赌

多租户订单号唯一:

orders.create_index(
    [
        ("tenant_id", 1),
        ("order_no", 1),
    ],
    unique=True,
    name="uniq_tenant_order_no",
)

这样不同租户可以有相同订单号,同一租户内不能重复。

如果软删除后允许重新创建同名对象,可以用部分唯一索引:

orders.create_index(
    [
        ("tenant_id", 1),
        ("external_no", 1),
    ],
    unique=True,
    partialFilterExpression={"deleted": False},
    name="uniq_active_external_no",
)

这类约束放在数据库层,比只靠应用层判断更稳。

hint():救急可以,长期别依赖

如果 MongoDB 选错索引,可以临时指定:

cursor = orders.find(query).hint("idx_admin_order_page")

hint() 是把选择权交给你。数据分布变了、查询条件变了,强制索引可能从救急变成新问题。

长期方案应该是:

整理查询形状
删除干扰索引
设计更匹配的复合索引
用 explain 验证
再压测真实请求

mongos 场景:索引在 shard 上,路由在 mongos 上

分片集群里,应用连的是 mongos。但 mongos 不存业务数据,也不存索引。索引建在每个 shard 的 mongod 上。

mongos 分片路由和索引

查询带 shard key 时:

orders.find({
    "tenant_id": "t_001",
    "order_id": "O10001",
})

mongos 更容易把请求路由到目标 shard。

查询不带 shard key 时:

orders.find({
    "order_id": "O10001",
})

mongos 可能需要把请求发到多个 shard,再合并结果。即使每个 shard 上都有 order_id 索引,整体代价也可能更高。

所以在分片集群里,复合索引要同时考虑:

shard key 是否参与高频查询
索引是否服务本 shard 内部过滤
排序是否需要跨 shard 合并
是否会出现 scatter gather

如果查询天然按租户隔离,把 tenant_id 作为 shard key 或 shard key 的一部分,会让很多查询更容易定向路由。

分片表的复合索引怎么想

假设 shard key 是:

{"tenant_id": 1}

高频查询:

orders.find({
    "tenant_id": "t_001",
    "shop_id": "s_008",
    "status": "paid",
}).sort([("priority_score", -1), ("order_seq", -1)])

索引可以这样:

orders.create_index(
    [
        ("tenant_id", 1),
        ("shop_id", 1),
        ("status", 1),
        ("priority_score", -1),
        ("order_seq", -1),
    ],
    name="idx_sharded_order_list",
)

这样既方便 mongos 定向路由,又方便目标 shard 内部走 ESR。

如果索引不带 tenant_id,单 shard 内部可能能用,但路由层仍然可能需要广播。分片场景里,路由效率和 shard 内索引效率要一起看。

用 PyMongo 管理索引清单

列出索引:

for item in orders.list_indexes():
    print(item["name"], item["key"])

创建索引时建议显式命名:

orders.create_index(
    [
        ("tenant_id", 1),
        ("shop_id", 1),
        ("status", 1),
    ],
    name="idx_tenant_shop_status",
)

删除索引也用名字:

orders.drop_index("idx_tenant_shop_status")

不要让索引名全是自动生成。项目大了以后,索引命名就是排查语言。

一个索引评审小脚本

你可以把查询计划里的核心指标打印出来,做索引评审辅助。

from pprint import pprint


def explain_summary(collection, query, projection=None, sort=None, limit=30):
    cursor = collection.find(query, projection)

    if sort:
        cursor = cursor.sort(sort)

    cursor = cursor.limit(limit)
    plan = cursor.explain("executionStats")
    stats = plan["executionStats"]

    summary = {
        "nReturned": stats["nReturned"],
        "totalKeysExamined": stats["totalKeysExamined"],
        "totalDocsExamined": stats["totalDocsExamined"],
        "winningPlan": plan["queryPlanner"]["winningPlan"],
    }

    pprint(summary)


explain_summary(
    orders,
    {
        "tenant_id": "t_001",
        "shop_id": "s_008",
        "deleted": False,
        "status": "paid",
    },
    {"_id": 0, "order_id": 1, "amount": 1},
    [("priority_score", -1), ("order_seq", -1)],
)

如果你看到:

totalDocsExamined 远大于 nReturned
winningPlan 里出现 COLLSCAN
winningPlan 里出现很重的 SORT

就值得回头重新看索引顺序和查询条件。

索引成本:写入、内存、磁盘都会付钱

索引不是免费的。

每多一个索引:

写入要更新它
更新索引字段要维护它
删除文档要清理它
磁盘要保存它
缓存要容纳它
查询规划器还要在候选计划里考虑它

所以索引治理同样重要。

建议保留:

核心列表页索引
唯一约束索引
高频查询索引
分片路由友好索引
关键聚合前置过滤索引

建议清理:

从未被 explain 命中的索引
功能重复的左前缀索引
早期临时排查留下的索引
和真实查询形状不匹配的索引

比如有:

{ tenant_id: 1, shop_id: 1 }
{ tenant_id: 1, shop_id: 1, status: 1 }

如果第一条没有独立价值,第二条可以覆盖它的前缀用途,那第一条可能就是冗余索引。