概述
前面命令行里敲过的那些 db.collection.find(),到了工程里都要换成 Java 代码。这里解决一个问题:交友项目如何在 Spring Boot 里接入 MongoDB,依赖怎么引、连接怎么配、实体和集合怎么映射,为什么为它单开一个 mf-dubbo-mongo 模块,之后展开用 MongoTemplate 做增删改查,从插入、查询、更新,到删除、批量写入,再到嵌套文档与数组的进阶操作。
接入形态:存储可插拔的域服务
Java 操作 MongoDB 有两条路:直接用官方 mongodb-driver,类似用裸 JDBC 操作 MySQL,代码啰嗦;或者用 Spring Data 家族的 spring-data-mongodb,底层封装好驱动,注入一个 MongoTemplate 就能完成 CRUD。项目选后者,这一篇先搭环境,CRUD 留到后面。
更关键的决策在架构层面:项目没有让应用层(mf-app-server)直连 MongoDB,而是新建了 mf-dubbo-mongo 这个域服务来连,应用层照旧通过 Dubbo 调用。这和 mf-dubbo-db 连 MySQL 的方式完全对称:
#mermaid-svg-ibEh0azSfL1MCWwT{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-ibEh0azSfL1MCWwT .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-ibEh0azSfL1MCWwT .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-ibEh0azSfL1MCWwT .error-icon{fill:#552222;}#mermaid-svg-ibEh0azSfL1MCWwT .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-ibEh0azSfL1MCWwT .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-ibEh0azSfL1MCWwT .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-ibEh0azSfL1MCWwT .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-ibEh0azSfL1MCWwT .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-ibEh0azSfL1MCWwT .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-ibEh0azSfL1MCWwT .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-ibEh0azSfL1MCWwT .marker{fill:#333333;stroke:#333333;}#mermaid-svg-ibEh0azSfL1MCWwT .marker.cross{stroke:#333333;}#mermaid-svg-ibEh0azSfL1MCWwT svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-ibEh0azSfL1MCWwT p{margin:0;}#mermaid-svg-ibEh0azSfL1MCWwT .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-ibEh0azSfL1MCWwT .cluster-label text{fill:#333;}#mermaid-svg-ibEh0azSfL1MCWwT .cluster-label span{color:#333;}#mermaid-svg-ibEh0azSfL1MCWwT .cluster-label span p{background-color:transparent;}#mermaid-svg-ibEh0azSfL1MCWwT .label text,#mermaid-svg-ibEh0azSfL1MCWwT span{fill:#333;color:#333;}#mermaid-svg-ibEh0azSfL1MCWwT .node rect,#mermaid-svg-ibEh0azSfL1MCWwT .node circle,#mermaid-svg-ibEh0azSfL1MCWwT .node ellipse,#mermaid-svg-ibEh0azSfL1MCWwT .node polygon,#mermaid-svg-ibEh0azSfL1MCWwT .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-ibEh0azSfL1MCWwT .rough-node .label text,#mermaid-svg-ibEh0azSfL1MCWwT .node .label text,#mermaid-svg-ibEh0azSfL1MCWwT .image-shape .label,#mermaid-svg-ibEh0azSfL1MCWwT .icon-shape .label{text-anchor:middle;}#mermaid-svg-ibEh0azSfL1MCWwT .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-ibEh0azSfL1MCWwT .rough-node .label,#mermaid-svg-ibEh0azSfL1MCWwT .node .label,#mermaid-svg-ibEh0azSfL1MCWwT .image-shape .label,#mermaid-svg-ibEh0azSfL1MCWwT .icon-shape .label{text-align:center;}#mermaid-svg-ibEh0azSfL1MCWwT .node.clickable{cursor:pointer;}#mermaid-svg-ibEh0azSfL1MCWwT .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-ibEh0azSfL1MCWwT .arrowheadPath{fill:#333333;}#mermaid-svg-ibEh0azSfL1MCWwT .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-ibEh0azSfL1MCWwT .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-ibEh0azSfL1MCWwT .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-ibEh0azSfL1MCWwT .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-ibEh0azSfL1MCWwT .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-ibEh0azSfL1MCWwT .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-ibEh0azSfL1MCWwT .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-ibEh0azSfL1MCWwT .cluster text{fill:#333;}#mermaid-svg-ibEh0azSfL1MCWwT .cluster span{color:#333;}#mermaid-svg-ibEh0azSfL1MCWwT div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-ibEh0azSfL1MCWwT .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-ibEh0azSfL1MCWwT rect.text{fill:none;stroke-width:0;}#mermaid-svg-ibEh0azSfL1MCWwT .icon-shape,#mermaid-svg-ibEh0azSfL1MCWwT .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-ibEh0azSfL1MCWwT .icon-shape p,#mermaid-svg-ibEh0azSfL1MCWwT .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-ibEh0azSfL1MCWwT .icon-shape .label rect,#mermaid-svg-ibEh0azSfL1MCWwT .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-ibEh0azSfL1MCWwT .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-ibEh0azSfL1MCWwT .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-ibEh0azSfL1MCWwT :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} Dubbo RPC
Dubbo RPC
注册
注册
mf-app-server
应用层
mf-dubbo-db
MyBatis-Plus
mf-dubbo-mongo
MongoTemplate
MySQL
MongoDB
Nacos
对称带来的是「存储可插拔」:应用层只认 mf-dubbo-interface 里的接口(比如 RecommendUserApi),至于这个接口的实现背后是 MySQL 还是 MongoDB,应用层不关心。今天「今日佳人」的推荐数据落在 MongoDB,实现挪进 mf-dubbo-mongo;明天某个数据要迁回 MySQL,也只需要换个实现模块,接口和调用方一行不动。
两个域服务的端口也是排好队的:
| 模块 | 服务端口 | Dubbo 端口 | 存储 |
|---|---|---|---|
| mf-dubbo-db | 18081 | 20881 | MySQL |
| mf-dubbo-mongo | 18082 | 20882 | MongoDB |
模块规划
为什么要单开 mf-dubbo-mongo,而不是把 MongoTemplate 塞进已有的 mf-dubbo-db?两个理由:
- 存储边界清晰:db 模块的依赖里是 MyBatis-Plus、MySQL 驱动、druid,混进 Mongo 的依赖后,谁在用哪个存储要靠翻代码才知道。分开后看模块名即知存储选型。
- 互不影响部署:两个域服务是两个独立的 Spring Boot 进程。Mongo 侧代码出问题重启 18082,MySQL 侧的用户登录、黑名单功能照常跑。
模块在工程中的位置(实读自 mf 工程快照):
tree
mf
├── mf-app-server # 应用层:Controller / Service,端口 18080
├── mf-model # 公共实体:MySQL 实体 + Mongo 实体(@Document)
├── mf-commons # 公共工具类
├── mf-autoconfig # 自动配置
└── mf-dubbo
├── mf-dubbo-interface # 对外 API:SettingsApi、RecommendUserApi...
├── mf-dubbo-db # MySQL 域服务(MyBatis-Plus) 18081 / 20881
└── mf-dubbo-mongo # MongoDB 域服务(MongoTemplate) 18082 / 20882
mf-dubbo-mongo 的 pom 依赖清单(实读):
| 依赖 | 作用 |
|---|---|
| spring-boot-starter-web | 启动容器与健康检查 |
| spring-cloud-starter-dubbo | Dubbo 服务暴露与调用 |
| spring-cloud-starter-alibaba-nacos-discovery | 注册到 Nacos |
| mf-dubbo-interface | 实现 API 接口(并传递引入 mf-model → mongo starter) |
| mf-commons | 公共工具 |
| lombok / commons-lang3 / jackson 等 | 基础工具 |
启动类没有任何特殊注解,和 db 模块长得一模一样:
java
package com.mf.dubbo;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication
public class DubboMongoApplication {
public static void main(String[] args) {
SpringApplication.run(DubboMongoApplication.class, args);
}
}
MongoDB 的自动配置全靠 classpath 上的 starter 触发,MongoAutoConfiguration 检测到配置文件里的 spring.data.mongodb.uri,注册好 MongoClient 和 MongoTemplate 两个 Bean,应用代码直接注入就行。
依赖坐标
最小依赖只有一个 starter:
xml
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-mongodb</artifactId>
</dependency>
版本不需要写,项目的父 pom 继承了 spring-boot-starter-parent:2.3.9.RELEASE,Spring Boot 的依赖管理里已经锁定了一批配套版本。真正的驱动 mongodb-driver-sync 就是这个 starter 带进来的。工程里在 mf-model 模块又显式声明了一次驱动:
xml
<dependency>
<groupId>org.mongodb</groupId>
<artifactId>mongodb-driver-sync</artifactId>
<version>${mongodb.version}</version>
</dependency>
这里有个容易看迷糊的细节:根 pom 的 <properties> 里定义的属性叫 mongo.version(值为 4.0.5),而上面引用的却是 ${mongodb.version}。mongodb.version 在自家根 pom 里根本没定义,它实际解析自 Spring Boot parent 自带的同名属性。也就是说这行显式声明「碰巧」和 parent 管理的版本对上了。自己搭工程时别学这个写法,要么不加显式驱动声明,交给 parent 管;要么属性名和引用名保持一致。
另外一个注意点:这个工程里,spring-boot-starter-data-mongodb 声明在 mf-model(公共实体模块)的 pom 中,而不是 mf-dubbo-mongo 直接引。依赖链是:
text
mf-dubbo-mongo
└── mf-dubbo-interface # 对外 API 接口
└── mf-model # 实体:MySQL 实体 + Mongo 实体(@Document 注解需要 spring-data-mongodb 的类)
├── spring-boot-starter-data-mongodb
└── mongodb-driver-sync
实体类上要标 @Document,这个注解来自 spring-data-mongodb,所以实体模块必须引这个 starter,否则编译都过不了。域服务模块通过依赖传递拿到全套能力,不需要重复声明。
连接配置
Spring Boot 2.3.x 时代,MongoDB 连接配置有两种写法。工程实读用的是 URI 写法:
yaml
server:
port: 18082
spring:
application:
name: mf-dubbo-mongo
cloud:
nacos:
discovery:
server-addr: 192.168.136.160:8848
data:
mongodb:
uri: mongodb://192.168.136.160:27017/mf
dubbo:
protocol:
name: dubbo
port: 20882
registry:
address: spring-cloud://localhost
scan:
base-packages: com.mf.dubbo.api
URI 的最后一段就是库名,连的是 mf 库。带账号密码时写成 mongodb://user:pass@host:27017/mf。
另一种写法是把连接参数拆开:
yaml
spring:
mongodb:
host: 192.168.136.160
port: 27017
database: mf
两种都能用,但优先选 URI:副本集、认证库、连接参数(如 replicaSet=rs0)都往 URI 里追加即可,拆开写法表达不了这些。集群连接串和单机的差别不小,属于后面集群篇的内容,这里先记住单机写法。
顺带修一个讲义笔误:讲义 §4.1 的配置示例里写的是 mongo://192.168.136.160:27017/testdb,协议头 mongo:// 是错的,跑起来会连接失败,正确前缀是 mongodb://。代码块语言标注也有误(标成 properties 实为 yaml),以这里为准。
实体注解映射
这是环境搭建的核心。MongoDB 是文档库,没有表结构约束,Java 对象要存进去,必须告诉 Spring Data:这个类对应哪个集合、哪个属性是主键、属性名和文档字段名对不上时怎么办。三个注解各管一件事。
先看工程里的真实实体(mf-model 模块,实读):
java
package com.mf.model.mongo;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import org.bson.types.ObjectId;
import org.springframework.data.mongodb.core.mapping.Document;
@AllArgsConstructor
@NoArgsConstructor
@Data
@Document(collection = "recommend_user")
public class RecommendUser implements java.io.Serializable {
private ObjectId id; //主键id
private Long userId; //推荐的用户id
private Long toUserId; //用户id
private Double score = 0d; //推荐得分
private String date; //日期
}
三个注解逐个说:
@Document(collection = 「recommend_user」) , 类到集合的映射,等价于 MyBatis-Plus 的 @TableName("tb_user")。不写的话默认集合名是类名首字母小写(recommendUser),和库里的集合名对不上就会查空。显式写出集合名是更稳妥的习惯。
@Id , 标在主键属性上,映射到文档的 _id 字段。MongoDB 推荐用 ObjectId 类型作主键,ObjectId.get() 能生成全局唯一的 24 位十六进制字符串。有意思的是,上面这个实体的 id 属性并没有加 @Id ,照常工作,因为 Spring Data 有条约定:名为 id 的属性自动映射为 _id,注解可以省略。教学讲义里的入门实体则两个都写全了,方便理解:
java
package com.mf.mongo.domain;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import org.bson.types.ObjectId;
import org.springframework.data.mongodb.core.mapping.Document;
import org.springframework.data.annotation.Id;
import org.springframework.data.mongodb.core.mapping.Field;
@Data
@AllArgsConstructor
@NoArgsConstructor
@Document(value = "person")
public class Person {
@Id
private ObjectId id;
@Field("username")
private String name; //属性叫 name,文档字段叫 username
private int age;
private String address;
}
@Field(「username」) , 属性名与文档字段名不一致时的映射,name 属性存进集合后字段叫 username。作用和 MyBatis-Plus 的 @TableField("nick_name") 一致。注意别引错包:实体映射用的 @Id 在 org.springframework.data.annotation 包下,不是 org.springframework.data.mongodb 下专有的,它是 Spring Data 通用注解,JDBC、Redis 的映射也用它。
会了 MyBatis-Plus,这套东西可以直接迁移:
| MySQL / MyBatis-Plus | Spring Data MongoDB | 说明 |
|---|---|---|
@TableName("tb_user") |
@Document(collection = "user") |
表名 / 集合名映射 |
@TableId |
@Id |
主键;Mongo 侧默认 ObjectId,名为 id 的属性可省略注解 |
@TableField("user_name") |
@Field("user_name") |
属性名与存储字段名不一致时的映射 |
BaseMapper<T> |
MongoTemplate / MongoRepository |
数据访问入口 |
QueryWrapper 条件构造 |
Query + Criteria |
查询条件对象 |
分页插件 IPage |
Query.skip().limit() + count() |
手动跳过条数、限制页大小 |
最后一个问题:域服务里怎么用?注入 MongoTemplate,包一层 @DubboService 暴露出去。工程里「今日佳人」的第一个接口就是这么写的(完整 CRUD 的拆解在后面):
java
package com.mf.dubbo.api;
import com.mf.model.mongo.RecommendUser;
import org.apache.dubbo.config.annotation.DubboService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.domain.Sort;
import org.springframework.data.mongodb.core.MongoTemplate;
import org.springframework.data.mongodb.core.query.Criteria;
import org.springframework.data.mongodb.core.query.Query;
@DubboService
public class RecommendUserApiImpl implements RecommendUserApi {
@Autowired
private MongoTemplate mongoTemplate;
//查询今日佳人:按评分取最高的一条
public RecommendUser queryWithMaxScore(Long toUserId) {
//构建条件:toUserId 等于指定用户
Criteria criteria = Criteria.where("toUserId").is(toUserId);
//按 score 倒序,取第一条
Query query = Query.query(criteria)
.with(Sort.by(Sort.Order.desc("score")))
.limit(1);
return mongoTemplate.findOne(query, RecommendUser.class);
}
}
注意 Criteria.where("toUserId") 里写的是文档字段名。本例属性名和字段名一致所以看不出差别,一旦哪天加了 @Field,这里必须跟着改,条件对象走的是字段名,不走 Java 属性名,这是新手高发坑。
环境验证
搭建是否成功,按三步验:
第一步,启动 DubboMongoApplication。控制台没有连接异常(典型报错是 MongoTimeoutException,十有八九是连不上虚拟机里的 MongoDB),Nacos 控制台能看到 mf-dubbo-mongo 服务注册上来。
第二步,mongo shell 里确认连接和集合:
bash
# 进入 mongo shell(连接 192.168.136.160)
mongo 192.168.136.160:27017
# 切换到 mf 库,查看集合
use mf
show collections
此时集合可能还没建(MongoDB 惰性建库,插入第一条数据时才真正创建),show dbs 里看不到 mf 也正常,只要应用启动无报错、能注入 MongoTemplate,连接就是通的。
第三步,写一个最小验证,证明注入可用:
java
@Autowired
private MongoTemplate mongoTemplate;
@Test
public void testConnection() {
//能打印出数据库名即连接成功,CRUD 下一篇展开
System.out.println(mongoTemplate.getDb().getName());
}
两种操作方式:MongoTemplate 与 MongoRepository
Spring Data 家族的通用套路是「Repository 接口」:定义一个继承 MongoRepository<Person, String> 的接口,方法名按规则命名(如 findByNameLike),Spring 在启动时生成实现。而 MongoTemplate 走的是另一条路:直接注入模板对象,用代码显式构建查询。
| 维度 | MongoTemplate | MongoRepository |
|---|---|---|
| 使用方式 | 注入模板对象,代码显式构建 Query/Update | 继承接口,方法名或 @Query 声明查询 |
| 灵活性 | 高,Query/Criteria/Update/聚合全都能表达 | 中,复杂查询要靠 @Query 写 JSON 或退回 Template |
| 学习成本 | 需要理解 Criteria API,但概念与 mongosh 语法一一对应 | 方法名命名规则上手快,规则复杂后可读性下降 |
| 动态条件拼接 | 天然支持,Criteria 链式追加即可 | 差,动态条件要写一堆方法重载或 Specification 风格 |
| 适合场景 | 条件动态多变、统计聚合、批量操作 | 固定维度的简单 CRUD |
交友项目选了 MongoTemplate,原因很直接:项目里的 Mongo 查询几乎全是动态条件的,「今日佳人」要按 toUserId 过滤再按 score 倒序取第一条,推荐列表要做分页加排序,这些用 Repository 方法名规则表达很别扭,用 Criteria 三行写完。看工程里真实的查询代码(mf-dubbo-mongo/src/main/java/com/mf/dubbo/api/RecommendUserApiImpl.java):
java
@DubboService
public class RecommendUserApiImpl implements RecommendUserApi {
@Autowired
private MongoTemplate mongoTemplate;
//查询今日佳人
public RecommendUser queryWithMaxScore(Long toUserId) {
//根据toUserId查询,根据评分score排序,获取第一条
//构建Criteria
Criteria criteria = Criteria.where("toUserId").is(toUserId);
//构建Query对象
Query query = Query.query(criteria).with(Sort.by(Sort.Order.desc("score")))
.limit(1);
//调用mongoTemplate查询
return mongoTemplate.findOne(query, RecommendUser.class);
}
}
条件、排序、截断条数全部在一处显式声明,这就是后面几节展开的写法基础。
实体回顾
实体注解(@Document 映射集合、@Id 标记主键)在环境搭建一篇已经讲过,这里直接给出项目里真实在用的实体。文件在 mf-model/src/main/java/com/mf/model/mongo/RecommendUser.java:
java
package com.mf.model.mongo;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import org.bson.types.ObjectId;
import org.springframework.data.mongodb.core.mapping.Document;
@AllArgsConstructor
@NoArgsConstructor
@Data
@Document(collection = "recommend_user")
public class RecommendUser implements java.io.Serializable {
private ObjectId id; //主键id
private Long userId; //推荐的用户id
private Long toUserId; //用户id
private Double score =0d; //推荐得分
private String date; //日期
}
注意这个实体的结构:id 是 MongoDB 的 ObjectId 主键,而 userId、toUserId 是业务上的 MySQL 用户 id(Long 类型)。主键与业务键分开,是 Mongo 建模里很常见的做法,主键只管唯一性,业务等值查询靠业务键字段。
插入:insert 与 save 的区别
这是 MongoTemplate 最经典的混淆点,用错了不是报错,而是静默覆盖别人的数据。
两个方法都能插入文档,但遇到 _id 冲突时行为完全不同:
| 方法 | 集合中 _id 已存在时 |
语义 | 适用场景 |
|---|---|---|---|
mongoTemplate.insert(obj) |
抛 DuplicateKeyException,插入失败 |
纯新增 | 确定是新建记录 |
mongoTemplate.save(obj) |
不报错,用新文档整体覆盖旧文档(upsert) | 存在则替换,不存在则插入 | 不确定是否存在,以最新状态为准 |
风险场景具体化一下:你在导入脚本里循环 save 一批用户数据,其中一条的 _id 和库里已有的一条撞了,save 不会给你任何提示,直接把库里那条盖掉。如果那是别的业务的记录,数据就静默丢了。反过来,如果业务语义是「不允许重复导入」,你用 save 该报错的时候也不报错。所以选型口诀是:确定是新建用 insert,让冲突暴露出来;确实要覆盖语义才用 save,并且要清楚 save 是整个文档替换,不是字段合并。
插入时代码不设 id,MongoDB 会自动生成 ObjectId 并回填到实体上:
java
package com.mf.mongo.test;
import com.mf.mongo.domain.RecommendUser;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.data.mongodb.core.MongoTemplate;
import org.springframework.test.context.junit4.SpringRunner;
@RunWith(SpringRunner.class)
@SpringBootTest(classes = MongoApplication.class)
public class InsertTest {
@Autowired
private MongoTemplate mongoTemplate;
@Test
public void testInsert() {
RecommendUser ru = new RecommendUser();
//不设置id,由MongoDB自动生成ObjectId
ru.setUserId(106L);
ru.setToUserId(1L);
ru.setScore(88d);
ru.setDate("2021-08-25");
mongoTemplate.insert(ru);
//插入完成后,ObjectId已回填到实体
System.out.println(ru.getId()); //如 61275c3980f68e67ab4fdf25
}
}
如果实体里字段名与集合内字段名不一致(比如 Java 属性 name 映射到集合字段 myname),加 @Field("myname") 即可,这是环境搭建篇的内容,不再展开。
查询:Criteria 与 Query
机翻稿里讲师说「之所以先讲查询,是因为后续更新和删除都会用到查询当中的对象」,确实如此,updateFirst 和 remove 的第一个参数都是 Query。查询是这三种操作的地基。
整体三步:创建 Criteria 设置条件 → 根据 Criteria 构建 Query → 调用 mongoTemplate 的 find 系列方法。
本章配套两个工程,演示工程 mf-mongo-demo 结构如下(这里的验证代码都落在它的 test 目录):
tree
mf-mongo-demo
└── src
├── main/java/com/wb/mongo
│ ├── MongoApplication.java # 启动类
│ └── domain
│ └── Person.java # 实体:@Document + @Id + @Field
└── test/java/com/wb/mongo/test
└── MongoTest.java # MongoTemplate 的 CRUD 单元测试
按 id 查
java
//方式一:findById,直接传id值
RecommendUser ru = mongoTemplate.findById("61275c3980f68e67ab4fdf25", RecommendUser.class);
//方式二:走Query,效果等价
Query query = Query.query(Criteria.where("id").is("61275c3980f68e67ab4fdf25"));
RecommendUser ru2 = mongoTemplate.findOne(query, RecommendUser.class);
这里的坑是 String 与 ObjectId 的转换。前端传来的 id 一定是字符串,而实体里 id 的类型是 ObjectId。实测下来,Spring Data MongoDB 在属性名恰好是 id 时会自动把字符串转成 ObjectId 再去查(这就是上面代码直接传字符串也能查到的原因);但如果你把条件字段写成 "_id",或者实体里主键字段改了名、加了 @Field 重命名,自动转换就不再生效,查询会静默匹配不到任何文档。稳妥的写法是显式转换:
java
import org.bson.types.ObjectId;
RecommendUser ru = mongoTemplate.findById(new ObjectId("61275c3980f68e67ab4fdf25"), RecommendUser.class);
另外字符串必须是 24 位十六进制,前端传来脏数据(比如截断的 id)时,new ObjectId(str) 会直接抛 IllegalArgumentException,这在接口层要做前置校验。
条件查询与 QueryWrapper 心智迁移
条件全部通过 Criteria 表达,Criteria.where("字段") 相当于 SQL 的 where 字段,后面链式接比较方式。演示工程(mf-mongo-demo)里的真实代码:
java
@Test
public void testFind() {
//1、创建Criteria对象,并设置查询条件
Criteria criteria = Criteria.where("myname").is("张三")
.and("age").is(18);
//2、根据Criteria创建Query
Query query = new Query(criteria);
//3、查询
List<Person> list = mongoTemplate.find(query, Person.class);
for (Person person : list) {
System.out.println(person);
}
}
三个容易踩的点:
where里写的是集合中的字段名 ,不是 Java 属性名。实体里name加了@Field("myname"),条件就要写myname。Criteria上.and()链式追加多个条件,默认多个条件之间是 AND 关系;OR 要用Criteria.orOperator(...)。- find 系列方法别导错包:
Query/Criteria都在org.springframework.data.mongodb.core.query下。
find 方法家族的分工:
| 方法 | 返回 | 说明 |
|---|---|---|
find(query, Class) |
List<T> |
条件查询,最常用 |
findOne(query, Class) |
T |
取第一条,配排序用(今日佳人就是这么写的) |
findAll(Class) |
List<T> |
查全集合 |
findById(id, Class) |
T |
按主键查 |
findAllAndRemove / findAndModify |
--- | 查询并删除/修改,按需使用 |
写过 MyBatis-Plus 的话,Criteria 可以直接按 QueryWrapper 的心智模型迁移,对照关系如下:
| MyBatis-Plus QueryWrapper | Mongo Criteria | 对应 SQL 概念 |
|---|---|---|
.eq("age", 18) |
Criteria.where("age").is(18) |
= |
.ne("age", 18) |
.ne(18) |
!= |
.gt("age", 20) / .ge(...) |
.gt(20) / .gte(20) |
> / >= |
.lt("age", 20) / .le(...) |
.lt(20) / .lte(20) |
< / <= |
.in("name", "a", "b") |
.in("a", "b") |
IN |
.like("name", "三") |
.regex("三") |
LIKE '%三%'(正则实现) |
.isNull("field") |
.isNull() |
IS NULL |
.between("age", 18, 30) |
.gte(18).lte(30) |
BETWEEN |
.eq(...).eq(...) 链式 |
.and("字段").is(...) 链式 |
AND |
.or(w -> w.eq(...)) |
orOperator(Criteria, Criteria) |
OR |
有了这张表,QueryWrapper 的条件翻译过来基本零成本。
排序与分页
排序用 query.with(Sort.by(...)),分页用 skip() 跳过条数、limit() 取条数。演示工程的完整写法:
java
@Test
public void testPage() {
int page = 1;
int size = 2;
//1、创建Criteria对象,并设置查询条件
Criteria criteria = Criteria.where("age").lt(50);
//2、根据Criteria创建Query
Query queryLimit = new Query(criteria)
.skip((page - 1) * size) //从第几条开始查询
.limit(size) //每页查询条数
.with(Sort.by(Sort.Order.desc("age")));
//3、查询
List<Person> list = mongoTemplate.find(queryLimit, Person.class);
for (Person person : list) {
System.out.println(person);
}
}
页码换算公式:skip = (page - 1) * size,与 MyBatis-Plus 的 Page(page, size) 语义一致,但注意两点差异:
- MyBatis-Plus 的
Page是把 count 和 list 两步封装好的对象;Mongo 这边要自己先count一次拿总数,再发第二次查询取当前页,两次查询的条件必须一致。 - Mongo 的
skip/limit是逻辑分页:数据量大时skip(100000)依然要从头扫过并丢弃前 10 万条。MySQL 深分页有同样的问题,解法也类似,用有序字段(如_id)记录上一页末尾位置,下一页用gt(上次末尾id)接着取,而不是无限加大 skip。 with(Sort)不加的话,Mongo 返回顺序是不保证的(自然顺序只是实现细节)。做「取 score 最高的一条」这类需求,排序和 limit(1) 必须同时给,RecommendUserApiImpl就是范例。
count 计数
java
Query countQuery = Query.query(Criteria.where("age").lt(50));
long total = mongoTemplate.count(countQuery, Person.class);
条件对象复用时注意:同一个 Criteria 可以构建两个 Query 分别做 count 和 find(讲义里的分页示例就是这么写的),但一个 Query 对象设置过 skip/limit 之后再拿去 count,条件虽然不受影响,语义上还是建议「计数用一个干净的 Query」,避免复用中被别人追加条件。
更新:updateFirst 与 Update
简单更新的套路:Query 定位文档,Update 描述改什么,updateFirst 只改第一条(改全部用 updateMulti,留在后面的批量与复杂更新一起讲)。
java
@Test
public void testUpdate() {
//1、条件:按id定位
Query query = Query.query(Criteria.where("id").is("61275c3980f68e67ab4fdf25"));
//2、更新的内容
Update update = new Update();
update.set("age", 20);
//3、执行
mongoTemplate.updateFirst(query, update, Person.class);
}
Update.set("age", 20) 底层生成的就是 mongosh 里的 $set 修改器,只改指定字段、不动其他字段,这和命令行篇里「直接 db.collection.update 第二参数写整个文档会删掉其他字段」是两种行为,混起来会出事故。
计数场景用 $inc,原子自增,这正是引入篇里提到的痛点:MySQL 里「先查出来 +1 再写回」在并发下会丢更新,Mongo 的 $inc 由数据库保证原子性:
java
//点赞数+1,等价于 {$inc: {likeCount: 1}}
Query query = Query.query(Criteria.where("id").is("61275c3980f68e67ab4fdf25"));
Update update = new Update();
update.inc("likeCount", 1);
mongoTemplate.updateFirst(query, update, Person.class);
inc 对不存在的字段会按 0 起步再自增,所以不用预先初始化字段。
一次查询的完整时序
以「今日佳人」(按 toUserId 查、按 score 倒序取第一条)为例:
RecommendUser实体 MongoDB(recommend_user集合) MongoTemplate RecommendUserApiImpl(Service) RecommendUser实体 MongoDB(recommend_user集合) MongoTemplate RecommendUserApiImpl(Service) #mermaid-svg-5U1BlMVqzIksDoUd{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-5U1BlMVqzIksDoUd .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-5U1BlMVqzIksDoUd .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-5U1BlMVqzIksDoUd .error-icon{fill:#552222;}#mermaid-svg-5U1BlMVqzIksDoUd .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-5U1BlMVqzIksDoUd .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-5U1BlMVqzIksDoUd .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-5U1BlMVqzIksDoUd .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-5U1BlMVqzIksDoUd .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-5U1BlMVqzIksDoUd .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-5U1BlMVqzIksDoUd .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-5U1BlMVqzIksDoUd .marker{fill:#333333;stroke:#333333;}#mermaid-svg-5U1BlMVqzIksDoUd .marker.cross{stroke:#333333;}#mermaid-svg-5U1BlMVqzIksDoUd svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-5U1BlMVqzIksDoUd p{margin:0;}#mermaid-svg-5U1BlMVqzIksDoUd .actor{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-5U1BlMVqzIksDoUd text.actor>tspan{fill:black;stroke:none;}#mermaid-svg-5U1BlMVqzIksDoUd .actor-line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-5U1BlMVqzIksDoUd .innerArc{stroke-width:1.5;stroke-dasharray:none;}#mermaid-svg-5U1BlMVqzIksDoUd .messageLine0{stroke-width:1.5;stroke-dasharray:none;stroke:#333;}#mermaid-svg-5U1BlMVqzIksDoUd .messageLine1{stroke-width:1.5;stroke-dasharray:2,2;stroke:#333;}#mermaid-svg-5U1BlMVqzIksDoUd #arrowhead path{fill:#333;stroke:#333;}#mermaid-svg-5U1BlMVqzIksDoUd .sequenceNumber{fill:white;}#mermaid-svg-5U1BlMVqzIksDoUd #sequencenumber{fill:#333;}#mermaid-svg-5U1BlMVqzIksDoUd #crosshead path{fill:#333;stroke:#333;}#mermaid-svg-5U1BlMVqzIksDoUd .messageText{fill:#333;stroke:none;}#mermaid-svg-5U1BlMVqzIksDoUd .labelBox{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-5U1BlMVqzIksDoUd .labelText,#mermaid-svg-5U1BlMVqzIksDoUd .labelText>tspan{fill:black;stroke:none;}#mermaid-svg-5U1BlMVqzIksDoUd .loopText,#mermaid-svg-5U1BlMVqzIksDoUd .loopText>tspan{fill:black;stroke:none;}#mermaid-svg-5U1BlMVqzIksDoUd .loopLine{stroke-width:2px;stroke-dasharray:2,2;stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-5U1BlMVqzIksDoUd .note{stroke:#aaaa33;fill:#fff5ad;}#mermaid-svg-5U1BlMVqzIksDoUd .noteText,#mermaid-svg-5U1BlMVqzIksDoUd .noteText>tspan{fill:black;stroke:none;}#mermaid-svg-5U1BlMVqzIksDoUd .activation0{fill:#f4f4f4;stroke:#666;}#mermaid-svg-5U1BlMVqzIksDoUd .activation1{fill:#f4f4f4;stroke:#666;}#mermaid-svg-5U1BlMVqzIksDoUd .activation2{fill:#f4f4f4;stroke:#666;}#mermaid-svg-5U1BlMVqzIksDoUd .actorPopupMenu{position:absolute;}#mermaid-svg-5U1BlMVqzIksDoUd .actorPopupMenuPanel{position:absolute;fill:#ECECFF;box-shadow:0px 8px 16px 0px rgba(0,0,0,0.2);filter:drop-shadow(3px 5px 2px rgb(0 0 0 / 0.4));}#mermaid-svg-5U1BlMVqzIksDoUd .actor-man line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-5U1BlMVqzIksDoUd .actor-man circle,#mermaid-svg-5U1BlMVqzIksDoUd line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;stroke-width:2px;}#mermaid-svg-5U1BlMVqzIksDoUd :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} Query = Criteria(toUserId=1) + Sort(score desc) + limit(1) findOne(query, RecommendUser.class) 转换为命令 find({toUserId:1}).sort({score:-1}).limit(1) 返回BSON文档 按实体注解映射(_id→ObjectId id,字段→属性) 返回RecommendUser对象(无匹配时返回null)
MongoTemplate 做的就是翻译和映射两层事:把 Query/Criteria/Sort 翻译成 Mongo 的 find 命令,把返回的 BSON 文档按注解映射回 Java 实体。
可复现验证
下面这个测试类把这里的操作串成一遍可跑的流程:插入两条 → 条件查询 → 排序分页 → 计数 → 更新计数。依赖环境搭建篇的工程(mf-mongo-demo),实体用 Person。
java
package com.wb.mongo.test;
import com.wb.mongo.MongoApplication;
import com.wb.mongo.domain.Person;
import org.bson.types.ObjectId;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.data.domain.Sort;
import org.springframework.data.mongodb.core.MongoTemplate;
import org.springframework.data.mongodb.core.query.Criteria;
import org.springframework.data.mongodb.core.query.Query;
import org.springframework.data.mongodb.core.query.Update;
import org.springframework.test.context.junit4.SpringRunner;
import java.util.List;
@RunWith(SpringRunner.class)
@SpringBootTest(classes = MongoApplication.class)
public class CrudVerifyTest {
@Autowired
private MongoTemplate mongoTemplate;
//1、插入两条(不设置id,ObjectId自动生成回填)
@Test
public void step1_insert() {
for (int i = 0; i < 2; i++) {
Person p = new Person();
p.setName("验证张三" + i);
p.setAge(18 + i);
p.setAddress("北京顺义" + i);
mongoTemplate.insert(p);
System.out.println("插入完成,生成的id = " + p.getId());
}
}
//2、条件查询:name精确匹配 + and追加年龄条件
@Test
public void step2_find() {
Criteria criteria = Criteria.where("myname").is("验证张三0")
.and("age").is(18);
List<Person> list = mongoTemplate.find(new Query(criteria), Person.class);
list.forEach(System.out::println); //预期输出1条
}
//3、排序+分页:按age倒序,第2页每页1条
@Test
public void step3_page() {
int page = 2;
int size = 1;
Query query = new Query(Criteria.where("age").lt(50))
.skip((page - 1) * size)
.limit(size)
.with(Sort.by(Sort.Order.desc("age")));
List<Person> list = mongoTemplate.find(query, Person.class);
list.forEach(System.out::println);
}
//4、计数:与分页使用相同的过滤条件
@Test
public void step4_count() {
Query query = Query.query(Criteria.where("age").lt(50));
long total = mongoTemplate.count(query, Person.class);
System.out.println("age<50的总数 = " + total);
}
//5、按id更新 + $inc自增
@Test
public void step5_update() {
Query query = Query.query(Criteria.where("id").is(new ObjectId("这里替换为step1打印的id")));
Update update = new Update();
update.set("address", "上海浦东");
update.inc("age", 1); //年龄+1
mongoTemplate.updateFirst(query, update, Person.class);
}
}
每个测试跑完后用 mongosh 对照查证(与命令行篇的语法衔接,那边有完整讲解):
javascript
//进入testdb库,查看tb_person集合
use testdb
db.tb_person.find().pretty()
//对照step3:验证排序分页结果
db.tb_person.find({age: {$lt: 50}}).sort({age: -1}).skip(1).limit(1)
//对照step4:验证计数
db.tb_person.find({age: {$lt: 50}}).count()
Java 侧的输出与 mongosh 的结果一致,说明映射正确。
删除:remove 家族
查询和更新在前面已经跑通了,这一篇补齐剩下两块最容易出事故的操作:删除 和批量写入,顺带把 MongoDB 最有价值的部分,嵌套文档与数组字段的进阶操作,讲透。删除写错条件没有回滚可用,这篇会给出先 count 再删的安全套路。
MongoTemplate 的删除有三个常用入口,全部围绕 Query 对象工作:
| 方法 | 行为 | 返回 |
|---|---|---|
remove(Query, Class) |
删除所有匹配条件的文档 | DeleteResult(含删除条数) |
findFirstAndRemove(Query, Class) |
只删匹配的第一条 | 被删的那个实体对象 |
findAllAndRemove(Query, Class) |
删除所有匹配的文档 | 被删的实体对象列表 |
demo 工程里的按 ID 删除(mf-mongo-demo/src/test/java/com/wb/mongo/test/MongoTest.java):
java
package com.wb.mongo.test;
import com.wb.mongo.domain.Person;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.data.mongodb.core.MongoTemplate;
import org.springframework.data.mongodb.core.query.Criteria;
import org.springframework.data.mongodb.core.query.Query;
import org.springframework.test.context.junit4.SpringRunner;
@RunWith(SpringRunner.class)
@SpringBootTest(classes = MongoApplication.class)
public class RemoveTest {
@Autowired
private MongoTemplate mongoTemplate;
//按 ID 删除:构建 Query,传实体类字节码
@Test
public void testRemove() {
Query query = Query.query(Criteria.where("id").is("5fe404c26a787e3b50d8d5ad"));
mongoTemplate.remove(query, Person.class);
}
//按条件批量删除:删除所有年龄大于 40 的记录
@Test
public void testRemoveByCondition() {
Query query = Query.query(Criteria.where("age").gt(40));
mongoTemplate.remove(query, Person.class);
}
//只删匹配的第一条,返回被删的实体
@Test
public void testFindFirstAndRemove() {
Query query = Query.query(Criteria.where("name").is("张三0"));
Person removed = mongoTemplate.findAndRemove(query, Person.class);
System.out.println("被删掉的是:" + removed);
}
//删掉所有匹配项,返回被删的实体列表
@Test
public void testFindAllAndRemove() {
Query query = Query.query(Criteria.where("age").lt(20));
Iterable<Person> removed = mongoTemplate.findAllAndRemove(query, Person.class);
removed.forEach(p -> System.out.println(p));
}
}
三个方法共用同一套条件构建逻辑:Criteria 描述条件,Query.query(criteria) 包一层,和查询时完全一致,查询条件怎么写,删除条件就怎么写。
与 MyBatis-Plus DeleteWrapper 的心智对照
写过 MyBatis-Plus 的话,这套模式其实已经在你脑子里了:
java
// MyBatis-Plus(MySQL)
LambdaQueryWrapper<Person> wrapper = new LambdaQueryWrapper<Person>()
.eq(Person::getName, "张三")
.gt(Person::getAge, 18);
personMapper.delete(wrapper);
// MongoTemplate(MongoDB)
Query query = Query.query(
Criteria.where("name").is("张三").and("age").gt(18));
mongoTemplate.remove(query, Person.class);
| 心智项 | MyBatis-Plus | MongoTemplate |
|---|---|---|
| 条件载体 | Wrapper(QueryWrapper / LambdaQueryWrapper) |
Query(内含 Criteria) |
| 等值 | .eq(field, v) |
Criteria.where(field).is(v) |
| 范围 | .gt / .lt / .between |
.gt / .lt / .gte / .lte |
| 且/或 | .and / .or |
.andOperator / .orOperator |
| 删除入口 | mapper.delete(wrapper) |
mongoTemplate.remove(query, Class) |
条件构建的心智可以完全平移,区别在底层:一个翻译成 SQL 的 DELETE ... WHERE,一个翻译成 MongoDB 的 deleteMany 命令。
删除语义与安全:条件写错会删多少条?
这是 MongoDB 和 MySQL 使用习惯上差异最大的地方。MySQL 里手滑少写 WHERE,客户端通常有安全更新模式(sql_safe_updates)兜底;MongoTemplate 没有这层保护,remove(new Query(), Person.class) 会清空整个集合,而且没有回滚。
两个成本极低的防护动作:
java
//防护一:先 count 再删,确认影响范围
Query query = Query.query(Criteria.where("age").gt(40));
long count = mongoTemplate.count(query, Person.class);
if (count > 100) {
throw new IllegalStateException("条件命中 " + count + " 条,超出预期,终止删除");
}
mongoTemplate.remove(query, Person.class);
//防护二:拿不准就用 findAndRemove 试探,只删一条验证条件
Person probe = mongoTemplate.findAndRemove(query, Person.class);
System.out.println("试探删除:" + probe); //检查这条是不是你想删的
关于事务要说清楚一件事:MongoDB 的写操作是单文档原子 的,跨多个文档的 remove 删到一半失败,前面已删的不会自动恢复。4.0 之后 MongoDB 支持多文档事务,但需要副本集环境,且项目并没有启用,所以操作线上数据前,先 count、先备份,比指望回滚靠谱得多。
删除的安全流程如下图:
#mermaid-svg-qEZVWbI2Rd8yB5mJ{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-qEZVWbI2Rd8yB5mJ .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-qEZVWbI2Rd8yB5mJ .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-qEZVWbI2Rd8yB5mJ .error-icon{fill:#552222;}#mermaid-svg-qEZVWbI2Rd8yB5mJ .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-qEZVWbI2Rd8yB5mJ .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-qEZVWbI2Rd8yB5mJ .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-qEZVWbI2Rd8yB5mJ .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-qEZVWbI2Rd8yB5mJ .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-qEZVWbI2Rd8yB5mJ .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-qEZVWbI2Rd8yB5mJ .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-qEZVWbI2Rd8yB5mJ .marker{fill:#333333;stroke:#333333;}#mermaid-svg-qEZVWbI2Rd8yB5mJ .marker.cross{stroke:#333333;}#mermaid-svg-qEZVWbI2Rd8yB5mJ svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-qEZVWbI2Rd8yB5mJ p{margin:0;}#mermaid-svg-qEZVWbI2Rd8yB5mJ .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-qEZVWbI2Rd8yB5mJ .cluster-label text{fill:#333;}#mermaid-svg-qEZVWbI2Rd8yB5mJ .cluster-label span{color:#333;}#mermaid-svg-qEZVWbI2Rd8yB5mJ .cluster-label span p{background-color:transparent;}#mermaid-svg-qEZVWbI2Rd8yB5mJ .label text,#mermaid-svg-qEZVWbI2Rd8yB5mJ span{fill:#333;color:#333;}#mermaid-svg-qEZVWbI2Rd8yB5mJ .node rect,#mermaid-svg-qEZVWbI2Rd8yB5mJ .node circle,#mermaid-svg-qEZVWbI2Rd8yB5mJ .node ellipse,#mermaid-svg-qEZVWbI2Rd8yB5mJ .node polygon,#mermaid-svg-qEZVWbI2Rd8yB5mJ .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-qEZVWbI2Rd8yB5mJ .rough-node .label text,#mermaid-svg-qEZVWbI2Rd8yB5mJ .node .label text,#mermaid-svg-qEZVWbI2Rd8yB5mJ .image-shape .label,#mermaid-svg-qEZVWbI2Rd8yB5mJ .icon-shape .label{text-anchor:middle;}#mermaid-svg-qEZVWbI2Rd8yB5mJ .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-qEZVWbI2Rd8yB5mJ .rough-node .label,#mermaid-svg-qEZVWbI2Rd8yB5mJ .node .label,#mermaid-svg-qEZVWbI2Rd8yB5mJ .image-shape .label,#mermaid-svg-qEZVWbI2Rd8yB5mJ .icon-shape .label{text-align:center;}#mermaid-svg-qEZVWbI2Rd8yB5mJ .node.clickable{cursor:pointer;}#mermaid-svg-qEZVWbI2Rd8yB5mJ .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-qEZVWbI2Rd8yB5mJ .arrowheadPath{fill:#333333;}#mermaid-svg-qEZVWbI2Rd8yB5mJ .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-qEZVWbI2Rd8yB5mJ .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-qEZVWbI2Rd8yB5mJ .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-qEZVWbI2Rd8yB5mJ .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-qEZVWbI2Rd8yB5mJ .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-qEZVWbI2Rd8yB5mJ .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-qEZVWbI2Rd8yB5mJ .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-qEZVWbI2Rd8yB5mJ .cluster text{fill:#333;}#mermaid-svg-qEZVWbI2Rd8yB5mJ .cluster span{color:#333;}#mermaid-svg-qEZVWbI2Rd8yB5mJ div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-qEZVWbI2Rd8yB5mJ .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-qEZVWbI2Rd8yB5mJ rect.text{fill:none;stroke-width:0;}#mermaid-svg-qEZVWbI2Rd8yB5mJ .icon-shape,#mermaid-svg-qEZVWbI2Rd8yB5mJ .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-qEZVWbI2Rd8yB5mJ .icon-shape p,#mermaid-svg-qEZVWbI2Rd8yB5mJ .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-qEZVWbI2Rd8yB5mJ .icon-shape .label rect,#mermaid-svg-qEZVWbI2Rd8yB5mJ .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-qEZVWbI2Rd8yB5mJ .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-qEZVWbI2Rd8yB5mJ .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-qEZVWbI2Rd8yB5mJ :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 超出预期
符合预期
是
否
是
否
构建 Query 条件
count 命中多少条?
修正条件后重来
是否需要再确认?
findAndRemove 删一条试探
删除对象正确?
remove 批量删除
核对 DeleteResult 的删除条数
批量操作:insertAll 与 BulkOperations
批量插入 insertAll
java
@Test
public void testInsertAll() {
List<Person> list = new ArrayList<>();
for (int i = 0; i < 1000; i++) {
Person p = new Person();
p.setId(ObjectId.get());
p.setName("用户" + i);
p.setAge(18 + i % 40);
p.setAddress("北京顺义" + i);
list.add(p);
}
Collection<Person> inserted = mongoTemplate.insertAll(list);
System.out.println("插入 " + inserted.size() + " 条");
}
批量更新:循环 vs BulkOperations
把 1000 条记录各更新一次,最直觉的写法是循环调 updateFirst:
java
//反例:循环逐条更新,1000 条 = 1000 次网络往返
for (Person p : list) {
Query query = Query.query(Criteria.where("id").is(p.getId()));
Update update = new Update().set("age", p.getAge() + 1);
mongoTemplate.updateFirst(query, update, Person.class);
}
每次调用都是一次完整的「发送命令 → 等待响应」网络往返,数据量一大,耗时基本都花在等待上。BulkOperations 把一批操作打包成一次请求发给 MongoDB 服务端执行:
java
import org.springframework.data.mongodb.core.BulkOperations;
import com.mongodb.bulk.BulkWriteResult;
@Test
public void testBulkUpdate() {
BulkOperations ops = mongoTemplate.bulkOps(BulkOperations.BulkMode.UNORDERED, Person.class);
for (Person p : list) {
Query query = Query.query(Criteria.where("id").is(p.getId()));
Update update = new Update().set("age", p.getAge() + 1);
//注意:是往 ops 里累积操作,不是直接执行
ops.updateOne(query, update);
}
BulkWriteResult result = ops.execute(); //必须调用 execute 才真正生效
System.out.println("修改条数:" + result.getModifiedCount());
}
BulkOperations 覆盖三类批量动作,API 与单条版一一对应:
java
BulkOperations ops = mongoTemplate.bulkOps(BulkOperations.BulkMode.UNORDERED, Person.class);
//批量插入:等价于单条的 insert / save
ops.insert(person1);
ops.insert(Arrays.asList(person2, person3));
//批量更新:updateOne 对应 updateFirst,updateMulti 对应 updateMulti
ops.updateOne(Query.query(Criteria.where("name").is("张三")), new Update().set("age", 20));
ops.updateMulti(Query.query(Criteria.where("age").lt(20)), new Update().inc("age", 1));
//批量删除:对应单条的 remove
ops.remove(Query.query(Criteria.where("age").gt(60)));
//最后统一执行,返回 BulkWriteResult
BulkWriteResult result = ops.execute();
BulkMode 两个选项:ORDERED(顺序执行,中途失败即停止)和 UNORDERED(乱序执行,失败跳过继续,吞吐更高)。实测下来,千级到万级的写入,BulkOperations 比循环逐条快一个数量级,差距主要来自网络往返次数。
批量删除的执行链路:
MongoDB MongoTemplate 应用代码 MongoDB MongoTemplate 应用代码 #mermaid-svg-AzYySy7j6yNypaHT{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-AzYySy7j6yNypaHT .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-AzYySy7j6yNypaHT .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-AzYySy7j6yNypaHT .error-icon{fill:#552222;}#mermaid-svg-AzYySy7j6yNypaHT .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-AzYySy7j6yNypaHT .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-AzYySy7j6yNypaHT .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-AzYySy7j6yNypaHT .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-AzYySy7j6yNypaHT .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-AzYySy7j6yNypaHT .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-AzYySy7j6yNypaHT .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-AzYySy7j6yNypaHT .marker{fill:#333333;stroke:#333333;}#mermaid-svg-AzYySy7j6yNypaHT .marker.cross{stroke:#333333;}#mermaid-svg-AzYySy7j6yNypaHT svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-AzYySy7j6yNypaHT p{margin:0;}#mermaid-svg-AzYySy7j6yNypaHT .actor{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-AzYySy7j6yNypaHT text.actor>tspan{fill:black;stroke:none;}#mermaid-svg-AzYySy7j6yNypaHT .actor-line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-AzYySy7j6yNypaHT .innerArc{stroke-width:1.5;stroke-dasharray:none;}#mermaid-svg-AzYySy7j6yNypaHT .messageLine0{stroke-width:1.5;stroke-dasharray:none;stroke:#333;}#mermaid-svg-AzYySy7j6yNypaHT .messageLine1{stroke-width:1.5;stroke-dasharray:2,2;stroke:#333;}#mermaid-svg-AzYySy7j6yNypaHT #arrowhead path{fill:#333;stroke:#333;}#mermaid-svg-AzYySy7j6yNypaHT .sequenceNumber{fill:white;}#mermaid-svg-AzYySy7j6yNypaHT #sequencenumber{fill:#333;}#mermaid-svg-AzYySy7j6yNypaHT #crosshead path{fill:#333;stroke:#333;}#mermaid-svg-AzYySy7j6yNypaHT .messageText{fill:#333;stroke:none;}#mermaid-svg-AzYySy7j6yNypaHT .labelBox{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-AzYySy7j6yNypaHT .labelText,#mermaid-svg-AzYySy7j6yNypaHT .labelText>tspan{fill:black;stroke:none;}#mermaid-svg-AzYySy7j6yNypaHT .loopText,#mermaid-svg-AzYySy7j6yNypaHT .loopText>tspan{fill:black;stroke:none;}#mermaid-svg-AzYySy7j6yNypaHT .loopLine{stroke-width:2px;stroke-dasharray:2,2;stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-AzYySy7j6yNypaHT .note{stroke:#aaaa33;fill:#fff5ad;}#mermaid-svg-AzYySy7j6yNypaHT .noteText,#mermaid-svg-AzYySy7j6yNypaHT .noteText>tspan{fill:black;stroke:none;}#mermaid-svg-AzYySy7j6yNypaHT .activation0{fill:#f4f4f4;stroke:#666;}#mermaid-svg-AzYySy7j6yNypaHT .activation1{fill:#f4f4f4;stroke:#666;}#mermaid-svg-AzYySy7j6yNypaHT .activation2{fill:#f4f4f4;stroke:#666;}#mermaid-svg-AzYySy7j6yNypaHT .actorPopupMenu{position:absolute;}#mermaid-svg-AzYySy7j6yNypaHT .actorPopupMenuPanel{position:absolute;fill:#ECECFF;box-shadow:0px 8px 16px 0px rgba(0,0,0,0.2);filter:drop-shadow(3px 5px 2px rgb(0 0 0 / 0.4));}#mermaid-svg-AzYySy7j6yNypaHT .actor-man line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-AzYySy7j6yNypaHT .actor-man circle,#mermaid-svg-AzYySy7j6yNypaHT line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;stroke-width:2px;}#mermaid-svg-AzYySy7j6yNypaHT :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} bulkOps(UNORDERED, Person.class) ops.remove(query1) 累积到操作列表(未执行) ops.remove(query2) 累积到操作列表(未执行) ops.execute() 一次 bulkWrite 命令(打包全部操作) BulkWriteResult(各操作成败与影响条数) BulkWriteResult
嵌套文档与数组的进阶查询
MongoDB 是文档数据库,字段可以是嵌套文档、也可以是数组,这才是它区别于 MySQL 的主场。以下用项目的场景建一套数据:用户文档里嵌套 address 对象,hobbies 是数组。
demo 工程的实体与集合映射结构如下(mf-mongo-demo):
tree
mf-mongo-demo
├── src/main/java/com/wb/mongo
│ ├── MongoApplication.java # 启动类
│ ├── domain
│ │ └── Person.java # @Document 映射 tb_person 集合
│ └── test/java/com/wb/mongo/test
│ └── MongoTest.java # CRUD 测试(本篇补充 Remove/Bulk 测试)
└── src/main/resources
└── application.yml # mongodb://192.168.136.160:27017/testdb
嵌套字段的查询:点号路径
嵌套字段用 "外层.内层" 的点号路径访问,与 MongoDB shell 里 address.city 的写法一致:
java
//文档结构:{ name: "张三", address: { city: "北京", district: "顺义" } }
Query query = Query.query(Criteria.where("address.city").is("北京"));
List<Person> list = mongoTemplate.find(query, Person.class);
数组字段的匹配:in / all / size
Criteria 对数组字段提供了专门的匹配方式,假设文档里有 hobbies: ["篮球", "游泳", "健身"]:
java
//in:hobbies 数组中包含"篮球"或"游泳"任意一个即命中(对应 $in)
Query queryIn = Query.query(Criteria.where("hobbies").in("篮球", "游泳"));
//all:要求数组同时包含所有列出元素,缺一不可(对应 $all)
Query queryAll = Query.query(Criteria.where("hobbies").all("篮球", "游泳"));
//size:数组长度等于 3 的文档(对应 $size)
Query querySize = Query.query(Criteria.where("hobbies").size(3));
三者的语义差异:in 是「有一个就行」,all 是「全都要有」,size 只看数组长度不看内容。日常最容易把 in 和 all 混用,筛「会 Java 或会 Python 的人」用 in,筛「Java 和 Python 都会的人」用 all。
push / pull:往数组里加、删元素
查询只是读数组,写数组靠 Update 上的 push 和 pull。拿项目后期「动态点赞」的场景举例:每条动态(Movement)维护一个 likeUserIds 数组记录点赞用户,点赞就 $push,取消点赞就 $pull:
java
package com.mf.dubbo.api;
import com.mf.model.mongo.Movement;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.mongodb.core.MongoTemplate;
import org.springframework.data.mongodb.core.query.Criteria;
import org.springframework.data.mongodb.core.query.Query;
import org.springframework.data.mongodb.core.query.Update;
import org.springframework.stereotype.Service;
@Service
public class MovementLikeApi {
@Autowired
private MongoTemplate mongoTemplate;
//点赞:把用户 id 追加到 likeUserIds 数组末尾($push)
public void like(Long movementId, Long userId) {
Query query = Query.query(Criteria.where("id").is(movementId));
Update update = new Update().push("likeUserIds", userId);
mongoTemplate.updateFirst(query, update, Movement.class);
}
//取消点赞:把用户 id 从数组中移除($pull)
public void dislike(Long movementId, Long userId) {
Query query = Query.query(Criteria.where("id").is(movementId));
Update update = new Update().pull("likeUserIds", userId);
mongoTemplate.updateFirst(query, update, Movement.class);
}
}
对照 shell 里的原生语法,push("likeUserIds", userId) 翻译成 { $push: { likeUserIds: userId } },pull 同理。这里隐含一个项目里的真实取舍:点赞数既可以直接对数组取 size 算出来,也可以另存一个计数器字段(前面讲过的 $inc),数组在千人以上后,每次拉整个数组更新的成本会明显上升,项目在推荐计算量大的模块用的是后者。
特别注意:push 是追加元素,set 是整个数组覆盖 。想把新元素挂进数组却写成 update.set("likeUserIds", userId),原数组会被替换成 [userId],其他点赞记录全部丢失,这是数组操作里翻车率最高的一处。
嵌入式文档 vs 引用:设计取舍
MongoDB 没有 join,关联数据要么嵌进来(内嵌文档),要么像项目里 RecommendUser 只存 userId、用户详细资料留在 MySQL 那样(引用),用的时候再查。选型口诀:查得多的一起放,改得少的一方放里面。
| 维度 | 嵌入式文档 | 引用(存 id) |
|---|---|---|
| 查询频率 | 一起查的字段,一次查询全拿到 | 需要二次查询(或跨库调接口) |
| 数据大小 | 子文档膨胀拖累整文档读写,单文档上限 16MB | 主文档保持精瘦 |
| 一致性 | 单文档原子更新,天然一致 | 两处数据可能不一致,需自行维护 |
| 更新频率 | 改得少的一方适合放里面 | 改得频繁的一方适合拆出去单独存 |
| 项目实例 | 动态 + 点赞用户列表 + 评论数 | RecommendUser 只存 userId,资料在 MySQL |
一条动态的评论和点赞跟着动态走(内嵌/计数字段),用户资料被几乎所有模块共享且频繁修改(留在 MySQL 引用),项目的混合存储正是按这个逻辑拆的。
聚合管道初窥:group 计数
按条件查列表之外,「分组统计」这类需求要靠聚合管道(Aggregation Pipeline)。它对应 SQL 的 GROUP BY,这里只给一个最小例子,按城市分组统计人数,不展开:
java
import org.bson.Document;
import org.springframework.data.mongodb.core.aggregation.Aggregation;
import org.springframework.data.mongodb.core.aggregation.AggregationResults;
@Test
public void testGroupCount() {
//等价 SQL:SELECT address, COUNT(*) FROM person GROUP BY address
Aggregation aggregation = Aggregation.newAggregation(
Aggregation.group("address").count().as("total"),
Aggregation.project("total").and("_id").as("address")
);
AggregationResults<Document> results =
mongoTemplate.aggregate(aggregation, "tb_person", Document.class);
results.forEach(doc -> System.out.println(doc));
}
复杂统计(多级分组、$match 过滤、排序拼接)都走这套管道语法,后面的推荐分数统计会再碰到它。
CRUD 全景 API 速查表
两篇合起来,MongoTemplate 的常用 API 全貌如下,右侧给出 MyBatis-Plus 对照方便迁移记忆:
| 操作 | MongoTemplate | MyBatis-Plus 对应 |
|---|---|---|
| 插入单条 | insert(entity) |
mapper.insert(entity) |
| 保存(存在则覆盖更新) | save(entity) |
insertOrUpdate |
| 批量插入 | insertAll(list) |
saveBatch(list) |
| 查询所有 | findAll(Class) |
mapper.selectList(null) |
| 条件查询列表 | find(query, Class) |
mapper.selectList(wrapper) |
| 查询单条 | findOne(query, Class) |
mapper.selectOne(wrapper) |
| 按 ID 查 | findById(id, Class) |
mapper.selectById(id) |
| 条件计数 | count(query, Class) |
mapper.selectCount(wrapper) |
| 排序分页 | query.with(Sort).skip(n).limit(m) |
page + orderBy |
| 更新第一条 | updateFirst(query, update, Class) |
mapper.update(entity, wrapper) |
| 更新多条 | updateMulti(query, update, Class) |
mapper.update(null, wrapper) |
| 存在则改否则插入 | upsert(query, update, Class) |
saveOrUpdate |
| 条件删除 | remove(query, Class) |
mapper.delete(wrapper) |
| 删一条并返回 | findAndRemove(query, Class) |
---(需先查后删) |
| 批量混合操作 | bulkOps(mode, Class) + execute() |
---(手写 SQL 或循环) |
| 分组统计 | Aggregation.group(...).count() |
SQL GROUP BY |
踩坑清单
| 坑 | 后果 | 正确做法 |
|---|---|---|
| insert/save 混用 | 该报错的不报错,或已有数据被静默覆盖 | insert 遇 _id 冲突抛 DuplicateKeyException;save 是整体覆盖的 upsert。新建用 insert,覆盖前确认语义 |
| String/ObjectId 转换异常 | 按 id 查永远返回 null,或直接抛 IllegalArgumentException | 字段名不是 id 时自动转换失效;id 字符串必须是 24 位十六进制。稳妥做法显式 new ObjectId(str) 并在接口层校验 |
| Criteria 字段名写错 | 查询不报错,但结果为空 | Mongo 无 schema 校验,字段拼错就是匹配一个不存在的字段,静默失败。条件字段用集合内字段名(注意 @Field 重命名),可疑时先 mongosh 验证字段存在 |
| skip/limit 参数算错 | 翻页丢数据或重复数据 | skip = (page-1)*size,页码从 1 起;漏乘 size 或页码从 0 起都会错位 |
| 忘记 with 排序 | 同样的查询两次结果顺序不同 | Mongo 不保证无排序查询的返回顺序。凡是「取最值/翻页」,必须显式 .with(Sort.by(...)) |
remove(new Query(), Class) |
空 Query 删光整个集合,且无回滚 | Query 条件必须非空;删除前先 count 校验命中数 |
updateFirst 条件无匹配 |
静默成功,ModifiedCount = 0,不报错 |
核对 UpdateResult;需要「没有就插入」时用 upsert |
数组追加误用 set |
整个数组被单元素覆盖,历史数据丢失 | 追加用 push,移除用 pull,覆盖语义才用 set |
BulkOperations 漏调 execute() |
操作全在内存队列里,一条都没执行,也不报错 | 组装完必须 execute(),并核对返回的 BulkWriteResult |
in 与 all 混用 |
「或」筛成「且」,结果集不符预期 | 包含任一用 in,同时包含所有用 all |
| 跨文档删除当事务用 | 中途失败,已删部分无法自动恢复 | 先 count、先备份;确需事务要副本集 + 事务 API(项目未用) |
验证:可复现的完整流程
用 mongosh 对照验证「批量插入 → 条件删除 → p u s h / push/ push/pull」:
shell
# 1. 批量插入后确认数量(对应 insertAll)
db.tb_person.countDocuments() # 预期:1000
# 2. 删除前先 count 确认命中范围(对应先 count 再删)
db.tb_person.count({ age: { $gt: 40 } }) # 假设命中 520,确认后再删
db.tb_person.deleteMany({ age: { $gt: 40 } })
db.tb_person.countDocuments() # 预期:480
# 3. 验证 $push:往点赞数组追加元素
db.movement.updateOne(
{ _id: ObjectId("6520a1b2c3d4e5f6a7b8c9d0") },
{ $push: { likeUserIds: NumberLong(106) } }
)
db.movement.findOne({}, { likeUserIds: 1 }) # 数组末尾多出 106
# 4. 验证 $pull:取消点赞
db.movement.updateOne(
{ _id: ObjectId("6520a1b2c3d4e5f6a7b8c9d0") },
{ $pull: { likeUserIds: NumberLong(106) } }
)
# 5. 误用 set 的对照组:整个数组被覆盖成一个元素
db.movement.updateOne(
{ _id: ObjectId("6520a1b2c3d4e5f6a7b8c9d0") },
{ $set: { likeUserIds: [NumberLong(106)] } }
)
db.movement.findOne({}, { likeUserIds: 1 }) # likeUserIds 只剩 [106]
# 6. 分组统计对照(对应 Aggregation.group().count())
db.tb_person.aggregate([
{ $group: { _id: "$address", total: { $sum: 1 } } }
])
每一步的 Java 调用和 shell 命令一一对应,跑完一遍,push/set 的差别就刻在肌肉记忆里了。
常见误解澄清
| 误解 | 事实 |
|---|---|
| save 是「只更新变化字段」 | save 是整个文档替换(不存在则插入);改部分字段要用 Update.set() |
| Mongo 查询字段写错会像 MySQL 一样报错 | 不会。Mongo 无 schema,查不存在的字段等价于空条件匹配,返回空结果而非报错 |
findById 必须传 ObjectId 对象 |
实体主键字段名为 id 时传 String 会自动转换;但依赖这个隐式行为不如显式转换可靠 |
updateFirst 找不到匹配文档会报错 |
不报错,返回结果里 matchedCount 为 0,需要自己判断 |
| Mongo 分页必须用 skip/limit | skip/limit 是最直白的做法,深分页性能差;生产上常用「记录上一页末尾 id + gt 条件」的游标式分页 |
remove 和 MySQL 一样误删能回滚 |
MongoDB 单条写是原子的,但跨文档删除没有自动回滚;多文档事务要 4.0+ 且需副本集,项目未启用 |
remove 一次只删一条 |
remove 删除所有 匹配文档;只删一条用 findAndRemove(删第一条并返回实体) |
BulkOperations 加完操作就生效了 |
加操作只是入队,必须调 execute() 才发往服务端 |
insert 和 save 完全一样 |
insert 遇到相同 _id 直接报错;save 会覆盖已有文档,批量场景按语义选,别混 |
| 嵌套字段查询要先把外层文档查出来 | 用点号路径 "address.city" 一次查询直达,不需要两段式查询 |
$push 和 $set 更新数组效果差不多 |
$push 追加元素,$set 整体覆盖数组,语义完全不同 |
API 速览
| 名称 | 类别 | 用途 |
|---|---|---|
spring-boot-starter-data-mongodb |
依赖 | Spring Data MongoDB 全套能力入口 |
mongodb-driver-sync |
依赖 | 官方驱动,由 starter 传递引入 |
spring.data.mongodb.uri |
配置 | 连接串,末段为库名 |
spring.mongodb.host/port/database |
配置 | 拆开式写法,2.3.x 可用,推荐改用 uri |
@Document(collection) |
注解 | 类 → 集合映射 |
@Id |
注解 | 属性 → _id 主键映射,可按约定省略 |
@Field |
注解 | 属性名 → 文档字段名映射 |
MongoTemplate |
Bean | 自动配置的数据访问入口,CRUD 核心对象 |
insert(obj) |
纯插入 | _id 冲突抛 DuplicateKeyException |
save(obj) |
插入或整体覆盖 | upsert 语义,谨慎使用 |
findById(id, Class) |
按主键查 | 支持自动/显式 ObjectId 转换 |
find(query, Class) |
条件查询,返回 List | Query = Criteria + Sort + skip/limit |
findOne(query, Class) |
取第一条 | 配 Sort 用于取最值 |
count(query, Class) |
计数 | 与列表查询条件保持一致 |
updateFirst(query, update, Class) |
更新第一条 | Update.set() 对应 $set |
Update.inc(field, n) |
原子自增 | 对应 $inc,字段不存在按 0 起步 |
官方文档
- Spring Data MongoDB 官方文档(MongoTemplate 与 Query/Criteria 的权威参考)
- Spring Data MongoDB 项目主页
- MongoDB 手册:update 修改器
$set/$inc的底层语义 - MongoTemplate API
- MongoDB 原子性与事务
- update 操作符(push / pull / $set)
总结
- 接入走 Dubbo 域服务模式:mf-dubbo-mongo 与 mf-dubbo-db 完全对称,应用层只认接口,存储可插拔。依赖只认一个 starter,驱动版本交给 Spring Boot parent;实体在 mf-model 里,starter 也声明在 model,靠依赖传递生效。连接配置用
spring.data.mongodb.uri,末段库名是 mf;协议头mongodb://,副本集写法后续集群篇再讲。 - 两种操作方式里,交友项目选 MongoTemplate:动态条件、排序截断这类需求用 Criteria 链式代码表达最自然。
- 实体映射三件套 @Document / @Id / @Field,逐一对照 @TableName / @TableId / @TableField 迁移;@Id 可按约定省略,@Field 不写则默认取属性名,但显式写出更稳。条件查询里
Criteria.where()写的是文档字段名,不是 Java 属性名。 - insert 与 save 的语义差别是这里最重要的记忆点:insert 冲突报错,save 静默覆盖,选错就是数据事故。
- 查询三步固定:Criteria 定条件(写集合字段名)→ Query 组装(排序、skip/limit)→ find/findOne/count。MyBatis-Plus 的 QueryWrapper 经验可以直接平移。
- 更新用 updateFirst +
Update.set(),计数用inc()原子自增,别用「查出来改完再 save」的方式丢并发更新。Mongo 的静默失败(字段拼错、id 转换失效)比 SQL 报错更难排查,拿不准就开 mongosh 对照验证。 - 删除三件套:
remove按条件删全部、findFirstAndRemove删一条并返回实体、findAllAndRemove删全部并返回列表;条件构建与查询共用Query + Criteria,MyBatis-Plus 的 Wrapper 心智可直接平移。删除没有后悔药:先count确认命中范围,拿不准用findAndRemove试探,线上数据先备份。 - 批量写入用
insertAll和BulkOperations,把 N 次网络往返压成一次;组装完别忘了execute()。 - 嵌套与数组是 MongoDB 的主场:点号路径查嵌套字段,
in/all/size匹配数组,push/pull维护数组元素;设计上「查得多的一起放,改得少的一方放里面」。分组统计走聚合管道Aggregation.group().count(),对应 SQL 的 GROUP BY。