2022-04-11
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 可能只选一个索引,也可能做索引交集,但复杂过滤加排序时,单字段索引很难同时满足“过滤 + 排序 + 分页”。
这种场景应该先分析查询形状,再设计复合索引。
MongoDB 官方有一条很实用的复合索引设计准则:ESR。
Equality:等值过滤
Sort:排序字段
Range:范围过滤
它的含义是:复合索引里,通常先放等值过滤字段,再放排序字段,最后放范围字段。
对于订单后台列表,可以考虑:
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:命中的索引名
更细一点,可以看执行统计:
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"])
一个常见健康信号是:totalKeysExamined 和 totalDocsExamined 不要远大于 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 越深,代价越明显:
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 上。
查询带 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 内索引效率要一起看。
列出索引:
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 }
如果第一条没有独立价值,第二条可以覆盖它的前缀用途,那第一条可能就是冗余索引。