1. 问题现场注解加了索引却迟迟没生效先说说我遇到的实际问题。当时在做一个订单相关的模拟项目订单集合数据量涨得很快按照用户ID加创建时间的组合查询越来越慢。于是很自然地在实体类上加了CompoundIndexes想搞一个复合索引来加速查询。代码写完之后重启了应用满心以为索引自动建好查询就会飞起来。结果一跑慢查询日志执行计划还是全集合扫描CompoundIndexes就跟没写一样。如果你也碰到过类似的情况——明明加上了注解MongoDB 里却没有生成索引或者索引存在但查询压根用不上那这篇文章就是为你准备的。我会把这个注解的用法拆开揉碎讲清楚同时把“不起作用”的常见原因逐个过一遍顺便给出我自己实测有效的排查思路。不管你是刚接触 Spring Data MongoDB还是已经踩过几个坑按照这里面的步骤走一遍基本都能找到症结所在。先说结论绝大多数情况下索引“没效果”不是注解本身失效而是它背后的触发机制、字段命名、查询条件和我们预想的不一致。搞清楚这两张表——注解怎么用索引怎么走问题就解决了一半。2. CompoundIndexes 到底是什么为什么需要它2.1 索引与复合索引的基础逻辑MongoDB 的索引机制可以粗暴地理解成书的目录。没有目录的时候你想找某个词只能一页一页翻集合扫描有了目录直接查页码再翻到对应页索引查找。单字段索引只对一个字段排序复合索引则同时对多个字段建立排序结构它的核心价值在于“同时作为多个查询条件的加速器”。举个例子。订单集合里的查询经常是db.order.find({ userId: U10086, createTime: { $gte: ISODate(2024-01-01) } })如果只有 userId 上的单字段索引MongoDB 可以先通过 userId 缩小范围再在结果集里过滤 createTime这个过程叫 index scan fetch整体效率还不错。但如果只有 createTime 索引那就只能先扫某段时间的所有订单再逐一匹配 userId效率就差多了。复合索引{ userId: 1, createTime: -1 }直接让这两个字段的匹配都在索引树里完成回表次数大幅减少。2.2 注解的定位Spring Data 的领域模型映射CompoundIndexes是 Spring Data MongoDB 提供的一个注解它可以放在Document实体类上用来声明复合索引。Java 代码里不写原生脚本就能在应用启动时自动为对应的集合创建索引。它本质上是一种“声明式索引管理”让索引定义和实体模型放在一起便于维护。基本写法如下Document(collection order) CompoundIndexes({ CompoundIndex(name user_time_idx, def {userId: 1, createTime: -1}), CompoundIndex(name order_status_time_idx, def {status: 1, createTime: -1}) }) public class OrderEntity { private String userId; private String status; private Date createTime; // 其他字段... }注意一个细节CompoundIndex里的def是一个 JSON 字符串字段名必须是 MongoDB 文档里存的字段名而不仅仅是 Java 属性名。如果实体类里 Java 属性是userId但通过Field(user_id)映射到了user_id那么def里就必须写user_id: 1写userId: 1创建出来的索引字段根本不存在查询照样用不上。2.3 注解的完整属性清单CompoundIndex注解包含以下属性逐一说明属性类型说明nameString索引名称。不指定时MongoDB 会根据字段自动生成类似userId_1_user_time_-1的名字。建议显式指定方便后续维护和排查。defString索引字段定义JSON 字符串例如{userId: 1, createTime: -1}。数字 1 表示升序-1 表示降序。backgroundboolean是否后台创建索引。默认为 false。对于大数据集建议设为 true避免阻塞数据库操作。uniqueboolean是否唯一索引。默认为 false。注意唯一索引对重复键会创建失败。sparseboolean稀疏索引。默认为 false。只索引包含该字段的文档适用于字段存在性不确定的场景。expireAfterSecondslongTTL 索引的过期时间单位秒。复合索引里使用这个属性要特别注意MongoDB 只支持单字段 TTL复合索引上设置 TTL 是不会生效的。partialFilterString部分索引过滤条件JSON 字符串。例如{status: {$exists: true}}只对满足条件的文档建索引。collationString排序规则。用于大小写、语言等规则定义。这里最常见的一个理解偏差就是expireAfterSeconds。很多人以为复合索引加了这个参数就能自动删旧数据其实不行。MongoDB 的 TTL 索引必须建立在单个日期字段上复合索引的 TTL 行为未定义。真要做过期数据清理单独在 createTime 字段上一个Indexed(expireAfterSeconds 3600)才是正道。2.4 Spring Data 的索引创建机制为什么要为“不起作用”背锅要理解“不起作用”必须先搞清楚 Spring Data MongoDB 在什么时机创建索引。应用启动时Spring Data 会通过MongoPersistentEntityIndexResolver扫描所有的Document实体类解析其中的索引注解再调用MongoTemplate的indexOps()对每个集合执行索引创建。这个流程是自动的默认在实体映射初始化阶段触发。问题来了这个自动创建依赖两个条件。第一你的实体类被 Spring 容器扫描到也就是必须处于组件扫描路径下并且被Document标注。第二应用启动时 MongoDB 必须能连接上如果连接失败索引创建自然不会执行。更隐蔽的是如果集合已经存在且索引名与已存在的索引同名Spring Data 不会主动去修改它。比如你在某个实体类上定义了一个名为user_time_idx的索引线上集合里恰好已经有一个同名但字段顺序不同的user_time_idxSpring Data 调用ensureIndex可能不会报错但实际上新索引并没有被创建。这种情况下注解“看起来”还在实际主导查询的仍然是旧索引。所以我现在的习惯是每次修改索引定义之后不光重启应用还会连到 MongoDB 里确认索引到底建没建、字段方向对不对而不是默认“注解加了就万事大吉”。3. 正确使用 CompoundIndexes 的完整姿势3.1 基本用法与字段顺序的黄金法则复合索引的大忌字段顺序乱写。CompoundIndexes里的def字段声明顺序就是索引键的优先级顺序。MongoDB 查询要想命中复合索引查询条件里必须包含索引前缀字段。所谓前缀字段就是索引定义里排在最前面的字段。比如索引是{userId: 1, createTime: -1}下面的查询可以用上索引db.order.find({ userId: U10086, createTime: { $gte: ISODate(2024-01-01) } }) db.order.find({ userId: U10086 })但下面的查询用不上// 没有 userId只有 createTime db.order.find({ createTime: { $gte: ISODate(2024-01-01) } })这就意味着你的查询条件里最高频、最具区分度的等值匹配字段要放在复合索引的最前面。以订单场景为例{userId: 1, status: 1, createTime: -1}和{status: 1, userId: 1, createTime: -1}看起来都是三个字段但适用场景完全不同。如果我们的查询模式总是“先按用户查再按状态过滤”前者就是对的。如果经常是“平台运营查所有某个状态的订单”后者反而更好。没有全局最优只有针对具体业务查询模式的局部最优。3.2 代码示例一个可复用的标准写法假设我们要做一个订单查询接口常见的入参是先按用户ID、再按订单状态、最后按创建时间倒序。那么索引可以这样定义Document(collection order) CompoundIndexes({ CompoundIndex( name idx_user_status_time, def {userId: 1, status: 1, createTime: -1}, background true ) }) public class OrderEntity { Id private String id; Field(user_id) private String userId; Field(status) private String status; Field(create_time) private Date createTime; // getter / setter 省略 }注意我这里用了Field(user_id)把 Java 属性映射成了数据库字段名。那么在def中必须写user_id: 1而不是userId: 1。这是我见过最容易踩的坑后面会重点讲。对应的查询代码Query query new Query(); query.addCriteria(Criteria.where(user_id).is(U10086)); query.addCriteria(Criteria.where(status).is(PAID)); query.addCriteria(Criteria.where(create_time).gte(startTime).lte(endTime)); query.with(Sort.by(Sort.Direction.DESC, create_time)); ListOrderEntity orders mongoTemplate.find(query, OrderEntity.class);这个查询的等值字段user_id、status都在索引前缀里排序字段create_time也和索引定义的方向一致可以完整命中idx_user_status_time。3.3 索引方向、排序与查询的匹配关系很多人忽略排序方向。MongoDB 的复合索引在匹配查询条件之后如果结果的排序顺序恰好和索引顺序一致就可以直接按索引顺序返回省掉一次排序开销。如果索引定义是createTime: -1查询里排序也是createTime DESC那就是最佳的。如果你在代码里用Sort.by(Sort.Direction.ASC, create_time)那索引方向反过来MongoDB 有可能会做 in-memory sort严重时甚至会放弃索引。再强调一个细节复合索引的“方向”在不同字段间其实是可以灵活搭配的。对于{a: 1, b: -1}等值查询a时b的方向对最终排序影响很大。如果你无法确定查询的排序方向一个通用的妥协方案是让索引方向跟随最常用排序方向。不允许来回改尽量一次定好。3.4 后台创建与唯一约束的收尾细节对于订单量很大的集合创建索引会阻塞数据库操作相当长时间。background true可以让索引在后台构建业务读写不被完全卡死。注意在 MongoDB 4.2 及以后的版本里创建索引本身已经有更细的构建选项但 Spring Data 注解层面依然支持background配置。大数据量场景实测下来这个参数值得开。unique true则要小心。如果集合里已经存在重复键索引创建会直接失败应用启动时可能会抛DuplicateKeyException或者索引创建失败的错误。如果你要加唯一约束先确认旧数据是否有重复。3.5 部分索引与稀疏索引怎么选sparse true说的是索引只对包含该字段的文档生效。比如订单实体新增了一个可选字段promotionId大部分订单没有这个字段你想查某个促销活动的订单时可以用普通索引但让所有订单都进索引树会浪费空间此时用稀疏索引就比较合适。partialFilter比sparse更灵活它支持写过滤条件类似CompoundIndex( name idx_status_time_partial, def {status: 1, createTime: -1}, partialFilter {status: {$in: [PAID, REFUND]}} )只有status是PAID或REFUND的文档才会进入索引。这在一些数据冷热分明的场景特别有用——只给活跃数据建索引索引体积和更新成本会明显下降。4. 排查实录让“不起作用”的索引真正跑起来4.1 第一步先确认索引到底建没建这是最基础但很多人跳过的步骤。直接用 MongoDB Shell 或者可视化工具查看集合索引db.order.getIndexes()输出里会列出这个集合所有索引。你要找的是通过CompoundIndexes定义的那个名称。如果压根没有说明 Spring Data 没有执行索引创建或者执行失败被吞掉。再检查应用启动日志。Spring Data 在创建索引时通常会输出类似下面的日志Ensuring index idx_user_status_time on collection order如果连这行日志都没有大概率是实体类没有被扫描到。看看你的实体类是否在SpringBootApplication所在的包或者其子包下是否确实标注了Document。如果实体类被随便放在一个没有扫描到的包里注解自然不会被解析。如果日志显示索引创建出错比如字段名非法、重复键冲突也会给出异常栈。逐个解决就好。4.2 第二步直接手动创建索引对比查询计划为了快速定位是不是注解本身的问题可以先手动在 MongoDB 里创建同名字的索引db.order.createIndex({ user_id: 1, status: 1, create_time: -1 }, { name: idx_user_status_time, background: true })然后跑一次查询用explain看执行计划db.order.find({ user_id: U10086, status: PAID, create_time: { $gte: ISODate(2024-01-01), $lte: ISODate(2024-01-31) } }).sort({ create_time: -1 }).explain(executionStats)在返回的executionStats里看totalDocsExamined和totalKeysExamined。如果totalDocsExamined等于命中文档数说明走了索引如果totalDocsExamined等于全集合文档数说明全集合扫描。同时看winningPlan里的stage是FETCH加IXSCAN还是COLLSCAN。IXSCAN才是走了索引。手动创建后如果查询能走索引而注解方式不行那么问题基本锁定在 Spring Data 解析或自动创建环节。如果手动创建也走不了索引那就是查询条件或者索引定义本身不匹配。4.3 第三步检查索引字段名与实际文档字段名是否一致我遇到最多的“不起作用”归根结底是字段名映射不一致。Java 属性是userId但 Spring Data 默认会把属性名当作字段名存到 MongoDB。如果你没有加Field那 MongoDB 里的字段就是userId。但订单表很可能通过Field(user_id)映射或者数据库里本身是user_id。你却在def里写了userId: 1。索引建出来了但索引里的字段在文档里根本不存在。判断方法很简单随便查一条文档看看真实字段名。db.order.findOne()把文档里的实际 key 和索引def里的字段名逐字对比大小写、下划线都不能错。注意def里的字段顺序也必须是查询条件依赖的前缀顺序。这一步排查成本最低收益最高。4.4 第四步检查查询条件是否满足复合索引的最左前缀即使索引建好了查询也不一定命中。假设索引是{user_id: 1, status: 1, create_time: -1}如果你查询条件里只给了status和create_time没有user_id索引直接失效。这是复合索引规则决定的Spring Data 和 MongoDB 不会有任何报错只有执行计划告诉你真相。还有一种情况是查询条件里第一个字段是范围查询而不是等值查询db.order.find({ create_time: { $gte: ... }, status: PAID })如果索引的第一个字段是create_time那么范围查询之后后续字段的索引利用效率就很有限甚至完全用不上。MongoDB 的复合索引在范围查询之后后续字段的定位能力会大打折扣。所以设计复合索引时要把等值过滤字段放在前面范围字段放在后面。4.5 第五步排除排序方向与类型不一致查询排序和索引顺序相反也会造成索引失效后的内存排序。前面已经说过这里再补充一个真实案例。某同学定义索引是{user_id: 1, create_time: -1}查询里写Sort.by(Sort.Direction.ASC, create_time)结果执行计划出现了SORT阶段而且是在FETCH之后。也就是说索引虽然被用来定位了但排序还得另做。如果排序数据量大这个额外的排序代价可能抵消索引带来的收益。另一个容易忽略的是类型不一致。比如文档里某个字段存的是字符串2024-01-01查询条件用Date类型比较索引查找会退化成低效的多次扫描。保持 Java 类型和 MongoDB 类型严格一致是索引生效的前提。4.6 第六步注意 Spring Data 版本差异与索引自动创建开关有些版本里CompoundIndexes自动创建索引的行为可能受spring.data.mongodb.auto-index-creation配置影响。默认情况下这个开关是 true 的但如果项目里有人把它设成了 false索引就不会自动创建。检查一下配置文件spring: data: mongodb: auto-index-creation: true如果你用的 Spring Boot 版本比较旧配置项名称可能会有所不同但原理一样。另外如果你引入了多数据源配置或者自定义了MongoTemplate索引自动创建也有可能被影响需要在自定义配置里手动触发。4.7 附一个典型的排查速查表现象可能原因处理方式集合里找不到索引Document未扫描、auto-index-creationfalse、启动时连接失败检查扫描路径、配置和启动日志索引存在但查询COLLSCAN字段名不匹配、查询条件不满足最左前缀、字段类型不一致核实字段名调整查询条件或索引定义索引存在但查询走了SORT排序方向和索引方向不一致统一排序方向或修改索引定义索引创建报错重复键、字段名非法、TTL复合索引清理数据、改字段名、拆分成单字段TTL索引索引同名冲突多次修改索引定义但未删除旧索引手动删除旧索引后重新创建5. 实战案例从注解到执行计划的完整验证5.1 场景还原用我前面提到的模拟订单系统来说明。实体类里加了这样的注解Document(collection orders) CompoundIndexes({ CompoundIndex( name idx_user_time, def {userId: 1, createTime: -1}, background true ) }) public class Order { private String userId; private Date createTime; }查询代码Query query new Query( Criteria.where(userId).is(U10086) .and(createTime).gte(start) ); ListOrder list mongoTemplate.find(query, Order.class);这个案例中索引看起来应该起作用。但实际排查时发现索引确实建出来了查询却依旧慢。手动看getIndexes()返回的数据{ v : 2, key : { userId : 1, createTime : -1 }, name : idx_user_time }索引没问题。再手动执行查询的explaindb.orders.find({ userId: U10086, createTime: { $gte: ISODate(2024-01-01) } })返回的 winningPlan 是IXSCAN。那为什么应用里查询慢最终发现应用查询里除了这两个条件还多了一个Criteria.where(status).is(PAID)。因为status不在索引字段中MongoDB 先走索引把某个用户某个时间段的所有订单都读出来再过滤status。如果这个用户在时间段内订单很多那FETCH数量依然很大。解决方式是追加索引字段把索引改成了{ userId: 1, status: 1, createTime: -1 }查询立刻快了一个量级。5.2 命中索引与执行计划验证在 Spring Data 中想直接拿到explain结果可以使用Document explainDocument mongoTemplate.getCollection(orders) .find(Filters.and( Filters.eq(userId, U10086), Filters.gte(createTime, start), Filters.eq(status, PAID) )) .sort(Sorts.descending(createTime)) .explain();或者用Query.explain()也可以。拿到结果后重点检查queryPlanner.winningPlan中是否有IXSCAN节点。这是最直接的证据比看日志、看时间都靠谱。5.3 索引修改之后旧索引怎么处理改索引定义之后集合里很可能同时存在旧索引和新索引。Spring Data 的机制是不会帮你删旧索引的。也就是说如果你把索引从idx_user_time改成了另一个定义但idx_user_time还在那么查询时优化器可能根据统计信息选择旧索引新索引依然不被使用。所以每次改了索引我建议手动清理db.orders.dropIndex(idx_user_time)再确认db.orders.getIndexes()让集合里只保留真正需要的索引。索引不是越多越好每个索引都会拖慢写入性能。无用的索引早删早好。5.4 一个隐蔽的坑equals 条件和正则条件混用在 Spring Data 里如果某个查询条件用了Regex比如按订单号模糊查询Criteria.where(orderNo).regex(^ORD2024)这种非精确匹配在复合索引里的表现取决于正则是否锚定前缀。如果正则没有前缀锚定MongoDB 基本无法用索引加速。同样如果查询条件里用了$or$or的每个分支需要分别命中索引才能整体高效。这些都会让人觉得“索引不起作用”其实是查询操作符本身限制了索引发挥空间。5.5 关于索引统计信息的维护MongoDB 的查询优化器会基于索引统计信息选择执行计划。如果数据出现剧烈变化比如某字段的值分布发生了很大变化旧的统计信息会让优化器选错索引。这时候可以用db.orders.aggregate([{ $indexStats: {} }])查看每个索引的被使用情况如果某个索引长期没有命中记录就要考虑删除或者调整。6. 经验总结与几个值得记住的实操技巧6.1 彩蛋技巧启动时主动校验索引为了不让“注解不生效”的问题拖到线上才发现我现在的做法是在应用启动后的一个ApplicationRunner里主动校验关键集合的索引是否存在Component public class IndexValidationRunner implements ApplicationRunner { Autowired private MongoTemplate mongoTemplate; Override public void run(ApplicationArguments args) { String collectionName orders; Document requiredIndex new Document(userId, 1).append(status, 1).append(createTime, -1); ListIndexesIterableDocument indexes mongoTemplate.getCollection(collectionName).listIndexes(); boolean found false; for (Document index : indexes) { if (requiredIndex.equals(index.get(key))) { found true; break; } } if (!found) { throw new IllegalStateException(关键索引缺失请检查 CompoundIndexes 配置); } } }这样一旦索引没建成功应用启动阶段就会直接报错而不是等到查询变慢了才发现。6.2 给新手的五条建议第一def字符串里的字段名先查一遍 MongoDB 里文档的真实 key不要想当然用 Java 属性名。第二索引字段顺序以查询条件里的等值字段优先范围字段放后面。第三排序方向写进索引定义里查询排序保持和索引一致。第四修改索引后手动删除旧索引避免优化器做出错误选择。第五开启应用日志观察启动时有没有Ensuring index日志这是第一个信号。6.3 扩展思考除了复合索引查询性能还能怎么优化如果确认复合索引已经生效但查询仍然不够快可以从这几个方向继续优化。第一是数据模型设计是否可以把经常一起查的大字段拆出去减少单个文档的体积。第二是分页方式深分页用skip会越来越慢改用基于游标或_id分页会好很多。第三是定期使用db.collection.compact()或者重建索引解决索引碎片问题。第四是考虑引入读写分离把读请求分散到从节点。核心思想仍然是先确认索引真正用上了再去考虑其他优化手段。根据我个人经验CompoundIndexes本身并不复杂复杂的是它背后“索引何时生效、查询如何命中”的规则。只要把字段名、字段顺序、查询条件这三件事对齐绝大多数问题都能在一小时之内搞定。你要是按上面的步骤排查完还是没生效那基本可以看看是不是 MongoDB 版本和 Spring Data 版本之间的兼容性问题升级驱动或者调整注解写法多半就能解决。