ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

SpringBoot整合MongoDB实战:配置、索引、聚合与事务避坑指南

SpringBoot整合MongoDB实战:配置、索引、聚合与事务避坑指南

1. 为什么这套组合值得单独拎出来讲

做业务系统做久了,总会被一类数据反复折腾:字段天天变、层级深浅不一、量还特别大。前两年我接手的一个内容采集与归档模块就踩在这条线上,关系库里一张表加了三四十个可空列,查询条件全靠动态拼接,改一次需求就得跑一次 DDL,测试环境的生产数据还得停下来迁移。那个阶段我最深的体会是,不是关系库不好用,而是用它来装"结构不确定"的数据,成本会以肉眼可见的速度膨胀。后来把这块数据整体挪到文档型存储里,配合SpringBoot 整合 MongoDB这套组合,表结构焦虑直接消失,改需求从"改表+改实体+改 SQL"变成"改实体就行"。这篇东西就围绕这件事展开,讲清楚 SpringBoot 怎么把 MongoDB 接进来、怎么用得稳、哪些坑我实打实踩过。

先把受众说清楚:如果你已经会写 Spring Boot 的 Controller 和 Service,但对 MongoDB 只停留在"听说过",这篇能让你在一个下午内跑通增删改查;如果你已经在项目里用了,但一直被时区、序列化、慢查询、连接池这些细节绊住,那第 4 章和第 5 章大概更对症。全文不涉及任何特定公司的内部框架,纯 Spring 官方生态的用法,拿过去就能往自己的工程里塞。

需要提前打个预防针:MongoDB 不是"不要钱的 MySQL 替代品"。它擅长的是读多写多、结构灵活、按文档整体读写的场景,比如内容详情、日志与埋点、商品扩展属性、会话与配置。如果你的业务是强关联、多表 join、跨表事务一致性要求极高,那关系库仍然是更省心的选择。选型阶段想清楚这件事,比后面写多少代码都重要。

2. 整合方案选型:三条路为什么我选了 starter

2.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 -Dincludes=org.springframework.data看一眼实际生效的版本,比瞎猜快得多。

还有 Java 版本。Spring Boot 3.x 要求 JDK 17 起步,javax.*全面换成jakarta.*,如果你从 2.x 往 3.x 迁移,@Document、@Id这些注解的包路径没变,但配置类的写法、MongoClientSettings的构造方式可能变,别看着教程是 2.x 的就照抄。

2.3 依赖到底该引几个

最小可用集合就一个:

<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-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_USERNAME=appuser \ -e MONGO_INITDB_ROOT_PASSWORD=apppass \ -e MONGO_INITDB_DATABASE=content_db \ -v /data/mongo-dev:/data/db \ mongo:6.0

这里有几个细节值得说。第一,MONGO_INITDB_ROOT_USERNAME创建的账号是建在admin库上的,所以后面连接串里必须带authSource=admin,否则会认证失败——这是新手最常卡住的地方,报错信息是Authentication failed,但很多人第一反应是密码打错了,来回改半天。第二,MONGO_INITDB_DATABASE只是在初始化时创建一个库,并不会自动建用户,别指望它帮你把业务账号配好。第三,数据卷一定要挂,不然docker rm之后你辛苦造的数据全没了,重来一遍很烦。

注意:如果你后面要用事务(第 5 章会讲),单节点实例是不行的,必须起副本集。本地验证功能时单节点够用,真要用@Transactional就得改成--replSet rs0再执行rs.initiate(),这一步别到上线前才发现。

3.2 application.yml 里每一行都在干什么

配置是最容易抄错的地方,我逐行拆一遍。

spring: data: mongodb: uri: mongodb://appuser:apppass@127.0.0.1:27017/content_db?authSource=admin auto-index-creation: true uuid-representation: standard

uri这一行里塞了不少东西,拆开看:appuser:apppass是认证信息;127.0.0.1:27017是地址端口;content_db是默认库,注意这个库在写入第一条数据前是不存在的,MongoDB 会惰性创建;authSource=admin指定去哪个库校验账号,这个参数漏了就是认证失败的头号原因。

auto-index-creation这个开关需要重点提醒。Spring Data 支持在实体上打@Indexed和@CompoundIndex,但从 2.2 版本开始这个特性默认是关闭的,也就是说你打了注解但不加这行配置,索引根本不会建。很多人以为自己写了注解就万事大吉,结果上线后查询慢得离谱,一查db.xxx.getIndexes()发现只有_id一个索引。生产环境我一般把它关掉,改成走脚本或迁移工具显式建索引,因为应用启动时建索引在大集合上可能锁很久,而且多实例同时启动会互相打架。

uuid-representation只在用 UUID 做主键时才需要,默认是javaLegacy,跨语言系统互相读数据时容易对不上,建议显式设成standard。

如果要精细控制连接池,uri里也能带参数,比如maxPoolSize=50&minPoolSize=5&connectTimeoutMS=3000&socketTimeoutMS=10000。这组值的含义是:连接池最多 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 List<String> tags; @Field("ext") private Map<String, 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输出更花哨一点。

第四,Map<String, Object>这种字段是 MongoDB 的杀手锏。业务上那些"每个品类扩展属性都不一样"的需求,塞进ext里就完事,不用加列。代价是查询时如果要对ext里的某个键建索引,得用点号路径,比如ext.color,而且这个键名一旦确定就不好改了。

4.2 Repository 和 Template 的边界在哪

先说结论:简单查询用 Repository,复杂查询用 Template,两者共存没问题。

MongoRepository<T, ID>提供的方法签名已经覆盖了大部分日常操作:

public interface ArticleRepository extends MongoRepository<ArticleDoc, String> { List<ArticleDoc> findByCategoryAndStatus(String category, Integer status); Page<ArticleDoc> findByStatus(Integer status, Pageable pageable); long countByCategory(String category); @Query("{ 'tags': { $in: ?0 } }") List<ArticleDoc> findByTagsIn(List<String> tags); }

方法名推导规则跟 JPA 那套很像,And、Or、In、GreaterThan、OrderBy都支持,能覆盖大部分单表条件。@Query注解里写的是原生 JSON 查询语法,不是 SQL,别写串了。

那什么时候必须上MongoTemplate?我总结了三类:

  • 需要动态拼条件:查询参数来自前端,条件是可选组合的,用Criteria拼比写一堆if-else的方法名分支干净得多。
  • 需要部分更新:只更新某几个字段,不想把整个文档读出来再写回去(读改写有并发覆盖风险),这时候用Update+updateFirst。
  • 需要聚合:Aggregation管道只能走 Template。

一个实战建议:别在 Repository 接口里塞几十个方法。我见过一个接口写了四十多个findBy...,改个字段名要全文替换,而且方法名一长,可读性直接崩。超过五个派生方法,就该考虑把复杂查询挪到一个专门的XxxQueryRepository里,内部用MongoTemplate实现。

4.3 Criteria 的写法与几个反直觉点

动态条件构造是MongoTemplate最常用的场景,我贴一段真实在用的代码:

public Page<ArticleDoc> search(String category, Integer status, String keyword, LocalDateTime start, LocalDateTime end, int page, int size) { List<Criteria> 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); List<ArticleDoc> 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 List<ArticleDoc> 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 List<Map> 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 加authSource=admin,确认账号所属库
Connection refused容器没起 / 端口没映射 / 防火墙telnet host 27017先探端口
No server chosen by ReadPreference副本集选主未完成等选主,或检查副本集配置
Timed out after 30000ms网络延迟 / 慢查询 / 连接池耗尽查慢日志,调maxPoolSize
Too 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 点"的诡异现象。我的做法是全面统一:应用侧统一用LocalDateTime,JVM 启动参数加-Duser.timezone=Asia/Shanghai,MongoDB 侧不做任何时区转换,接受库里存的是 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 改成 1,matchedCount是 1 但modifiedCount是 0,用后者判断成功会误判。业务里判断"是否存在并更新成功",用matchedCount更靠谱。

开发环境别用生产库的账号。MongoDB 没有表级别的权限,一个账号能操作整个库,开发时手滑db.article.drop()就是灾难。给开发环境单独建库、单独建账号,权限只给到那个库。

写操作尽量设计成幂等。网络抖动时客户端可能重试,非幂等的$inc会导致重复累加。解决办法是带一个唯一的请求 ID 字段,用addToSet记录已处理的 ID,或者用版本号做 CAS。这块多花十分钟设计,能省掉后面几小时的排查。

定期看一眼集合的存储占用。db.article.stats()能看到size(数据大小)和storageSize(磁盘占用),两者差距大说明碎片多,可以考虑compact命令整理。不过compact会阻塞写操作,必须挑时间窗口做。

我在实际使用中的体会是:SpringBoot 整合 MongoDB 的代码量确实少,一个 Starter 依赖加几行配置就能跑起来,但"能跑"和"跑得稳"之间隔着索引设计、连接池调优、序列化对齐这几道坎。这几道坎都迈过去之后,它在结构灵活、读写吞吐这两件事上的优势才会真正显现出来。如果你的项目正好卡在"字段总在变"这个痛点上,那这套组合值得花一个下午认真过一遍。

返回列表