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 就能搞定,但遇到多条件动态查询、聚合管道、updateManybulkWrite 等场景,Template 才是正解。

技术映射:MongoRepository ≈ JpaRepository(声明式),MongoTemplate ≈ JdbcTemplate(命令式)。两者的底层连接池、序列化配置是共享的,都通过 MongoClient 管理。

小白:那连接池参数呢?Spring Boot 默认值是多少,我需要改什么?

大师:默认值很大方——maxPoolSize = 100minPoolSize = 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.Datejava.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 思考题

  1. Spring Data MongoDB 的分页查询 Page<Product> 在底层是如何转换为 MongoDB 命令的?它用 skip + limit 还是游标分页?
  2. 如果要在 Spring Boot 中实现 MongoDB 的主从读写分离(写 Primary,读 Secondary),应该怎么配置?读写分离有哪些一致性陷阱?

(答案将在第 13 章末尾揭晓)


上一章思考题答案

  1. 事务内先 insert 再 update 同一文档如发生 WriteConflictException,会重试整个事务,而非仅重试冲突操作。MongoDB 在 commit 时检测冲突——如果事务读过的文档在事务期间被其他操作修改了,commit 触发的 WriteConflictException 会导致整个事务 abort 并由应用层重试。这就是快照隔离下的乐观并发控制(Optimistic Concurrency Control)——提交时检测冲突,而非操作时阻塞。

  2. 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 与私有化大模型实战

大型语言模型(LLM) vLLM 高性能推理落地实战

Agent开发之LlamaIndex 实战修炼与源码进阶

大语言模型Transformers 实战修炼与源码剖析

posted on 2026-07-25 21:51  一天不进步,就是退步  阅读(2)  评论(0)    收藏  举报