09. Spring Data MongoDB 实战

Spring Data MongoDB: Repository、MongoTemplate、聚合查询、事务与监听的完整实践

Spring Data MongoDB 是 Spring 生态中对 MongoDB 的官方支持,提供了 Repository 接口和 MongoTemplate 两种操作模式。本文覆盖配置、基础 CRUD、复杂查询、聚合、事务、实时监听、大文件存储、地理空间查询、全文检索以及索引优化,帮助开发者构建生产级 MongoDB 应用。

1. 配置

Spring Boot 通过 spring-boot-starter-data-mongodb 提供开箱即用的 MongoDB 集成。推荐使用 URI 方式连接,可一次性指定副本集、读写偏好、连接池大小以及认证信息。此外,MongoDB 4.2+ 建议使用事务,因此连接字符串中的副本集参数 replicaSet=rs0 是必需的,因为 MongoDB 的事务必须运行在副本集或分片集群上。

# application.yml
spring:
  data:
    mongodb:
      uri: mongodb://user:pass@mongo1:27017,mongo2:27017/mydatabase?replicaSet=rs0&readPreference=primaryPreferred&w=majority
  # 可选连接池参数
      # connection-pool:
        # min-size: 10
        # max-size: 200
        # max-wait-time: 2000
        # max-connection-life-time: 0
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-mongodb</artifactId>
</dependency>

w=majority 确保写入操作在大多数节点提交后才返回成功,保障数据一致性。readPreference=primaryPreferred 让读请求优先走主节点,主节点不可用时自动切换到从节点,兼顾一致性和可用性。连接池大小应根据并发量调整,避免连接耗尽导致服务降级。

2. 实体映射

Spring Data MongoDB 使用一套丰富的注解完成 Java 对象与 MongoDB 文档的双向映射。@Document 指定集合名,@Id 映射 _id 字段,@Field 自定义字段名,@DBRef 建立引用关系。对于嵌套文档,直接使用 Java 对象即可,MongoDB 驱动会自动完成序列化与反序列化。@CompoundIndex 可以在类级别声明复合索引,提升查询效率。

@Document(collection = "products")
@CompoundIndex(name = "category_price", def = "{'category': 1, 'price': -1}")
@CompoundIndex(name = "name_text", def = "{'name': 'text'}")
public class Product {
    @Id
    private String id;

    private String name;
    private String category;
    private BigDecimal price;
    private int stock;

    @Field("created_at")
    private Instant createdAt;

    @GeoSpatialIndexed(type = GeoSpatialIndexType.GEO_2DSPHERE)
    private GeoJsonPoint location;

    @DBRef
    private Supplier supplier;

    private List<Review> reviews;
    private Map<String, String> attributes;

    @Version
    private Long version;
}

public record Review(String userId, int rating, String comment, Instant date) {}

@Version 字段为乐观锁机制提供支持,当并发的更新操作检测到版本号不一致时会抛出 OptimisticLockingFailureException,从而防止脏写。@GeoSpatialIndexed 定义地理空间索引,用于后续的 $near、$geoWithin 查询。Map 类型的字段可以灵活存储动态属性,MongoDB 的文档模型天然支持异构数据结构。

3. Repository 接口

继承 MongoRepository 后,Spring Data MongoDB 会根据方法签名自动生成查询实现,这称为派生查询方法。命名规则遵循 findBy + 属性名 + 操作符 的模式,支持 And、Or、Like、Between、In、Exists 等多种组合。对于复杂查询,可使用 @Query 注解直接编写原生 JSON 查询,或使用 @Aggregation 注解声明聚合管道。

public interface ProductRepository extends MongoRepository<Product, String> {

    List<Product> findByCategoryAndPriceLessThan(String category, BigDecimal price);

    List<Product> findByNameContainingIgnoreCase(String name);

    Page<Product> findByCategoryOrderByPriceDesc(String category, Pageable pageable);

    boolean existsByName(String name);

    long countByCategory(String category);

    @Query("{ 'category': ?0, 'price': { $gte: ?1, $lte: ?2 } }")
    List<Product> findByPriceRange(String category, BigDecimal min, BigDecimal max);

    @Query(value = "{ 'category': ?0 }", fields = "{ 'name': 1, 'price': 1 }")
    List<Product> findNamesAndPricesByCategory(String category);

    @Aggregation(pipeline = {
        "{ $match: { category: ?0 } }",
        "{ $group: { _id: null, avgPrice: { $avg: '$price' }, totalStock: { $sum: '$stock' } } }"
    })
    ProductStats getStatsByCategory(String category);

    @Aggregation(pipeline = {
        "{ $match: { category: ?0 } }",
        "{ $lookup: { from: 'suppliers', localField: 'supplier.$id', foreignField: '_id', as: 'supplierInfo' } }",
        "{ $unwind: '$supplierInfo' }",
        "{ $project: { name: 1, supplierName: '$supplierInfo.name' } }"
    })
    List<ProductSupplierView> getProductsWithSupplier(String category);
}

fields = "{ 'name': 1, 'price': 1 }" 属于投影操作,仅返回指定字段,减少网络传输量和内存占用。$lookup 是 MongoDB 中的左外连接操作,在聚合管道中可实现跨集合关联查询,替代部分 @DBRef 的使用场景以提升性能。

4. MongoTemplate

MongoTemplate 是 Spring Data MongoDB 的核心操作类,提供了比 Repository 更灵活的控制能力,特别适合构建动态条件查询、执行批量操作以及编写复杂聚合管道。Criteria 是构建查询条件的基石,支持链式调用组合多个条件。BulkOperations 则可以将多个写操作合并为一次网络往返,显著提升写入吞吐量。

@Service
public class ProductService {
    @Autowired private MongoTemplate mongoTemplate;

    public List<Product> advancedSearch(String keyword, String category,
                                         BigDecimal minPrice, BigDecimal maxPrice,
                                         Pageable pageable) {
        Query query = new Query();

        if (StringUtils.hasText(keyword)) {
            query.addCriteria(Criteria.where("name").regex(keyword, "i"));
        }
        if (StringUtils.hasText(category)) {
            query.addCriteria(Criteria.where("category").is(category));
        }
        if (minPrice != null && maxPrice != null) {
            query.addCriteria(Criteria.where("price").gte(minPrice).lte(maxPrice));
        }

        query.with(Sort.by(Sort.Direction.DESC, "createdAt"));
        query.with(pageable);

        return mongoTemplate.find(query, Product.class);
    }

    public void updateStockBatch(Map<String, Integer> stockChanges) {
        BulkOperations bulkOps = mongoTemplate.bulkOps(
            BulkOperations.BulkMode.UNORDERED, Product.class);

        stockChanges.forEach((id, qty) -> {
            Query query = Query.query(Criteria.where("_id").is(id));
            Update update = new Update().inc("stock", qty);
            bulkOps.updateOne(query, update);
        });

        bulkOps.execute();
    }

    public List<Document> getCategoryRevenue() {
        Aggregation agg = Aggregation.newAggregation(
            Aggregation.match(Criteria.where("status").is("completed")),
            Aggregation.unwind("items"),
            Aggregation.group("items.category")
                .sum(ArithmeticOperators.Multiply.valueOf("items.price")
                    .multiplyBy("items.quantity")).as("revenue"),
            Aggregation.sort(Sort.Direction.DESC, "revenue"),
            Aggregation.project("revenue").and("_id").as("category").andExclude("_id")
        );

        return mongoTemplate.aggregate(agg, "orders", Document.class)
            .getMappedResults();
    }

    public void upsertProduct(Product product) {
        Query query = Query.query(Criteria.where("sku").is(product.getSku()));
        Update update = new Update()
            .setOnInsert("createdAt", Instant.now())
            .set("name", product.getName())
            .set("price", product.getPrice())
            .set("updatedAt", Instant.now());

        mongoTemplate.upsert(query, update, Product.class);
    }
}

setOnInsert 在 upsert 场景中尤为有用,仅在文档初次插入时生效,不会覆盖已有字段。对于聚合管道,建议使用 Aggregation.project 重命名字段,让输出结果更符合业务语义。BulkMode.UNORDERED 允许个别操作失败后继续执行剩余操作,适用于日志写入等容错性高的场景;ORDERED 则在出错时立即停止。

5. 事务

MongoDB 的事务自 4.0 版本引入,在单副本集上支持多文档 ACID 事务,4.2 之后扩展至分片集群。Spring Data MongoDB 通过 @Transactional 注解和 MongoTransactionManager 将 MongoDB 事务无缝集成到 Spring 的事务抽象层中。事务中的读写操作,要么全部成功提交,要么全部回滚。需要注意的是,事务必须在副本集或分片集群上运行,且会话开启后所有涉及的数据库和集合都不能为空配置。

@Service
public class OrderService {
    @Autowired private MongoTemplate mongoTemplate;
    @Autowired private MongoTransactionManager transactionManager;

    @Transactional
    public void createOrder(OrderRequest request) {
        Product product = mongoTemplate.findOne(
            Query.query(Criteria.where("sku").is(request.sku())),
            Product.class
        );

        if (product == null || product.getStock() < request.qty()) {
            throw new IllegalStateException("库存不足或商品不存在");
        }

        mongoTemplate.updateFirst(
            Query.query(Criteria.where("sku").is(request.sku())),
            new Update().inc("stock", -request.qty()),
            Product.class
        );

        Order order = new Order(request);
        order.setCreatedAt(Instant.now());
        mongoTemplate.save(order);

        mongoTemplate.save(new InventoryLog(request.sku(), -request.qty(), "出库"));
    }

    @Transactional
    public void cancelOrder(String orderId) {
        Order order = mongoTemplate.findById(orderId, Order.class);
        if (order == null || order.isCancelled()) return;

        mongoTemplate.updateFirst(
            Query.query(Criteria.where("_id").is(orderId)),
            new Update().set("cancelled", true).set("cancelledAt", Instant.now()),
            Order.class
        );

        mongoTemplate.updateFirst(
            Query.query(Criteria.where("sku").is(order.getSku())),
            new Update().inc("stock", order.getQty()),
            Product.class
        );
    }
}

在事务内部,MongoDB 会默认以快照隔离级别执行,读取操作看到的是事务开始时的数据快照,避免了幻读和不可重复读问题。当事务抛出异常时,Spring 会自动调用 session.abortTransaction() 回滚所有更改,已写入的文档和日志不会持久化到数据文件中。

6. Change Streams 实时数据变更监听

Change Streams 是 MongoDB 3.6 引入的一项特性,允许应用程序以流式方式监听集合或数据库级别的变更事件,包括 insert、update、replace、delete、invalidate 等操作。与轮询相比,Change Streams 基于 oplog 实现,具有毫秒级延迟且无额外查询开销,是实现实时数据同步、缓存失效和事件驱动架构的理想选择。

@Service
public class ChangeStreamListener implements CommandLineRunner {
    @Autowired private MongoTemplate mongoTemplate;

    @Override
    public void run(String... args) {
        ChangeStreamOptions options = ChangeStreamOptions.builder()
            .filter(Aggregation.newAggregation(
                Aggregation.match(Criteria.where("operationType")
                    .in("insert", "update", "replace"))
            ))
            .fullDocumentLookup(FullDocument.UPDATE_LOOKUP)
            .build();

        MessageListenerContainer container = new DefaultMessageListenerContainer(mongoTemplate);
        container.start();

        ChangeStreamRequest<Document> request = ChangeStreamRequest.builder()
            .collection("products")
            .publishTo(doc -> {
                String op = doc.getString("operationType");
                Document fullDoc = doc.get("fullDocument", Document.class);
                System.out.println("[ChangeStream] 操作类型: " + op + ", 文档: " + fullDoc);
            })
            .build();

        container.register(request, Document.class);
    }
}

UPDATE_LOOKUP 选项在更新事件中嵌入变更后的完整文档,避免了客户端需要二次查询才能获取最新状态的麻烦。由于 Change Streams 依赖 oplog,因此 oplog 的保留窗口需要足够长,防止应用短暂离线后无法恢复断点续传。在实际部署中,可以将监听到的变更事件转发到 Kafka、RabbitMQ 或 Redis Pub/Sub,解耦生产端和消费端。

7. GridFS 大文件分块存储

当需要存储超过 16MB 的文件时,MongoDB 的单个文档大小限制决定了无法将文件内容直接存入标准文档集合。GridFS 是 MongoDB 内置的规范,将大文件拆分为 255KB 的 chunk 分片存储,并通过 fs.files 和 fs.chunks 两个集合记录元数据和内容块。Spring Data MongoDB 通过 GridFsTemplate 提供了对 GridFS 的友好封装。

@Service
public class FileStorageService {
    @Autowired private GridFsTemplate gridFsTemplate;
    @Autowired private GridFsOperations gridFsOperations;

    public String storeFile(InputStream inputStream, String filename,
                            String contentType, ObjectId userId) {
        DBObject metadata = new BasicDBObject();
        metadata.put("userId", userId.toString());
        metadata.put("uploadDate", Instant.now().toString());
        metadata.put("contentType", contentType);

        ObjectId fileId = gridFsTemplate.store(
            inputStream, filename, contentType, metadata);
        return fileId.toString();
    }

    public GridFSFile findFile(String fileId) {
        return gridFsTemplate.findOne(
            Query.query(Criteria.where("_id").is(new ObjectId(fileId))));
    }

    public void downloadFile(String fileId, OutputStream out) throws IOException {
        GridFSFile file = findFile(fileId);
        if (file == null) return;

        GridFsResource resource = gridFsOperations.getResource(file);
        try (InputStream in = resource.getInputStream()) {
            in.transferTo(out);
        }
    }

    public void deleteFile(String fileId) {
        gridFsTemplate.delete(
            Query.query(Criteria.where("_id").is(new ObjectId(fileId))));
    }
}

gridFsTemplate.store 会自动管理文件分块、写入 fs.files 和 fs.chunks,并返回文件的唯一标识符。读取时通过 GridFsOperations.getResource 将分散的 chunk 重新拼接为完整流,调用方无需关心底层分片细节。对于图片、PDF、视频等二进制大对象场景,GridFS 结合 CDN 边缘缓存可以构建高效的文件分发系统。

8. GeoSpatial 地理空间查询($near、$geoWithin)

MongoDB 支持 2dsphere 和 2d 两种地理空间索引。2dsphere 基于地球表面球面几何计算距离,适合真实世界的坐标数据;2d 则是平面坐标系,适合游戏地图等虚拟场景。Spring Data MongoDB 通过 NearQuery 和 Criteria 封装了 $near、$nearSphere、$geoWithin 等操作符,开发者只需提供 Point 或 GeoJsonPoint 对象即可完成空间查询。

@Service
public class LocationService {
    @Autowired private MongoTemplate mongoTemplate;

    public List<Product> findNearby(double longitude, double latitude,
                                    double maxDistanceKm) {
        Point point = new Point(longitude, latitude);
        Distance distance = new Distance(maxDistanceKm, Metrics.KILOMETERS);

        NearQuery nearQuery = NearQuery.near(point)
            .maxDistance(distance)
            .spherical(true);

        GeoResults<Product> results = mongoTemplate.geoNear(
            nearQuery, Product.class);

        return results.getContent().stream()
            .map(GeoResult::getContent)
            .toList();
    }

    public List<Product> findWithinPolygon(List<double[]> polygon) {
        GeoJsonPolygon geoPolygon = new GeoJsonPolygon(
            polygon.stream().map(p -> new Point(p[0], p[1])).toList()
        );

        Query query = new Query(
            Criteria.where("location").within(geoPolygon));
        return mongoTemplate.find(query, Product.class);
    }

    public List<Product> findWithinCircle(double longitude, double latitude,
                                          double radiusKm) {
        Circle circle = new Circle(longitude, latitude, radiusKm / 6378.1);
        Query query = new Query(
            Criteria.where("location").withinSphere(circle));
        return mongoTemplate.find(query, Product.class);
    }
}

$geoWithin 支持多边形、圆形和框形区域查询,常用于外卖配送范围、地理围栏等业务。$near 返回的结果会按距离升序排列,并可结合 spherical(true) 使用球面距离公式,避免地图投影带来的误差。地理空间索引的构建需要确保坐标数组顺序为 [longitude, latitude],否则会导致查询异常。

9. Text Search 全文检索与权重排序

MongoDB 的全文索引通过为字段创建 text 索引来支持基于词干分析的自然语言搜索,能够匹配关键字、忽略大小写和标点,并按相关性评分排序。每个集合最多只能创建一个全文索引,但该索引可以覆盖多个字段。Spring Data MongoDB 通过 TextCriteria 和 TextQuery 提供对 $text 和 $meta 操作符的封装,支持设置语言模型、精确短语搜索以及区分大小写。

@Service
public class SearchService {
    @Autowired private MongoTemplate mongoTemplate;

    public List<Product> textSearch(String keywords, String language) {
        TextCriteria criteria = TextCriteria.forLanguage(language)
            .matching(keywords);

        Query query = TextQuery.queryText(criteria)
            .sortByScore()
            .with(PageRequest.of(0, 20));

        return mongoTemplate.find(query, Product.class);
    }

    public List<Product> textSearchWithFields(String keywords) {
        TextCriteria criteria = TextCriteria.forDefaultLanguage()
            .matching(keywords)
            .caseSensitive(false)
            .diacriticSensitive(false);

        Query query = TextQuery.queryText(criteria)
            .sortByScore()
            .includeScore("score");

        return mongoTemplate.find(query, Product.class);
    }
}

在实体类中,可以通过 weights 参数为不同字段指定搜索权重:例如商品名称的权重设为 10,而描述的权重设为 1,这样当关键词同时匹配两者时,名称匹配的结果会排在前面。对于中文搜索,需将 language 设为 "none" 以避免英文词干分析器错误地拆分汉字。

10. 索引优化与 Explain 分析

索引是 MongoDB 查询性能的基石。Spring Data MongoDB 提供了 @Indexed、@CompoundIndex 和 @TextIndexed 等注解,在应用启动后自动创建相应索引。建议在实际开发中结合 MongoDB 的 explain 命令分析查询计划,确认查询是否命中预期索引、执行的扫描方式是全表扫描还是索引扫描、以及排序操作是否充分利用索引的有序特性。

@Document(collection = "products")
@CompoundIndex(name = "category_price", def = "{'category': 1, 'price': -1}")
public class Product {
    @Id private String id;

    @Indexed
    private String sku;

    @TextIndexed(weight = 10)
    private String name;

    @TextIndexed(weight = 1)
    private String description;

    @Indexed(expireAfterSeconds = 86400)
    private Instant expireAt;
}
@Service
public class IndexService {
    @Autowired private MongoTemplate mongoTemplate;

    public void analyzeQuery(Query query, String collectionName) {
        Document explainResult = mongoTemplate.executeCommand(
            new Document("explain", new Document("find", collectionName)
                .append("filter", query.getQueryObject()))
                .append("verbosity", "executionStats")
        );

        Document queryPlanner = explainResult.get("queryPlanner", Document.class);
        Document winningPlan = queryPlanner.get("winningPlan", Document.class);
        String stage = winningPlan.getString("stage");

        Document executionStats = explainResult.get("executionStats", Document.class);
        long docsExamined = executionStats.getLong("totalDocsExamined");
        long keysExamined = executionStats.getLong("totalKeysExamined");

        System.out.println("查询阶段: " + stage);
        System.out.println("扫描文档数: " + docsExamined);
        System.out.println("扫描键值数: " + keysExamined);
    }

    public void createIndexManually() {
        IndexOps indexOps = mongoTemplate.indexOps(Product.class);
        Index index = new Index()
            .on("createdAt", Sort.Direction.ASC)
            .named("createdAt_asc");
        indexOps.ensureIndex(index);
    }
}

executionStats 是性能诊断的关键输出,totalDocsExamined 远大于返回结果数时通常意味着查询未命中索引或索引选择不当。expireAfterSeconds 可创建 TTL 索引,让 MongoDB 自动删除过期文档,非常适合会话记录、验证码缓存等有时效性的数据。复合索引的字段顺序至关重要,应将高选择性的字段放在前面,让 MongoDB 能够利用前缀匹配快速缩小扫描范围。

延伸阅读

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「mongodb」更多文章

  1. 数据生命周期、TTL 与冷热归档
  2. $graphLookup 与层次结构建模
  3. GridFS 与大文件存储实践