1. 为什么这套组合值得单独拎出来讲做业务系统做久了总会被一类数据反复折腾字段天天变、层级深浅不一、量还特别大。前两年我接手的一个内容采集与归档模块就踩在这条线上关系库里一张表加了三四十个可空列查询条件全靠动态拼接改一次需求就得跑一次 DDL测试环境的生产数据还得停下来迁移。那个阶段我最深的体会是不是关系库不好用而是用它来装结构不确定的数据成本会以肉眼可见的速度膨胀。后来把这块数据整体挪到文档型存储里配合SpringBoot 整合 MongoDB这套组合表结构焦虑直接消失改需求从改表改实体改 SQL变成改实体就行。这篇东西就围绕这件事展开讲清楚 SpringBoot 怎么把 MongoDB 接进来、怎么用得稳、哪些坑我实打实踩过。先把受众说清楚如果你已经会写 Spring Boot 的 Controller 和 Service但对 MongoDB 只停留在听说过这篇能让你在一个下午内跑通增删改查如果你已经在项目里用了但一直被时区、序列化、慢查询、连接池这些细节绊住那第 4 章和第 5 章大概更对症。全文不涉及任何特定公司的内部框架纯 Spring 官方生态的用法拿过去就能往自己的工程里塞。需要提前打个预防针MongoDB 不是不要钱的 MySQL 替代品。它擅长的是读多写多、结构灵活、按文档整体读写的场景比如内容详情、日志与埋点、商品扩展属性、会话与配置。如果你的业务是强关联、多表 join、跨表事务一致性要求极高那关系库仍然是更省心的选择。选型阶段想清楚这件事比后面写多少代码都重要。2. 整合方案选型三条路为什么我选了 starter2.1 先把可选项摆出来对比SpringBoot 接 MongoDB市面上通行的是三条路我把它们放在一张表里方便你按自己的工程情况对号入座。方案依赖上手成本灵活度适用场景Spring Data MongoDB Starterspring-boot-starter-data-mongodb低中高绝大多数业务项目CRUD 简单聚合原生 Mongo Java Drivermongodb-driver-sync/mongodb-driver-reactivestreams中最高需要精细控制 BSON、批量、游标生命周期响应式 Starterspring-boot-starter-data-mongodb-reactive中高高全链路 WebFlux、高并发 IO 密集我选第一条路的原因很朴素项目里 90% 的数据库操作是按条件查一条按条件分页查按 ID 更新几个字段这些用MongoRepository一行方法签名就能解决剩下 10% 的复杂查询用MongoTemplate兜底两条腿走路刚刚好。原生 Driver 我也用过比如做大批量数据搬迁时需要手动控制FindIterable的batchSize和游标关闭时机那种场景下确实更顺手但如果只是为了日常业务查询就上原生 Driver等于放着自动配置不用自己去管连接和序列化属于给自己找活干。提示响应式那套请谨慎选择。它要求你的整条调用链都是非阻塞的中间只要混进一个 JDBC 调用或者Thread.sleep响应式的优势立刻归零反而多了一层调试难度。团队里没人熟悉 Reactor 的情况下别为了看起来先进而上。2.2 版本对齐这件小事翻车率最高我见过最多的启动失败不是代码写错而是Spring Boot 版本和 Spring Data MongoDB 版本没对齐。Starter 的版本应该跟着 Spring Boot 的父 POM 走不要自己去 lockspring-data-mongodb的版本号。父 POM 里已经做过兼容性验证手动覆盖等于把人家验过的东西拆了。具体来说如果你用spring-boot-starter-parent那么 MongoDB 相关依赖全部不写version如果你是用dependencyManagement导入 BOM 的方式那导入的必须是同一版本的spring-boot-dependencies。我遇到过一种很隐蔽的情况项目里同时存在两个 BOM一个是老版本一个是新版本Maven 的就近原则让某些包取了老版本结果MongoTemplate里某个方法签名对不上编译期报一个看起来毫不相干的错。排查办法很土但有效mvn dependency:tree -Dincludesorg.springframework.data看一眼实际生效的版本比瞎猜快得多。还有 Java 版本。Spring Boot 3.x 要求 JDK 17 起步javax.*全面换成jakarta.*如果你从 2.x 往 3.x 迁移Document、Id这些注解的包路径没变但配置类的写法、MongoClientSettings的构造方式可能变别看着教程是 2.x 的就照抄。2.3 依赖到底该引几个最小可用集合就一个dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-data-mongodb/artifactId /dependency它会传递引入mongodb-driver-sync、spring-data-mongodb、以及 Spring Boot 的自动配置。下面这几个是可选的看情况加spring-boot-starter-validation实体上要写NotBlank之类校验注解时加。spring-boot-starter-actuator要看健康检查里 MongoDB 的连接状态时加/actuator/health会带一个mongo节点。lombok纯为了少写 getter/setter跟 MongoDB 无关但实体类字段一多就很香。不需要加的是各种mongo 工具包比如某些封装了分页和条件构造的第三方库。理由很简单Spring Data 自带的Query、Criteria、Update已经够用多引一层封装出问题时你得多翻一份文档而且这类库的更新节奏往往跟不上 Spring Boot 的大版本。3. 环境与配置地基打不平后面全是玄学3.1 先把一个能连上的实例跑起来本地开发我不建议装客户端套件直接用容器最省事。一条命令起来端口映射到 27017数据卷挂到本地目录这样容器删了数据还在docker run -d \ --name mongo-dev \ -p 27017:27017 \ -e MONGO_INITDB_ROOT_USERNAMEappuser \ -e MONGO_INITDB_ROOT_PASSWORDapppass \ -e MONGO_INITDB_DATABASEcontent_db \ -v /data/mongo-dev:/data/db \ mongo:6.0这里有几个细节值得说。第一MONGO_INITDB_ROOT_USERNAME创建的账号是建在admin库上的所以后面连接串里必须带authSourceadmin否则会认证失败——这是新手最常卡住的地方报错信息是Authentication failed但很多人第一反应是密码打错了来回改半天。第二MONGO_INITDB_DATABASE只是在初始化时创建一个库并不会自动建用户别指望它帮你把业务账号配好。第三数据卷一定要挂不然docker rm之后你辛苦造的数据全没了重来一遍很烦。注意如果你后面要用事务第 5 章会讲单节点实例是不行的必须起副本集。本地验证功能时单节点够用真要用Transactional就得改成--replSet rs0再执行rs.initiate()这一步别到上线前才发现。3.2 application.yml 里每一行都在干什么配置是最容易抄错的地方我逐行拆一遍。spring: data: mongodb: uri: mongodb://appuser:apppass127.0.0.1:27017/content_db?authSourceadmin auto-index-creation: true uuid-representation: standarduri这一行里塞了不少东西拆开看appuser:apppass是认证信息127.0.0.1:27017是地址端口content_db是默认库注意这个库在写入第一条数据前是不存在的MongoDB 会惰性创建authSourceadmin指定去哪个库校验账号这个参数漏了就是认证失败的头号原因。auto-index-creation这个开关需要重点提醒。Spring Data 支持在实体上打Indexed和CompoundIndex但从 2.2 版本开始这个特性默认是关闭的也就是说你打了注解但不加这行配置索引根本不会建。很多人以为自己写了注解就万事大吉结果上线后查询慢得离谱一查db.xxx.getIndexes()发现只有_id一个索引。生产环境我一般把它关掉改成走脚本或迁移工具显式建索引因为应用启动时建索引在大集合上可能锁很久而且多实例同时启动会互相打架。uuid-representation只在用 UUID 做主键时才需要默认是javaLegacy跨语言系统互相读数据时容易对不上建议显式设成standard。如果要精细控制连接池uri里也能带参数比如maxPoolSize50minPoolSize5connectTimeoutMS3000socketTimeoutMS10000。这组值的含义是连接池最多 50 个连接、最少保活 5 个、建连超时 3 秒、单次读写超时 10 秒。超时不要设太大秒级失败快速重试比挂在那里干等要好尤其是接口链路里一个 60 秒的超时足以把上游线程池拖干。3.3 什么时候该自己写配置类自动配置能覆盖八成场景剩下两成需要自定义MongoClientSettings比如要注册自定义编解码器、要开 SSL、要配读写关注ReadConcern/WriteConcern。这时候写一个Configuration类注入MongoProperties然后构造MongoClientSettings再交给MongoTemplate和MongoDatabaseFactory。我个人的原则是能用配置项解决的绝不写配置类。配置类一旦写下去Spring Boot 大版本升级时你就得跟着改而配置文件里的键名相对稳定得多。另一个常见需求是慢查询日志。MongoDB 驱动支持命令监听器可以拦截所有发往服务端的命令把耗时超过阈值的记录下来。这个功能排查问题时非常好用但它对性能有轻微影响生产环境要评估。我一般只在预发环境开生产靠 MongoDB 服务端的profiler和慢日志。4. 实体映射与数据访问层写起来简单写好不简单4.1 Document 系列注解的正确打开方式先看一个我实际在用的实体字段做了脱敏处理Document(collection article) CompoundIndex(name idx_cate_status_time, def {category: 1, status: 1, publishTime: -1}) public class ArticleDoc { Id private String id; Field(title) private String title; Field(category) private String category; Field(status) private Integer status; Field(tags) private ListString tags; Field(ext) private MapString, Object ext; Field(publish_time) private LocalDateTime publishTime; Field(create_time) private LocalDateTime createTime; }几个关键点。第一Id字段类型用String最省心。MongoDB 默认主键是ObjectId驱动会自动把它映射成 32 位十六进制字符串如果你声明成ObjectId类型那前端传参、JSON 序列化、日志打印全得跟着处理麻烦。用String的好处是业务层完全感知不到ObjectId的存在坏处是拿不到ObjectId里内嵌的时间戳信息不过这个信息一般也用不上。第二Field的作用是显式指定 BSON 里的字段名。要是不写默认按 Java 字段名走publishTime在库里就是publishTime。我的习惯是统一用下划线风格跟关系库的命名习惯保持一致以后做数据同步时少一层心智负担。如果整个项目都想统一命名策略可以配FieldNamingStrategy比一个个打注解省事。第三CompoundIndex定义的复合索引要跟查询模式对齐。上面这个索引是为按分类 状态筛按发布时间倒序这个高频查询建的。索引字段顺序极其重要{category:1, status:1, publishTime:-1}能覆盖category 等值 status 等值 publishTime 范围/排序但如果你反过来写成{publishTime:-1, category:1}那按分类筛的查询就用不上它了。这个道理跟关系库的联合索引完全一致只不过 MongoDB 的explain输出更花哨一点。第四MapString, Object这种字段是 MongoDB 的杀手锏。业务上那些每个品类扩展属性都不一样的需求塞进ext里就完事不用加列。代价是查询时如果要对ext里的某个键建索引得用点号路径比如ext.color而且这个键名一旦确定就不好改了。4.2 Repository 和 Template 的边界在哪先说结论简单查询用 Repository复杂查询用 Template两者共存没问题。MongoRepositoryT, ID提供的方法签名已经覆盖了大部分日常操作public interface ArticleRepository extends MongoRepositoryArticleDoc, String { ListArticleDoc findByCategoryAndStatus(String category, Integer status); PageArticleDoc findByStatus(Integer status, Pageable pageable); long countByCategory(String category); Query({ tags: { $in: ?0 } }) ListArticleDoc findByTagsIn(ListString tags); }方法名推导规则跟 JPA 那套很像And、Or、In、GreaterThan、OrderBy都支持能覆盖大部分单表条件。Query注解里写的是原生 JSON 查询语法不是 SQL别写串了。那什么时候必须上MongoTemplate我总结了三类需要动态拼条件查询参数来自前端条件是可选组合的用Criteria拼比写一堆if-else的方法名分支干净得多。需要部分更新只更新某几个字段不想把整个文档读出来再写回去读改写有并发覆盖风险这时候用UpdateupdateFirst。需要聚合Aggregation管道只能走 Template。一个实战建议别在 Repository 接口里塞几十个方法。我见过一个接口写了四十多个findBy...改个字段名要全文替换而且方法名一长可读性直接崩。超过五个派生方法就该考虑把复杂查询挪到一个专门的XxxQueryRepository里内部用MongoTemplate实现。4.3 Criteria 的写法与几个反直觉点动态条件构造是MongoTemplate最常用的场景我贴一段真实在用的代码public PageArticleDoc search(String category, Integer status, String keyword, LocalDateTime start, LocalDateTime end, int page, int size) { ListCriteria conditions new ArrayList(); if (StringUtils.hasText(category)) { conditions.add(Criteria.where(category).is(category)); } if (status ! null) { conditions.add(Criteria.where(status).is(status)); } if (StringUtils.hasText(keyword)) { conditions.add(Criteria.where(title).regex(Pattern.compile( Pattern.quote(keyword), Pattern.CASE_INSENSITIVE))); } if (start ! null end ! null) { conditions.add(Criteria.where(publish_time).gte(start).lt(end)); } Criteria criteria conditions.isEmpty() ? new Criteria() : new Criteria().andOperator(conditions.toArray(new Criteria[0])); Query query new Query(criteria) .with(Sort.by(Sort.Direction.DESC, publish_time)) .skip((long) page * size) .limit(size); long total mongoTemplate.count(query, ArticleDoc.class); ListArticleDoc list mongoTemplate.find(query, ArticleDoc.class); return new PageImpl(list, PageRequest.of(page, size), total); }这段代码里有三个点值得单独说。第一个是andOperator。如果你用new Criteria().and(a).is(1).and(b).is(2)这种链式写法当条件为空时它会报错或者行为异常而andOperator接收一个条件数组空数组的处理也更可控。我现在的习惯是先把所有非空条件收进 List最后统一用andOperator拼逻辑上最不容易出错。第二个是正则。Pattern.quote这一层包装很多人会漏掉如果用户输入的 keyword 里带.*(这类正则元字符不转义会直接抛异常或者匹配到意料之外的结果。另外要注意不带前缀锚定的正则查询用不上索引title字段上的$regex如果是.*keyword.*这种全表扫描跑不掉。数据量大的话老实用全文索引或者搜索引擎别指望 MongoDB 正则能扛住。第三个是count。分页接口里每次都调count是个隐性成本集合大了之后 count 本身就要扫一遍。如果前端能接受不显示总数或者用是否有下一页的方式翻页那就把 count 省了。我做过压测一个两百万文档的集合count大概占总耗时的三到四成去掉之后接口 P99 直接降了一截。4.4 索引上线前必须过一遍的清单索引这件事我的经验是上线前必查别等慢查询报警。检查方式很直接连上库执行db.article.getIndexes()看输出里有没有你预期的那几个。没有的话用explain看查询计划db.article.find({category: tech, status: 1}) .sort({publish_time: -1}) .explain(executionStats)重点看两个指标totalDocsExamined和nReturned。理想情况下这两个数应该接近 1:1如果你的查询返回 20 条却扫了 20 万条文档那就是没走索引或者索引不匹配。stage字段显示COLLSCAN表示全集合扫描IXSCAN才是走索引。建索引的时机也有讲究。数据量小的时候建索引很快但生产环境的大集合建索引可能耗时几十分钟而且会占用大量内存和 IO。MongoDB 4.2 之后支持后台建索引默认行为但依然建议在低峰期做并且提前在预发环境拿相同量级的数据试一遍心里有个时间预期。另外索引不是越多越好每个索引都要在写入时维护写多的集合上索引数量控制在 5 个以内比较稳妥。提示CompoundIndex里字段的排序方向1 或 -1对等值 排序的组合查询有影响但对纯等值查询基本没影响。而且 MongoDB 支持反向扫描索引所以{a:1, b:-1}也能服务{a:1, b:1}的排序需求这一点比某些关系库灵活。5. 核心功能实操从 CRUD 到聚合到事务5.1 增删改查的完整走法新增有两种写法语义不同。save()是有 ID 就更新、没 ID 就插入insert()是只插入ID 重复直接报错。业务里我倾向于用save()因为很多场景是从前端带着 ID 过来的用insert得先判断一遍。但要注意save()的更新是整文档替换——你传进去的对象如果有些字段是 null这些字段会被覆盖成 null 吗答案是Spring Data 默认只写非 null 字段null 字段会被忽略。这个行为可以通过Field(write Field.Write.NON_NULL)之类的策略调整不同版本默认值略有差异涉及把字段置空的需求时一定要实测别想当然。部分更新的写法是这样的public boolean updateStatus(String id, Integer status) { Query query Query.query(Criteria.where(_id).is(id)); Update update new Update() .set(status, status) .set(update_time, LocalDateTime.now()) .inc(version, 1); UpdateResult result mongoTemplate.updateFirst(query, update, ArticleDoc.class); return result.getModifiedCount() 0; }set是设值inc是自增unset是删字段push是往数组里追加addToSet是数组去重追加pull是从数组移除。这几个操作符组合起来能覆盖绝大多数只改局部的需求而且因为是服务端原子操作不存在读改写导致的并发覆盖问题。那个version字段是我加的乐观锁痕迹配合Query里的版本条件就能实现 CAS 式更新后面会细说。删除方面deleteById和delete(entity)是 Repository 提供的remove是 Template 的。这里有个坑remove的返回值是DeleteResult里面的getDeletedCount()才是真正删掉的条数别拿getClass()之类的东西去判断成功与否。删之前最好先count一下或者干脆用软删除加个deleted标记字段因为 MongoDB 的删除不可回滚误删就是真没了。5.2 分页与排序skip 的坑比想象中大skip limit是分页的标准写法但它有个致命问题skip 的代价随偏移量线性增长。翻到第一万页时MongoDB 要先扫描并丢弃前面十万条文档耗时可能是第一页的几十倍。这个问题在关系库里也存在只是 MongoDB 的文档通常更大放大效应更明显。解决办法是基于游标的分页也叫上一页最后一条的排序值作为下一页的起点public ListArticleDoc nextPage(LocalDateTime lastTime, String lastId, int size) { Criteria criteria new Criteria().orOperator( Criteria.where(publish_time).lt(lastTime), new Criteria().andOperator( Criteria.where(publish_time).is(lastTime), Criteria.where(_id).lt(lastId)) ); Query query new Query(criteria) .with(Sort.by(Sort.Direction.DESC, publish_time) .and(Sort.by(Sort.Direction.DESC, _id))) .limit(size); return mongoTemplate.find(query, ArticleDoc.class); }为什么需要_id作为第二排序键因为publish_time可能重复重复时排序不稳定会导致翻页时漏数据或重复数据。加上唯一键做二次排序游标才是严格单调的。这套写法适合下拉加载更多这类无限滚动不适合跳到第 37 页这种随机跳页后者老老实实用 skip但要把页数限制住比如最多允许翻到 100 页。排序还有一个细节排序字段必须有索引否则内存排序有 32MB 上限。超过这个上限MongoDB 直接报错Sort operation used more than the maximum 33554432 bytes of RAM。遇到这个错要么加索引要么在聚合管道里显式指定allowDiskUse(true)让排序落盘。前者治本后者救急。5.3 聚合管道MongoDB 真正拉开差距的地方聚合是我认为 MongoDB 最值得学的部分。它本质是一个分阶段的数据流管道每个阶段处理上一阶段的输出。业务里最常见的场景是按某个维度分组统计比如统计每个分类下已发布文章的数量和平均字数public ListMap statsByCategory() { Aggregation agg Aggregation.newAggregation( Aggregation.match(Criteria.where(status).is(1)), Aggregation.group(category) .count().as(total) .avg(wordCount).as(avgWords) .max(publish_time).as(latest), Aggregation.sort(Sort.Direction.DESC, total), Aggregation.limit(20) ); return mongoTemplate.aggregate(agg, article, Map.class).getMappedResults(); }这段对应的 JSON 管道是$match → $group → $sort → $limit。有几个实践要点。$match一定要放最前面。MongoDB 的查询优化器虽然会尝试把$match前移但涉及$group之后的$match也就是 HAVING 语义是没法前移的能提前过滤的数据一定要提前过滤这直接决定了扫描量。$lookup相当于左连接可以把另一个集合的数据关联进来但它不是免费的尤其是在被关联字段没有索引的时候性能会非常难看。我的经验是能用冗余字段解决的就别用$lookup。MongoDB 的设计哲学本来就是数据冗余换查询效率把常用的少数字段冗余到主文档里比每次关联查一遍要快得多。真的需要关联确保外键字段上有索引。$unwind用来把数组字段拆成多行。这个操作会放大数据量如果数组里有一百个元素一条文档就变成一百条要小心内存。配合$match使用时要考虑清楚是先拆再筛还是先筛再拆一般情况下先筛再拆效率更高。结果映射方面aggregate(agg, article, Map.class)是最省事的写法返回Map列表。如果字段类型明确建议定义一个 DTO 类用OutputType指定避免到处做强转。类型不匹配是聚合里最常见的运行时错误尤其是日期字段返回的可能是Date也可能是BsonDateTime具体取决于驱动版本。5.4 事务能不用就别用要用就搞清楚前提Spring 的Transactional在 MongoDB 上是支持的但有三个硬性前提必须是副本集或分片集群、必须是 MongoDB 4.0 以上、事务内的集合必须提前存在4.4 之后支持隐式创建但老版本不行。配置上Spring Boot 2.2 之后会自动配置MongoTransactionManager你只要在启动类或者配置类上加EnableTransactionManagement方法上打Transactional就能用。写法和关系库一模一样Service public class ArticleService { Transactional(rollbackFor Exception.class) public void publish(String id, String operator) { articleRepository.updateStatus(id, 1); auditLogRepository.insert(new AuditLog(id, operator, PUBLISH)); // 任意一步抛异常整体回滚 } }但我得说实话我在实际项目里很少用 MongoDB 事务。原因有两个。一是它的事务开销比单文档操作大不少延迟明显上升二是大部分业务场景其实可以通过单文档原子更新解决。比如扣库存 记流水把库存和流水放在同一个文档里用$inc一次更新完天然原子根本不需要事务。设计阶段多想想能不能把强关联的数据收进同一个文档比事后加事务优雅得多。真要用事务有几个坑得记住。第一事务内不要做耗时操作MongoDB 的事务有默认 60 秒的超时限制超时自动中止。第二事务内读到的数据在提交前对其他会话不可见这跟关系库的隔离级别行为类似别指望用事务做先读后判的跨请求锁。第三事务和WriteConcern有交互w:majority下事务提交会更慢但更安全具体取舍看业务对一致性的要求。6. 踩坑实录与排查速查表6.1 连接类问题八成是配置两成是网络连接问题的表现形式高度集中我把遇到过的整理成一张表出问题时按图索骥比瞎改快。报错关键字常见原因处理方式Authentication failed缺authSource/ 账号建在别的库URI 加authSourceadmin确认账号所属库Connection refused容器没起 / 端口没映射 / 防火墙telnet host 27017先探端口No server chosen by ReadPreference副本集选主未完成等选主或检查副本集配置Timed out after 30000ms网络延迟 / 慢查询 / 连接池耗尽查慢日志调maxPoolSizeToo many open connections连接泄漏检查是否手动 new 了 MongoClient 没关重点说两个。Authentication failed我在前文提过但还有一种变体账号密码都对库名也对就是连不上最后发现是密码里有或者:这类特殊字符没做 URL 编码URI 解析时被截断了。解决办法是用URLEncoder.encode处理密码或者干脆换个不含特殊字符的密码。连接池耗尽这个问题更有意思。表面症状是接口偶发超时日志里全是Timed out while waiting for a connection。根因往往是某段代码里手动创建了MongoClient却没关闭每次请求都新建一个客户端连接数很快打满。MongoDB 的MongoClient是线程安全且应该全局单例的交给 Spring 管理就行绝对不要在方法里new MongoClient(...)。6.2 序列化问题字段名对不上、类型对不上序列化的问题通常表现为数据写进去了但读出来是 null。排查顺序是这样的先用命令行db.article.findOne()看库里的真实字段名再对照实体的Field注解。如果库里是publish_time而实体上没打注解那读出来必然是 null因为 Spring Data 按字段名精确匹配。时间类型是重灾区。LocalDateTime存进 MongoDB 会转成 UTC 的Date读出来再转回本地时间如果服务器时区配置不一致就会出现存进去是 10 点取出来是 2 点的诡异现象。我的做法是全面统一应用侧统一用LocalDateTimeJVM 启动参数加-Duser.timezoneAsia/ShanghaiMongoDB 侧不做任何时区转换接受库里存的是 UTC 时间这个事实展示层再转。千万别一会儿用Date一会儿用LocalDateTime混用必然出错。BigDecimal的映射在不同版本间有过变化早些年默认按字符串存后来支持Decimal128。如果你的金额字段读出来精度丢了先查库里的实际类型再决定是加自定义Converter还是统一用Decimal128。金额这种字段我倾向于在实体上显式声明转换器别依赖默认行为。还有一个隐蔽问题集合名大小写。Document(collection Article)和Document(collection article)在 MongoDB 里是两个不同的集合Linux 环境下尤其容易出问题因为 Windows 的文件系统不区分大小写本地跑得好好的上服务器就找不到数据。统一全小写别给自己挖坑。6.3 性能问题从慢到快的几条实操路径性能问题我按见效速度排序给你一条路径。第一步看慢日志。MongoDB 的db.setProfilingLevel(1, {slowms: 100})会把超过 100ms 的操作记到system.profile集合里db.system.profile.find().sort({ts:-1}).limit(10)就能看到最近的慢操作。这是最直接的抓手比猜快一百倍。第二步对慢查询跑explain看是不是COLLSCAN。是的话补索引补完再跑一遍确认变成IXSCAN。这一步能解决七八成的性能问题。第三步如果索引也有了还是慢看返回的数据量。有时候不是查询慢是传输慢——一次查回来几千条完整文档网络和反序列化都吃不消。解决办法是用投影只取需要的字段Query query new Query(criteria) .with(Sort.by(Sort.Direction.DESC, publish_time)) .limit(20); query.fields().include(title, publish_time).exclude(_id);第四步考虑数据规模。单集合几千万文档时即使有索引随机 IO 也会让延迟上去。这时候要么做归档把冷数据挪到历史集合要么上分片。分片是个大工程分片键选错了比不分片还惨选之前一定要想清楚查询模式。6.4 几个文档里不会写的实操心得最后分享几条我在实际项目里攒下来的经验都是文档里找不到、但确实能省时间的。批量插入记得分批。insertAll传十万条会撑爆内存也会让单次请求过大。我一般按 1000 到 5000 条一批分批提交同时用BulkOperations的UNORDERED模式让单条失败不影响其他条。更新时注意modifiedCount和matchedCount的区别。matchedCount是匹配到的条数modifiedCount是实际发生变化的条数。如果你把状态从 1 改成 1matchedCount是 1 但modifiedCount是 0用后者判断成功会误判。业务里判断是否存在并更新成功用matchedCount更靠谱。开发环境别用生产库的账号。MongoDB 没有表级别的权限一个账号能操作整个库开发时手滑db.article.drop()就是灾难。给开发环境单独建库、单独建账号权限只给到那个库。写操作尽量设计成幂等。网络抖动时客户端可能重试非幂等的$inc会导致重复累加。解决办法是带一个唯一的请求 ID 字段用addToSet记录已处理的 ID或者用版本号做 CAS。这块多花十分钟设计能省掉后面几小时的排查。定期看一眼集合的存储占用。db.article.stats()能看到size数据大小和storageSize磁盘占用两者差距大说明碎片多可以考虑compact命令整理。不过compact会阻塞写操作必须挑时间窗口做。我在实际使用中的体会是SpringBoot 整合 MongoDB 的代码量确实少一个 Starter 依赖加几行配置就能跑起来但能跑和跑得稳之间隔着索引设计、连接池调优、序列化对齐这几道坎。这几道坎都迈过去之后它在结构灵活、读写吞吐这两件事上的优势才会真正显现出来。如果你的项目正好卡在字段总在变这个痛点上那这套组合值得花一个下午认真过一遍。