Java 项目实战: 交友项目 - SpringData-Mongo 环境搭建和 CRUD

概述

前面命令行里敲过的那些 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 起步

官方文档

总结

  • 接入走 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。
相关推荐
SunnyDays10111 小时前
Java 操作 PPT 超链接:添加、修改和删除
java·powerpoint·ppt·超链接
Thomas.Sir1 小时前
第4课:配置文件详解:application.yml/properties,多环境配置、配置分组、配置校验
java·spring boot
凯瑟琳.奥古斯特1 小时前
动态调优IoU提升OCR文本合并精度
开发语言·python·算法
陌陌龙1 小时前
【紧急预警!PHP 核弹级漏洞 php2shell】
开发语言·php
白露与泡影1 小时前
一线 Java 后端面试八股(2026 更新版,含完整答案)
java·开发语言·面试
SL_staff1 小时前
APS落地失败的5个隐形坑:离散制造排产为何总在Excel里打转?
java·人工智能·开源
企业解惑小能手-域智盾系统2 小时前
内网监控软件策略不生效、日志缺失?一套从现象到根因的排查框架
开发语言·php·域智盾
1024奇点2 小时前
【仓颉语言入门 · 第二篇 第 10 课】
开发语言·数据库·ide·mysql·开源
Code-zyc2 小时前
读取电表数据并展示
java