应按ESR规则创建以tenantId为最左前缀的复合索引,如{"tenantId":1,"status":1,"createdAt":-1},确保每个查询显式携带tenantId,并删除无tenantId前导的旧索引。

多租户SaaS应用中,同一张orders集合里混存数百个租户的数据,查询时却只返回3条本租户记录,却要扫描1498条索引键——这说明复合索引没按租户隔离设计,最左前缀失效,热数据被冷租户索引挤出内存。
确认查询模式并提取等值字段
打开MongoDB日志或使用db.currentOp()捕获慢查询,找到高频语句,例如:db.orders.find({tenantId: "t-789", status: "paid", createdAt: {$gte: ISODate("2026-09-01")}})。从中明确 tenantId 和 status 是等值匹配,createdAt 是范围条件。
这一步不能跳过:如果tenantId没出现在所有查询条件里,复合索引就失去租户隔离意义,后续建的索引全无效。
必须确保每个查询都显式携带tenantId,中间件注入不可信,漏写即越界。
按ESR规则排列复合索引字段顺序
ESR即Equal → Sort → Range,是MongoDB复合索引最核心的设计铁律。对上面的查询,正确顺序是:tenantId(等值)→ status(等值)→ createdAt(范围)。
执行命令:
db.orders.createIndex({"tenantId": 1, "status": 1, "createdAt": -1})
错误示例:{"createdAt": -1, "tenantId": 1, "status": 1}——范围字段放在最左,整个索引对tenantId单条件查询完全不可用;【一旦建错,删除重建会阻塞写入,且无法在线重命名】
在Spring Boot中自动创建并验证索引
方法一:使用@CompoundIndex注解声明
@Document(collection = "orders")
@CompoundIndex(name = "tenant_status_created_index", def = "{'tenantId': 1, 'status': 1, 'createdAt': -1}")
public class Order { ... }
应用启动时自动建索引,但需确保spring.data.mongodb.auto-index-creation=true(默认false)。
方法二:用MongoTemplate手动触发
在Configuration类中注入MongoTemplate,调用ensureIndex()并传入Index对象,适合灰度发布时分批建索引。
验证是否生效:对任意租户ID执行db.orders.find({tenantId:"t-789", status:"paid"}).explain("executionStats"),检查executionStats.keysExamined是否接近nReturned,且stage为IXSCAN而非COLLSCAN。
为高频子集补充Partial Indexes
当某个租户(如t-789)日增订单超5万条,而其他租户日均不足100条时,通用复合索引仍会为其冗余存储大量低效键。
第一步:为该租户单独建Partial Index
db.orders.createIndex({"status": 1, "createdAt": -1}, {partialFilterExpression: {"tenantId": "t-789"}})
第二步:确保查询中显式写出tenantId === "t-789",否则MongoDB根本不会考虑该索引。
第三步:用db.orders.getIndexes()确认索引已存在,注意partialFilterExpression字段必须精确匹配字符串值,不能用正则或$in列表。
注意:不要为每个租户都建Partial Index——租户数超50后,索引元数据本身会成为性能瓶颈。
强制tenantId作为所有索引前导字段
① 检查现有索引:db.orders.getIndexes(),过滤掉不含tenantId的索引(如仅{status: 1}或{createdAt: -1})。
② 删除无tenantId前导的索引:db.orders.dropIndex("status_1"),避免优化器误选低效路径。
③ 对唯一约束场景,必须建{tenantId: 1, externalId: 1}唯一索引,防止不同租户externalId重复被忽略。
④ 若使用分片集群,将tenantId设为hashed分片键前,先确认95%以上查询都带tenantId条件,否则路由失效导致全广播查询。
这一步操作起来很简单,直接把旧索引名填进dropIndex()就行。

















