1. 项目背景
业务场景:本地生活电商的 Java 技术栈已经固定:Spring Boot 3 + Java 21。前几章的内容都是用 mongosh 演示的,开发团队要问:"这些都很好,但怎么落到 Spring Boot 代码里?MongoTemplate 和 MongoRepository 有什么区别?连接池参数怎么配?测试怎么跑?" 更关键的是,有开发同事直接用 MongoRepository 做了一个 5 层 $lookup 的聚合查询,运行时内存直接爆炸,还不清楚原因。
痛点:从 mongosh 到 Java 代码不止是语法翻译——连接池大小配置不当导致线上间歇性超时;序列化/反序列化配置缺失导致 Date 字段差了 8 小时;MongoTemplate 和 MongoRepository 的职责混淆,Repository 写复杂聚合查询代码又丑又慢;单元测试依赖真实 MongoDB 启动,CI 管道跑一次 5 分钟;分页查询在 MongoDB Driver 中的实现方式与 SQL 不同,直接照搬 MyBatis 分页插件思路。
2. 项目设计
小胖(抱着笔记本冲进会议室):大师!我从 mongosh 切换到 Spring Boot,光是连接就报了一堆错。MongoDB Java Driver、Spring Data MongoDB、MongoTemplate、MongoRepository……这几个东西到底是什么关系?
大师:分层关系,从底层到上层:
MongoDB Java Driver (最底层,连接、序列化、命令发送)
↓
Spring Data MongoDB (封装 Driver,提供 MongoTemplate + Repository)
↓
MongoTemplate / MongoRepository (你用这俩就够了)
- MongoDB Java Driver:官方驱动,处理连接池、BSON 序列化、网络通信。
- Spring Data MongoDB:Spring 对官方驱动的高层封装,提供模板方法和 Repository 抽象。
- MongoTemplate:命令式 API,让你像 mongosh 一样灵活地执行任意 MongoDB 操作——聚合、批量写入、原生命令。
- MongoRepository:声明式 API,继承接口就能自动生成 CRUD 方法,像 Spring Data JPA 一样方便。
小胖:那我到底该用哪个?
大师:记住一个原则——"简单 CRUD 用 Repository,复杂查询/聚合/批量操作用 Template"。80% 的业务代码用 Repository 就能搞定,但遇到多条件动态查询、聚合管道、updateMany、bulkWrite 等场景,Template 才是正解。
技术映射:MongoRepository ≈ JpaRepository(声明式),MongoTemplate ≈ JdbcTemplate(命令式)。两者的底层连接池、序列化配置是共享的,都通过 MongoClient 管理。
小白:那连接池参数呢?Spring Boot 默认值是多少,我需要改什么?
大师:默认值很大方——maxPoolSize = 100,minPoolSize = 0。但真正的坑不是大小,而是超时和等待队列。关键参数:
| 参数 | 默认值 | 含义 | 何时调 |
|---|---|---|---|
maxPoolSize |
100 | 最大连接数 | 高并发写入时调到 200-500 |
minPoolSize |
0 | 最小空闲连接 | 设为 10-20 避免冷启动耗时 |
maxIdleTimeMS |
0(永不过期) | 空闲连接最大存活 | 设为 600000(10 分钟) |
waitQueueTimeoutMS |
120000 | 等待可用连接的超时 | 减小到 2000(2 秒),快速失败 |
connectTimeoutMS |
10000 | 连接建立超时 | 保持默认 |
小胖:waitQueueTimeoutMS 为什么建议调小?2 秒拿不到连接就失败是不是太快了?
大师:因为正常查询不应该等连接——如果你的连接池满了,说明系统已经过载。与其让用户等 2 分钟再失败,不如 2 秒就返回"系统繁忙,请稍后重试"。这叫"快速失败(Fail Fast)",是分布式系统的基本素养。
技术映射:连接池满时,新的请求会进入等待队列。waitQueueTimeoutMS 决定"排多久还轮不上就报错"。
小白(继续追问):那序列化呢?Java 的 LocalDateTime 和 MongoDB 的 ISODate 怎么对应?我之前遇到过差了 8 小时的 bug。
大师:这是 Spring Data MongoDB 和 Java 时间 API 之间的"时区陷阱"。关键配置:
spring:
data:
mongodb:
uri: mongodb://admin:admin123@localhost:27017/local_life?authSource=admin
然后在代码中:
- 存日期:用
java.util.Date或java.time.Instant(推荐),MongoDB 始终存 UTC。 - 取日期:Spring Data MongoDB 默认把 UTC 转为本地时区(GMT+8),如果前后端时区不一致就出现"差 8 小时"。
- 解决:统一用
Instant作为 DTO 的时间类型,用@DateTimeFormat或自定义 Converter 控制序列化。
大师(总结):今天记住三件事——简单操作用 Repository,复杂操作用 Template;连接池不要用默认的等待 2 分钟,改成 2 秒快速失败;时间类型统一用 Instant,避免时区黑洞。
3. 项目实战
3.1 环境准备
| 组件 | 版本 | 用途 |
|---|---|---|
| Java | 21 | 运行时 |
| Spring Boot | 3.x | 应用框架 |
| spring-boot-starter-data-mongodb | 最新 | Spring Data MongoDB 集成 |
| Testcontainers | 1.19+ | 集成测试的 MongoDB 容器 |
| JUnit 5 | 5.10+ | 测试框架 |
Maven 依赖(pom.xml 核心片段):
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-mongodb</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<!-- 测试 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>testcontainers</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>mongodb</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>junit-jupiter</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
application.yml:
spring:
data:
mongodb:
uri: mongodb://admin:admin123@localhost:27017/local_life?authSource=admin
# 连接池参数
connection-pool:
max-size: 100
min-size: 10
max-wait-time: 2000ms # waitQueueTimeoutMS
max-connection-idle-time: 600s
max-connection-life-time: 1800s
3.2 分步实现
步骤一:定义实体与 Repository
目标:创建商品实体类和声明式 Repository。
// Product.java
import org.springframework.data.annotation.Id;
import org.springframework.data.mongodb.core.index.Indexed;
import org.springframework.data.mongodb.core.mapping.Document;
import org.springframework.data.mongodb.core.mapping.Field;
import java.math.BigDecimal;
import java.time.Instant;
import java.util.List;
@Document(collection = "products") // 映射到 products 集合
public class Product {
@Id
private String id;
@Indexed // 自动建索引(仅开发环境推荐)
private String name;
private String category;
private BigDecimal price; // Decimal128 → BigDecimal
private Integer stock;
private List<String> tags;
private String status; // "在售" / "下架" / "已删除"
@Field("created_at")
private Instant createdAt; // ISODate → Instant(UTC)
@Field("updated_at")
private Instant updatedAt;
// getters and setters ...
}
// ProductRepository.java
import org.springframework.data.mongodb.repository.MongoRepository;
import org.springframework.data.mongodb.repository.Query;
import org.springframework.stereotype.Repository;
import java.util.List;
import java.util.Optional;
@Repository
public interface ProductRepository extends MongoRepository<Product, String> {
// 方法命名查询:Spring 自动解析方法名生成查询
List<Product> findByCategoryAndStatus(String category, String status);
List<Product> findByPriceBetween(BigDecimal min, BigDecimal max);
Optional<Product> findByName(String name);
// 自定义查询(MongoDB JSON 查询语法)
@Query("{ 'category': ?0, 'price': { $gte: ?1, $lte: ?2 }, 'status': '在售' }")
List<Product> findActiveByCategoryAndPriceRange(
String category, BigDecimal minPrice, BigDecimal maxPrice);
// 分页查询
Page<Product> findByStatus(String status, Pageable pageable);
}
步骤二:MongoTemplate 聚合与批量写入
目标:用 MongoTemplate 实现 Repository 无法优雅表达的操作。
// ProductService.java
import org.springframework.data.domain.Sort;
import org.springframework.data.mongodb.core.MongoTemplate;
import org.springframework.data.mongodb.core.aggregation.*;
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;
import com.mongodb.bulk.BulkWriteResult;
import org.springframework.data.mongodb.core.BulkOperations;
import java.math.BigDecimal;
@Service
public class ProductService {
private final MongoTemplate mongoTemplate;
private final ProductRepository productRepository;
public ProductService(MongoTemplate mongoTemplate, ProductRepository productRepository) {
this.mongoTemplate = mongoTemplate;
this.productRepository = productRepository;
}
// ---- Template:批量更新 ----
public long batchUpdateStatus(String category, String newStatus) {
Query query = new Query(Criteria.where("category").is(category));
Update update = new Update()
.set("status", newStatus)
.set("updatedAt", Instant.now());
// updateMulti = updateMany
return mongoTemplate.updateMulti(query, update, Product.class)
.getModifiedCount();
}
// ---- Template:聚合管道 ----
public List<CategoryStats> categoryStats() {
// $match
MatchOperation match = Aggregation.match(
Criteria.where("status").is("在售"));
// $group
GroupOperation group = Aggregation.group("category")
.count().as("productCount")
.avg("price").as("avgPrice")
.sum("stock").as("totalStock");
// $sort
SortOperation sort = Aggregation.sort(Sort.by(Sort.Direction.DESC, "productCount"));
Aggregation agg = Aggregation.newAggregation(match, group, sort);
return mongoTemplate.aggregate(agg, "products", CategoryStats.class)
.getMappedResults();
}
// ---- Template:批量写入(ordered: false) ----
public int bulkInsert(List<Product> products) {
BulkOperations bulkOps = mongoTemplate.bulkOps(
BulkOperations.BulkMode.UNORDERED, Product.class);
products.forEach(bulkOps::insert);
BulkWriteResult result = bulkOps.execute();
return result.getInsertedCount();
}
// ---- Template:原子库存扣减 ----
public boolean deductStock(String productId, int quantity) {
Query query = new Query(Criteria.where("id").is(productId)
.and("stock").gte(quantity));
Update update = new Update()
.inc("stock", -quantity)
.set("updatedAt", Instant.now());
return mongoTemplate.updateFirst(query, update, Product.class)
.getModifiedCount() > 0;
}
}
// CategoryStats.java (DTO)
public class CategoryStats {
private String id; // $group 的 _id 映射到 id
private int productCount;
private BigDecimal avgPrice;
private int totalStock;
// getters/setters ...
}
步骤三:连接池配置与优雅关闭
目标:自定义 MongoClient 参数以优化连接管理。
// MongoConfig.java
import com.mongodb.ConnectionString;
import com.mongodb.MongoClientSettings;
import com.mongodb.client.MongoClient;
import com.mongodb.client.MongoClients;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.mongodb.core.MongoTemplate;
import java.util.concurrent.TimeUnit;
@Configuration
public class MongoConfig {
@Value("${spring.data.mongodb.uri}")
private String mongoUri;
@Bean
public MongoClient mongoClient() {
MongoClientSettings settings = MongoClientSettings.builder()
.applyConnectionString(new ConnectionString(mongoUri))
.applyToConnectionPoolSettings(builder -> builder
.maxSize(100)
.minSize(10)
.maxWaitTime(2000, TimeUnit.MILLISECONDS)
.maxConnectionIdleTime(10, TimeUnit.MINUTES)
.maxConnectionLifeTime(30, TimeUnit.MINUTES))
.applyToSocketSettings(builder -> builder
.connectTimeout(5000, TimeUnit.MILLISECONDS)
.readTimeout(30000, TimeUnit.MILLISECONDS))
.build();
return MongoClients.create(settings);
}
@Bean
public MongoTemplate mongoTemplate(MongoClient mongoClient) {
return new MongoTemplate(mongoClient, "local_life");
}
}
# application.yml 完整版
spring:
data:
mongodb:
uri: mongodb://admin:admin123@localhost:27017/local_life?authSource=admin&retryWrites=true
# 优雅关闭保证请求处理完毕再退出
server:
shutdown: graceful
spring:
lifecycle:
timeout-per-shutdown-phase: 30s
步骤四:Testcontainers 集成测试
目标:不依赖外部 MongoDB,用容器在测试中自动启动数据库。
// ProductServiceTest.java
import org.junit.jupiter.api.*;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.context.DynamicPropertyRegistry;
import org.springframework.test.context.DynamicPropertySource;
import org.testcontainers.containers.MongoDBContainer;
import org.testcontainers.junit.jupiter.Container;
import org.testcontainers.junit.jupiter.Testcontainers;
import org.testcontainers.utility.DockerImageName;
import java.math.BigDecimal;
import java.time.Instant;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
@SpringBootTest
@Testcontainers
class ProductServiceTest {
@Container
static MongoDBContainer mongoDBContainer =
new MongoDBContainer(DockerImageName.parse("mongo:8.0"));
@DynamicPropertySource
static void setProperties(DynamicPropertyRegistry registry) {
registry.add("spring.data.mongodb.uri", mongoDBContainer::getReplicaSetUrl);
}
@Autowired
private ProductRepository productRepository;
@Autowired
private ProductService productService;
@BeforeEach
void setUp() {
productRepository.deleteAll();
}
@Test
void shouldInsertAndFindProduct() {
Product product = new Product();
product.setName("测试耳机");
product.setCategory("数码");
product.setPrice(new BigDecimal("199.00"));
product.setStock(100);
product.setStatus("在售");
product.setTags(List.of("蓝牙", "降噪"));
product.setCreatedAt(Instant.now());
product.setUpdatedAt(Instant.now());
Product saved = productRepository.save(product);
assertThat(saved.getId()).isNotNull();
List<Product> found = productRepository.findByCategoryAndStatus("数码", "在售");
assertThat(found).hasSize(1);
assertThat(found.get(0).getName()).isEqualTo("测试耳机");
}
@Test
void shouldDeductStockAtomically() {
Product product = new Product();
product.setName("库存测试");
product.setStock(10);
product.setPrice(new BigDecimal("99"));
product.setCreatedAt(Instant.now());
product.setUpdatedAt(Instant.now());
Product saved = productRepository.save(product);
boolean success = productService.deductStock(saved.getId(), 3);
assertThat(success).isTrue();
Product updated = productRepository.findById(saved.getId()).orElseThrow();
assertThat(updated.getStock()).isEqualTo(7);
// 扣减超过库存 → 失败
boolean fail = productService.deductStock(saved.getId(), 100);
assertThat(fail).isFalse();
}
@Test
void shouldAggregateCategoryStats() {
// 插入测试数据
for (int i = 0; i < 5; i++) {
Product p = new Product();
p.setName("统计商品" + i);
p.setCategory(i < 3 ? "数码" : "家居");
p.setPrice(new BigDecimal(100 + i * 50));
p.setStock(10 + i);
p.setStatus("在售");
p.setCreatedAt(Instant.now());
p.setUpdatedAt(Instant.now());
productRepository.save(p);
}
List<CategoryStats> stats = productService.categoryStats();
assertThat(stats).hasSize(2);
assertThat(stats.stream().filter(s -> "数码".equals(s.getId()))
.findFirst().get().getProductCount()).isEqualTo(3);
}
}
步骤五:分页与排序的 Spring Data 方式
// ProductController.java
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Sort;
import org.springframework.web.bind.annotation.*;
@RestController
@RequestMapping("/api/products")
public class ProductController {
private final ProductRepository productRepository;
private final MongoTemplate mongoTemplate;
public ProductController(ProductRepository productRepository, MongoTemplate mongoTemplate) {
this.productRepository = productRepository;
this.mongoTemplate = mongoTemplate;
}
// Repository 分页
@GetMapping
public Page<Product> list(
@RequestParam(defaultValue = "在售") String status,
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "20") int size) {
PageRequest pageable = PageRequest.of(
page, size,
Sort.by(Sort.Direction.DESC, "createdAt"));
return productRepository.findByStatus(status, pageable);
}
// Template 动态条件分页
@GetMapping("/search")
public List<Product> search(
@RequestParam(required = false) String category,
@RequestParam(required = false) BigDecimal minPrice,
@RequestParam(required = false) BigDecimal maxPrice,
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "20") int size) {
Query query = new Query();
query.addCriteria(Criteria.where("status").is("在售"));
if (category != null) query.addCriteria(Criteria.where("category").is(category));
if (minPrice != null || maxPrice != null) {
Criteria priceCriteria = Criteria.where("price");
if (minPrice != null) priceCriteria = priceCriteria.gte(minPrice);
if (maxPrice != null) priceCriteria = priceCriteria.lte(maxPrice);
query.addCriteria(priceCriteria);
}
query.with(Sort.by(Sort.Direction.DESC, "createdAt"))
.with(PageRequest.of(page, size));
return mongoTemplate.find(query, Product.class);
}
}
3.3 完整代码清单
| 文件 | 用途 |
|---|---|
pom.xml |
Maven 依赖配置 |
application.yml |
Spring Boot + MongoDB 配置 |
Product.java |
商品实体类 |
ProductRepository.java |
声明式 Repository |
ProductService.java |
MongoTemplate 复杂操作 |
MongoConfig.java |
连接池自定义配置 |
ProductController.java |
REST 接口 |
ProductServiceTest.java |
Testcontainers 集成测试 |
3.4 测试验证
# 运行测试(在项目根目录)
mvn test -Dtest=ProductServiceTest
# 期望输出:
# [INFO] Tests run: 3, Failures: 0, Errors: 0, Skipped: 0
# 启动应用
mvn spring-boot:run
# 测试接口
curl -u admin:admin123 http://localhost:8080/api/products?status=在售
curl -X POST http://localhost:8080/api/products \
-H "Content-Type: application/json" \
-d '{"name":"API测试商品","category":"数码","price":199.00,"stock":50}'
4. 项目总结
4.1 MongoRepository vs MongoTemplate
| 维度 | MongoRepository | MongoTemplate |
|---|---|---|
| 编码风格 | 声明式(接口方法) | 命令式(代码逻辑) |
| CRUD 简洁度 | 极高(继承即有) | 中(需手动写 Query) |
| 复杂聚合 | 差(仅 @Query) |
优(Aggregation DSL) |
| 批量操作 | 无(需 saveAll) | 优(bulkWrite) |
| 原生命令 | 不支持 | 支持(executeCommand) |
| 学习曲线 | 低 | 中 |
| 推荐占比 | 80% 常规操作 | 20% 复杂/批量/聚合 |
4.2 适用场景
MongoRepository 适用:简单 CRUD、按固定条件查询、分页列表、按 ID 查询。
MongoTemplate 适用:聚合管道、批量写入、原子更新($inc)、动态条件查询、原生 mongosh 命令、索引管理。
4.3 注意事项
| 注意事项 | 说明 |
|---|---|
@Indexed 慎用 |
开发环境方便,生产环境用脚本显式管理索引 |
| BigDecimal 映射 | MongoDB 的 Decimal128 与 Java BigDecimal 自动映射 |
| Instant 而非 LocalDateTime | LocalDateTime 不带时区信息,序列化后可能对不齐 |
| 连接不释放 | 确认 session.endSession() 或使用 try-with-resources |
| Repository 方法名过长 | Spring Data 解析长方法名可能生成错误查询,超过 3 个条件用 @Query |
4.4 常见踩坑经验
故障案例一:连接池耗尽导致雪崩
某服务 maxPoolSize=10,QPS 500,平均响应 50ms。大促期间 QPS 翻倍,响应变慢(下游依赖延迟),连接持有时间变长,连接池满,新请求等待队列排到 2 分钟,用户请求雪崩。根因:连接池太小且 waitQueueTimeoutMS 未设置(默认 2 分钟)。解决:maxPoolSize 调到 50,waitQueueTimeoutMS 设为 2 秒,配合限流和熔断。
故障案例二:LocalDateTime 与 ISODate 时区错位
某国际化项目,中国开发存 LocalDateTime.now(),欧洲用户前端解析后发现时间比实际晚了 8 小时——因为 MongoDB 存储的是 UTC,Spring 在序列化时加了 GMT+8,前端又把 UTC 当本地时间展示。解决:统一用 Instant 存数据库,DTO 转换时用 ZonedDateTime 并返回 ISO-8601 格式含时区后缀的字符串。
故障案例三:MongoRepository 自动建索引风险
开发在实体类上到处加 @Indexed,Spring Boot 启动时自动在 MongoDB 中创建索引。某次线上重启,一个不合理的复合索引创建耗时 3 分钟,阻塞了启动流程,导致滚动发布中断。解决:生产环境关闭 spring.data.mongodb.auto-index-creation=true 改为 false,索引变更通过专门的变更工单和脚本执行。
4.5 思考题
- Spring Data MongoDB 的分页查询
Page<Product>在底层是如何转换为 MongoDB 命令的?它用skip + limit还是游标分页? - 如果要在 Spring Boot 中实现 MongoDB 的主从读写分离(写 Primary,读 Secondary),应该怎么配置?读写分离有哪些一致性陷阱?
(答案将在第 13 章末尾揭晓)
上一章思考题答案:
事务内先 insert 再 update 同一文档如发生 WriteConflictException,会重试整个事务,而非仅重试冲突操作。MongoDB 在 commit 时检测冲突——如果事务读过的文档在事务期间被其他操作修改了,commit 触发的 WriteConflictException 会导致整个事务 abort 并由应用层重试。这就是快照隔离下的乐观并发控制(Optimistic Concurrency Control)——提交时检测冲突,而非操作时阻塞。
MongoDB 不支持 READ UNCOMMITTED 的原因是 WiredTiger 的 MVCC 在启动时就无法读取未提交数据——每个事务看到的是一个一致的时间点快照(Snapshot)。MySQL 的 REPEATABLE READ 是保证事务内同一查询多次执行结果相同(通过锁或 MVCC 实现),但可能发生幻读(InnoDB 用 Next-Key Lock 消除了幻读)。MongoDB 快照隔离和 InnoDB 的 REPEATABLE READ 实质相近,但 MongoDB 统一用一种隔离级别简化了使用者的选择负担。
延伸阅读与资源
MongoDB 实战进阶与内核修炼
python入门:Rquests从菜鸟脚本到企业级SDK的网络实战圣经
Milvus向量数据库实战修炼:从 0 到 1精通向量检索与生产落地
后端工程师的 AI 转型第一课:Ollama 与私有化大模型实战
10倍开发者的 Dify 魔法书:从零构建全栈 AI 应用
后端工程师转型AI第一课-Ollama 与私有化大模型实战

微信公众号: 架构师日常笔记 欢迎关注!
浙公网安备 33010602011771号