写代码的时候给实体类加了@CompoundIndexes,重启应用,日志里看不到任何索引创建的提示,跑查询该慢还是慢,进 MongoDB 一看,集合里压根没有那张复合索引。这个场景我见过不止一次,很多同学第一反应就是“注解没用”“这功能是坏的”,但实际上大多数情况是某个前置条件没满足,或者创建过程被配置项拦住了。这篇就围绕@CompoundIndexes从原理到落地完整讲一遍,尤其是“为什么没效果”和“怎么确认它真的生效”。
1. 人人都说没效果,先分清是真没生效还是索引没被用上
先别急着喷这个注解。排查之前,把问题归成两类,方向完全不同。
第一类是“索引确实没建出来”。你连上数据库执行db.collection.getIndexes(),发现里面只有默认的_id_索引,根本没有你声明的那条复合索引。这种情况属于注解没有被处理,原因集中在扫描范围、配置开关、启动时机这几块,下文第 2 节和第 3 节专门讲。
第二类更隐蔽:索引明明已经存在,getIndexes()里能看到,字段顺序也对,但业务查询依然走了全表扫描,或者走了别的索引。这种情况不是“注解失效”,而是查询条件、排序字段和索引设计不匹配。很多人以为是注解问题,实际是索引设计问题,第 5 节会展开讲。
怎么快速区分?两步:
- 连库执行
db.collection.getIndexes(),确认目标复合索引是否存在。 - 存在的话,对具体查询执行
explain(),看winningPlan里到底是IXSCAN还是COLLSCAN。
索引存在但查询没用上,那是索引定义和查询模式不匹配;索引压根不存在,才需要回头查注解为什么没被处理。把这两类问题混在一起谈,很容易绕进去。
2. 从启动日志到集合索引:搞懂这个注解什么时候才会被处理
@CompoundIndexes是 Spring Data MongoDB 提供的一个容器注解,它内部持有多个@CompoundIndex:
@Document(collection = "order_info") @CompoundIndexes({ @CompoundIndex(name = "idx_user_status_time", def = "{'userId': 1, 'status': 1, 'createdAt': -1}"), @CompoundIndex(name = "idx_order_no", def = "{'orderNo': 1}", unique = true) }) public class OrderEntity { @Id private String id; private String userId; private String orderNo; private Integer status; private Date createdAt; // getter / setter 省略 }注解本身不会在运行时“主动”做什么,它只是一个元数据声明。Spring Data MongoDB 在应用启动时会扫描带有@Document注解的实体类,从中读取索引元数据,然后组装成 MongoDB 的索引创建命令下发到数据库。
这个过程中有一个关键类:MongoPersistentEntityIndexCreator。它会监听MappingContext中实体类的注册事件,对每个实体类解析索引注解,并调用MongoTemplate的indexOps()来确保索引存在。也就是说,@CompoundIndexes生效的前提条件是:实体类确实被 Spring Data MongoDB 的MappingContext扫描到了,并且应用上下文完整启动过。
有几种情况会导致它压根见不到你的实体类:
- 实体类不在
@SpringBootApplication所在包及其子包下,而你又没有显式配置@EntityScan或@ComponentScan把包路径引进来。 - 你自己手动
new了一个MongoTemplate或者自定义了MongoTemplate的 Bean,但没有把 Spring Data MongoDB 默认的MongoPersistentEntityIndexCreator触发链路保留下来。自定义配置时这步很容易被漏掉。 - 实体类没有加
@Document,只加了@CompoundIndexes。扫描器只认@Document对应的MongoPersistentEntity,没加聚合根的注解,索引声明自然不会被处理。 - 配置里把自动索引创建关掉了。Spring Data MongoDB 有一个
spring.data.mongodb.auto-index-creation配置项,默认是true,如果被某些团队基础配置改成了false,注解会全部静默失效。
还有一个容易忽略的点:索引创建是异步保证还是同步保证?早期版本的 Spring Data MongoDB 对索引创建是“尽力而为”的,应用启动完成后索引不一定立即可见。但现代版本(3.x / 4.x)在启动阶段会同步下发创建命令,前提是自动索引创建打开。如果你在启动完成后立刻查getIndexes()没看到,多半不是异步延迟,而是前面的某个条件没满足。
提示:
@CompoundIndexes本身可以标注在@Document实体类上,也可以用在@Document对应的“视图类”(比如聚合管道的查询投影类)上,但只有被MappingContext识别为MongoPersistentEntity的类型才会触发索引创建。投影类通常不会被识别,别指望在非聚合根上声明索引能生效。
3. 一条可复现的排查路径:八成靠日志和 getIndexes 就能定位
如果你确认“注解确实写了,但索引没建出来”,按下面的顺序从前往后排查。每一步都对应一类真实踩过的坑,按顺序走能省掉大量猜谜时间。
3.1 第一步:看启动日志里有没有 index 相关的输出
正常启动时,如果你配置了spring.data.mongodb.auto-index-creation=true,并且实体类被扫描到,日志里通常会看到类似:
MappedElasticsearchIndexCreator or MongoPersistentEntityIndexCreator... Creating index: IndexDefinition{...} for collection: order_info不同版本日志格式不一样,但关键词都是Creating index、IndexDefinition、collection。如果日志里完全没有这类输出,基本能确定索引创建流程没有被触发,直接跳到 3.3 检查扫描和配置。
3.2 第二步:检查集合索引列表
不管日志有没有输出,都要连到 MongoDB 去确认实际情况:
use your_database_name db.order_info.getIndexes()重点看两点:一是自定义索引是否存在;二是索引名是否和你声明的一致。有时候索引是创建成功的,但名称跟你预期不同,很多人只扫描了一眼就误以为“没建出来”。
3.3 第三步:确认实体类被真实扫描到
最简单的方式是在启动类上显式指定实体类扫描包:
@SpringBootApplication @EntityScan(basePackages = "com.yourproject.domain") public class Application { public static void main(String[] args) { SpringApplication.run(Application.class, args); } }同时确认实体类头部有@Document(collection = "xxx")。
注意,@EntityScan在实际项目里经常被忽略——如果你没有写它,默认扫描范围就是启动类所在的包。如果你把实体类放在独立的模块或者 jar 包里,路径一旦不在默认扫描范围内,注解就静默失效了。这是最最常见的“注解没用”原因,没有之一。
3.4 第四步:检查自动索引创建配置
全局搜索auto-index-creation和autoIndexCreation这两个关键字:
spring: data: mongodb: auto-index-creation: true配置在旧版本里叫spring.data.mongodb.auto-index-creation,新版本基本一致。如果你的项目配置中心把值写成了false,那@CompoundIndexes不会报任何错误,就是单纯不干活,非常坑。
3.5 第五步:排查配置类冲撞
自定义了MongoTemplate或AbstractMongoClientConfiguration的配置类时,要确保没有直接绕过默认的索引创建监听器。
我自己以前就踩过这个坑:为了做读写分离,自定义了MongoTemplateBean,结果实体类上的所有索引注解全部失效。原因就是自定义 Bean 创建方式后,MongoPersistentEntityIndexCreator的注册链路被破坏。解决思路是别直接 new MongoTemplate,而是让自定义逻辑基于 Spring Data MongoDB 自动配置的产物做二次增强,或者手动补一个MongoPersistentEntityIndexCreator监听器。
3.6 第六步:看索引是否被同名冲突阻碍
如果你的应用连接的是一个已有集合,而这个集合里已经存在同名但不同定义的其他索引,创建会被拒绝。比如有历史脚本建过idx_user_status_time,但字段顺序是status -> userId -> createdAt,你现在定义的顺序不一样,MongoDB 看到同名索引存在,不会自动删除重建,而是忽略你的新定义。这时要做的就是先删掉旧索引,或换一个索引名。
| 排查步骤 | 判断依据 | 常见结局 |
|---|---|---|
| 启动日志 | 有无Creating index输出 | 无输出则说明未触发流程 |
| getIndexes() | 索引是否存在、名字是否一致 | 不一致说明被同名索引阻挡 |
| @EntityScan 范围 | 实体类是否被扫描 | 未扫描则自定义索引全部失效 |
| auto-index-creation | 配置是否为 true | false 时注解全部静默 |
| 自定义 MongoTemplate | 是否破坏默认索引创建链路 | 链路破坏则注解失效 |
4. 正确姿势与参数说明:字段映射、索引名称、方向与唯一性
排查完“没生效”之后,再说说真正正确的写法。很多注解“看似正确但还是有问题”,原因是字段映射、名称、方向这三样至少有一项没对齐。
4.1 字段名映射:注解里的字符串和 Java 字段不一定同名
复合索引定义里写的'userId': 1是 MongoDB 文档里真实的字段名,不是 Java 属性名。如果实体类上用了@Field("uid")这类自定义映射,索引定义里就必须写'uid': 1,而不是'userId': 1:
@Document(collection = "order_info") @CompoundIndexes({ @CompoundIndex(name = "idx_uid_status", def = "{'uid': 1, 'status': 1}") }) public class OrderEntity { @Id private String id; @Field("uid") private String userId; private Integer status; }如果你不写@Field,Spring Data MongoDB 默认的映射策略是字段名直接使用 Java 属性名。有的项目全局配置了下划线命名策略field-naming-strategy: SNAKE_CASE(对应snake_case),Java 里的userId到了 MongoDB 里就变成user_id,索引定义里的'userId'虽然能创建成功,但永远匹配不上查询条件里的'user_id',索引就变成了摆设。
所以写索引定义之前,先带一条测试数据进去看一下真实文档结构,照着字段名写,别靠 Java 属性名脑补。
4.2 索引方向:1 和 -1 的意义比你想的更实际
很多教程说 1 是升序、-1 是降序,然后就没了。实际使用中方向主要影响两类场景:
- 单字段排序查询:
sort({createdAt: -1})时,如果索引方向也是-1,可以走索引完成排序,避免SORT内存排序。 - 复合索引的多字段排序组合:查询条件等值匹配 + 排序字段方向,如果能和索引定义的方向完全对齐,排序就能直接复用索引顺序。
举一个常见开发场景:列表页按创建时间倒序 + 按状态过滤:
@CompoundIndexes({ @CompoundIndex(name = "idx_status_createTime", def = "{'status': 1, 'createTime': -1}") }) public class ArticleEntity { private Integer status; private Date createTime; }这里我推荐把等值查询条件字段放前面,排序字段放后面,方向取实际排序方向。这样find({status: 1}).sort({createTime: -1})能够走索引完成过滤,又能在索引内完成排序。
4.3 name 属性:别偷懒,显式命名
@CompoundIndex的name不填的话,MongoDB 会自己生成名字,生成的规则基于字段名拼接。问题在于,如果你后续调整了复合索引定义,自动生成的名字可能不变,旧索引和新定义冲突,新定义创建失败。显式命名后,你能在getIndexes()里一眼认出哪条索引是哪次声明创建的;要重建索引时也能明确删除哪个索引名。
4.4 唯一索引与稀疏索引的附加参数
@CompoundIndex支持unique = true和sparse = true等属性:
@CompoundIndexes({ @CompoundIndex( name = "uk_order_no", def = "{'orderNo': 1}", unique = true ), @CompoundIndex( name = "idx_uid_status_sparse", def = "{'uid': 1, 'status': 1}", sparse = true ) })唯一复合索引的业务含义是“组合唯一”,比如同一个用户下订单号唯一。稀疏索引适合“字段不一定存在”的场景,比如某些文档没有orderNo字段,稀疏索引会跳过这些文档,避免唯一约束误伤。
但注意:MongoDB 的唯一索引对缺失字段的文档也会生效(一个文档如果完全缺失索引字段,会被当作用 null 参与唯一判断)。想放行缺失字段文档,必须靠稀疏索引。这是很多人在唯一索引上踩数据重复坑的根源。
5. 为什么索引建出来了查询还是慢:字段顺序和查询条件顺序的关系
索引建出来了,查询还是慢慢吞吞,这通常不是注解问题,而是复合索引的字段顺序设计与查询参数不匹配。复合索引最核心的设计原则是:字段顺序决定索引能支撑哪些查询。
MongoDB 的复合索引遵循“最左前缀”原则,但和关系型数据库略有区别。给定复合索引{a: 1, b: 1, c: 1},它能支撑的查询条件组合包括:
- 全部等值:
{a: 1, b: 2, c: 3} a等值 +b等值:{a: 1, b: 2}a等值 +c等值(跳过 b):{a: 1, c: 3}——这时候只会用到a字段的索引部分,c的条件是在索引结果上过滤- 只有
b或只有c:基本用不上这条复合索引
最常见的错误是:把范围查询字段放在索引前面,把高选择性的等值字段放后面。比如:
@CompoundIndex(name = "idx_time_status_uid", def = "{'createTime': -1, 'status': 1, 'uid': 1}")但业务查询是find({uid: 123, status: 1, createTime: {$gte: ...}})。由于索引第一个字段是createTime,查询优化器想用这个索引,也只能拿createTime去定位,uid和status的等值条件只能在索引结果里逐条过滤,选择性大打折扣。
推荐的复合索引设计思路可以概括为“ESR 顺序三步”:
- 等值(Equality)字段排最前:
uid、status、type这类WHERE =的字段。 - 排序(Sort)字段次之:最终结果要按什么排序,紧跟等值字段。
- 范围(Range)字段放最后:
$gte、$lte、$in、$ne这类范围或否定条件字段放在最后一位。
应用到上面的例子,应该改成:
@CompoundIndex(name = "idx_uid_status_time", def = "{'uid': 1, 'status': 1, 'createTime': -1}")这样查询{uid: 123, status: 1}能精确落到索引区间,createTime再负责在索引内部排序,整条查询既不需要回表扫全文档,也不需要单独做内存排序。
再说一个很多人没注意的点:复合索引的最终目标不是“一条索引覆盖所有查询”,而是少数几条索引覆盖核心查询路径。每条复合索引都有存储代价和写入代价,索引建多了,写入变慢、存储膨胀,反而把主流程拖垮。我见过一个极端例子,某个业务集合上有 9 条复合索引,单条写入耗时从 2ms 涨到 20ms,后来砍掉 5 条重复度较高的索引才恢复正常。
6. 用 explain 说话:被索引命中和全表扫描一眼就能分辨
无论前面设计得多花哨,最终判定标准只有一个:跑explain()看执行计划。这一步必须养成习惯,否则“索引有没有效果”永远停留在猜测阶段。
6.1 基本 explain 用法
db.order_info.find({uid: 123, status: 1}).sort({createTime: -1}).explain("executionStats")重点关注三块:
queryPlanner.winningPlan.inputStage:是IXSCAN还是COLLSCAN。IXSCAN说明走了索引,COLLSCAN说明全表扫描。executionStats.totalDocsExamined:实际扫描的文档数。这个值越小越好。如果索引命中,通常等于返回文档数附近;如果远大于返回数,说明索引部分过滤,还有优化空间。executionStats.totalKeysExamined:扫描了多少索引条目。如果IXSCAN但totalKeysExamined非常大,说明复合索引字段顺序没那么理想。
6.2 没走索引时 explain 的典型表现
一条最简单的查询,比如find({uid: 123})如果没建任何uid相关索引,winningPlan 会显示:
{ "stage": "COLLSCAN", "filter": { "uid": { "$eq": 123 } } }看到COLLSCAN就别再猜了,要么索引没建,要么字段名不匹配。把查询里实际用到的字段名和getIndexes()返回的keyPattern拿出来对一遍。很多时候就是因为@Field映射导致索引定义里的字段名和查询条件里的字段名不一致。
6.3 字段顺序不对时的 explain 表现
字段顺序设计不对时,查询优化器可能会“部分命中”索引。比如索引是{createTime: -1, uid: 1},查询是{uid: 123}.sort({createTime: -1}),explain 中winningPlan里 indexName 确实用了你的索引,但totalDocsExamined明显远大于返回集数量。原因是优化器只借用了createTime字段的索引前缀,uid条件无法在索引树中直接定位,只能逐条比对索引条目。
这种时候 explain 不会报错,也不会说你索引没用,但性能依旧差。所以光看IXSCAN不够,还得看totalKeysExamined和totalDocsExamined的比值。
6.4 日常自查建议
我建议把索引验证流程固化到每次发布中,而不是只在出问题时才排查。具体做法:
开发环境启动应用后,先跑一遍核心查询的explain(),确认每条查询都走了预期索引;上线前再对照getIndexes()和项目代码里的@CompoundIndexes声明,确保环境一致。MongoDB 的索引一旦和代码声明不一致,不会主动同步重建,这是很多项目“测试环境正常、生产环境慢”的隐藏原因。
7. 踩坑记录:一个真实项目里的复合索引恢复过程
最后分享一个具体的复盘过程,算是把前面的知识点串起来。
模拟项目 X 中,某订单模块的查询一直很慢,线上集合 3000 万文档,按uid + status + createTime过滤,explain()全表扫描。开发说“我明明写了@CompoundIndexes”。
第一步,连库执行getIndexes(),发现集合上只有_id_索引。说明注解确实没生效。第二步,查代码,实体类在 API 模块里被引用,但实体类本身放在独立的 commons 包中,启动类默认扫描路径并没有覆盖到那个包。观察日志,整个过程没有任何索引创建提示。第三步,在启动类上显式加了@EntityScan(basePackages = ...),重启。日志出现Creating index提示,再查getIndexes(),索引建立成功。
你以为到这里就结束了?没有。索引建好后,查询还是没走这条索引。继续explain(),发现查询条件里用的是uid,但实体类里用@Field("user_id")做了映射,真实文档字段名是user_id,而索引定义里写的是'uid': 1。索引虽然建出来了,但查询条件和索引字段对不上,优化器只能选择全表扫描。
修改索引定义为{'_user_id': 1, 'status': 1, 'createTime': -1}(实际按真实字段名写),重新创建索引并删除旧索引后,explain()显示IXSCAN,totalDocsExamined从 3000 万降到几十条。整个排查过程前后花费大约两个小时,大部分时间都耗在“以为注解没生效”和“实际是字段名不匹配”这两件事上。
这个案例里有一个很典型的教训:注解只是声明,真正的生效链路是“实体类被扫描 -> 索引元数据被解析 -> 数据库命令被下发”。任何一个环节断掉,都不报错、不警告、不影响启动,只有查询变慢之后才会暴露出来。所以写@CompoundIndexes的同时,一定要配套一个“启动后验证索引存在”的自动化检查,哪怕只是写一个简单的测试用例断言indexOps().getIndexInfo()里包含预期索引名,也比上线后靠人肉排查强得多。
我自己现在的做法是:所有核心集合的索引声明都集中维护,启动后自动比对代码声明的索引和数据库实际索引的差异,不一致直接告警。这样即便有人调整了实体类字段映射,或者有人通过脚本手工改了索引,也能第一时间暴露,而不是等查询慢到报警才发现。