Spring Boot 聚合MongoDB查询

一、技术实战

1.1 核心依赖

xml 复制代码
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-mongodb</artifactId>
</dependency>

1.2 application.yml

yaml 复制代码
server:
  port: 8080
spring:
  data:
    mongodb:
      # 带密码
      # uri: mongodb://admin:123456@localhost:27017/open_db?authSource=admin
      # 无密码
      uri: mongodb://localhost:27017/open_db
logging:
  level:
    org.springframework.data.mongodb.core.MongoTemplate: DEBUG

1.3 数据准备

  • user 集合
javascript 复制代码
db.user.insertMany([
    { _id: NumberLong("1"), name: "张三", city: "北京", vipLevel: "gold" },
    { _id: NumberLong("2"), name: "李四", city: "上海", vipLevel: "platinum" },
    { _id: NumberLong("3"), name: "王五", city: "广州", vipLevel: "gold" },
    { _id: NumberLong("4"), name: "赵六", city: "深圳", vipLevel: "silver" },
    { _id: NumberLong("5"), name: "周杰", city: "北京", vipLevel: "bronze" }
])
  • order 集合
javascript 复制代码
db.order.insertMany([
    // 张三
    { userId: NumberLong("1"), status: "PAID", amount: NumberDecimal("199.00"), createTime: ISODate("2025-01-15T10:00:00Z") },
    { userId: NumberLong("1"), status: "SHIPPED", amount: NumberDecimal("50.00"), createTime: ISODate("2025-01-20T12:00:00Z") },
    { userId: NumberLong("1"), status: "DONE", amount: NumberDecimal("320.00"), createTime: ISODate("2025-02-05T09:30:00Z") },
    { userId: NumberLong("1"), status: "CANCELLED", amount: NumberDecimal("89.90"), createTime: ISODate("2025-03-10T14:20:00Z") },
    { userId: NumberLong("1"), status: "PAID", amount: NumberDecimal("450.00"), createTime: ISODate("2025-04-01T11:00:00Z") },
    // 李四
    { userId: NumberLong("2"), status: "PAID", amount: NumberDecimal("299.00"), createTime: ISODate("2025-02-10T09:00:00Z") },
    { userId: NumberLong("2"), status: "PAID", amount: NumberDecimal("150.00"), createTime: ISODate("2025-02-18T14:00:00Z") },
    { userId: NumberLong("2"), status: "SHIPPED", amount: NumberDecimal("99.99"), createTime: ISODate("2025-03-05T16:45:00Z") },
    { userId: NumberLong("2"), status: "DONE", amount: NumberDecimal("780.00"), createTime: ISODate("2025-04-10T08:15:00Z") },
    // 王五
    { userId: NumberLong("3"), status: "PAID", amount: NumberDecimal("520.00"), createTime: ISODate("2025-01-25T13:20:00Z") },
    { userId: NumberLong("3"), status: "REFUNDED", amount: NumberDecimal("200.00"), createTime: ISODate("2025-02-28T10:10:00Z") },
    { userId: NumberLong("3"), status: "PAID", amount: NumberDecimal("89.00"), createTime: ISODate("2025-03-15T11:45:00Z") },
    // 赵六
    { userId: NumberLong("4"), status: "PAID", amount: NumberDecimal("45.50"), createTime: ISODate("2025-01-08T09:00:00Z") },
    { userId: NumberLong("4"), status: "SHIPPED", amount: NumberDecimal("320.00"), createTime: ISODate("2025-02-20T15:30:00Z") },
    { userId: NumberLong("4"), status: "DONE", amount: NumberDecimal("670.00"), createTime: ISODate("2025-04-05T12:00:00Z") },
    // 周杰
    { userId: NumberLong("5"), status: "PAID", amount: NumberDecimal("128.00"), createTime: ISODate("2025-03-22T18:30:00Z") },
    { userId: NumberLong("5"), status: "CANCELLED", amount: NumberDecimal("55.00"), createTime: ISODate("2025-04-08T07:20:00Z") }
])

1.4. 实体类

java 复制代码
@Document(collection = "order")
@Data
public class Order {
    @Id
    private String id;

    // 用户ID
    private Long userId;

    // 订单状态
    private String status;

    // 订单金额, 默认情况下MongoDB会把BigDecimal存成double类型。
    @Field(targetType = FieldType.DECIMAL128)
    private BigDecimal amount;

    // 创建时间  //东八区比 UTC 快 8 小时
    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8")
    private LocalDateTime createTime;
}

1.5 核心代码实现

1.5.1 Service层

java 复制代码
package cn.cjc.service;

import org.bson.Document;
import org.bson.types.Decimal128;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.domain.*;
import org.springframework.data.mongodb.core.MongoTemplate;
import org.springframework.data.mongodb.core.aggregation.*;
import org.springframework.data.mongodb.core.query.*;
import org.springframework.stereotype.Service;

import java.math.BigDecimal;
import java.util.ArrayList;
import java.util.List;

@Service
public class OrderAggService {

    @Autowired
    private MongoTemplate mongoTemplate;

    public List<Document> sumByMonthForPaidOrders() {
        MatchOperation matchOperation = Aggregation.match(Criteria.where("status").is("PAID"));
        ProjectionOperation projectionOperation = Aggregation.project("amount")
                // 精简写法:适用于Date类型、时间戳数字、时间字符串, 不带toDate($)只有字段时只适用原生Date类型
                .andExpression("dateToString('%Y-%m', toDate($createTime))").as("month")
                // 原生MongoDB语法: 全部适用
                //.andExpression("{$dateToString: {format: '%Y-%m', date: {$toDate: '$createTime'}}}").as("month")
                // 原生MongoDB语法: 适用时间字符串
                //.andExpression("{$dateToString: {format: '%Y-%m', date: {$dateFromString: {dateString: '$createTime'}}}}").as("month")
                ;
        GroupOperation groupOperation = Aggregation.group("month")
                .first("month").as("month")
                .sum("amount").as("totalAmount");
        // 方式一:最细粒度,手动创建 Sort.Order 对象,代码偏长
        //Aggregation.sort(Sort.by(Sort.Order.desc("month"), Sort.Order.desc("day")));
        // 方式二:流式链式调用
        //Aggregation.sort(Sort.by("month").descending())
        // 方式三:老式重载方法,不推荐
        //Aggregation.sort(Sort.Direction.DESC, "month")
        // 方式四:推荐标准写法, 分组必须输出month排序才生效
        SortOperation sortOperation = Aggregation.sort(Sort.by(Sort.Direction.ASC, "month"));

        ProjectionOperation rename = Aggregation.project()
                .and("_id").as("month")
                .and("totalAmount").as("totalAmount")
                .andExclude("_id");

        Aggregation aggregation = Aggregation.newAggregation(matchOperation, projectionOperation, groupOperation, sortOperation, rename);
        return mongoTemplate.aggregate(aggregation, "order", Document.class).getMappedResults();
    }

    public List<Document> qryOrderWithUser() {
        LookupOperation lookupOperation = LookupOperation.newLookup()
                .from("user")
                .localField("userId")
                .foreignField("_id")
                .as("userInfo");
        UnwindOperation unwindOperation = Aggregation.unwind("userInfo");

        SortOperation sortOperation = Aggregation.sort(Sort.by(Sort.Direction.ASC, "createTime"));

        ProjectionOperation projectionOperation = Aggregation.project()
                .and("amount").as("orderAmount")
                .and("status").as("orderStatus")
                .andExpression("{ $dateToString: { format: '%Y-%m-%d %H:%M:%S', date: '$createTime', timezone: 'Asia/Shanghai' } }")
                .as("createTime")
                .and("userInfo.name").as("userName")
                // 原始ObjectId转换为字符串
                .andExpression("{ $toString: '$_id' }").as("orderId")
                // 原始ObjectId转换为时间戳加日期的字符串
                //.and("_id").as("orderId")
                .andExclude("_id");

        Aggregation aggregation = Aggregation.newAggregation(lookupOperation, sortOperation, unwindOperation, projectionOperation);
        return mongoTemplate.aggregate(aggregation, "order", Document.class).getMappedResults();
    }

    public List<Document> filterUserByTotalAmount(BigDecimal minTotalAmount) {
        GroupOperation groupOperation = Aggregation.group("userId")
                .sum("amount").as("totalAmount")
                .count().as("orderCount");

        // 将 BigDecimal 转换为 Decimal128
        Decimal128 decimalMinAmount = new Decimal128(minTotalAmount);
        MatchOperation matchOperation = Aggregation.match(Criteria.where("totalAmount").gte(decimalMinAmount));

        SortOperation sortOperation = Aggregation.sort(Sort.by(Sort.Direction.DESC, "totalAmount"));

        Aggregation aggregation = Aggregation.newAggregation(groupOperation, matchOperation, sortOperation);
        return mongoTemplate.aggregate(aggregation, "order", Document.class).getMappedResults();
    }

    public List<Document> amountDistribution() {
        BucketOperation bucketOperation = Aggregation.bucket("amount")
                .withBoundaries(0.0, 100.0, 500.0, 1_000_000_000.0)
                .withDefaultBucket("Other")
                .andOutput("count").count().as("orderCount")
                .andOutput("amount").sum().as("totalAmount");

        Aggregation aggregation = Aggregation.newAggregation(bucketOperation);
        return mongoTemplate.aggregate(aggregation, "order", Document.class).getMappedResults();
    }

    public List<Document> qryLastOrderPerUser() {
        SortOperation sortOperation = Aggregation.sort(Sort.by(Sort.Direction.DESC, "createTime"));
        GroupOperation groupOperation = Aggregation.group("userId")
                .first("_id").as("orderId")
                .first("amount").as("amount")
                .first("status").as("status")
                .first("createTime").as("latestTime");
        ProjectionOperation projectionOperation = Aggregation.project()
                .andExpression("{ $toString: '$_id' }").as("userId")
                .andExpression("{ $toString: '$orderId' }").as("orderId")
                .and("amount").as("amount")
                .and("status").as("status")
                .andExpression("{ $dateToString: { format: '%Y-%m-%d %H:%M:%S', date: '$latestTime', timezone: 'Asia/Shanghai' } }").as("latestTime")
                .andExclude("_id");

        Aggregation aggregation = Aggregation.newAggregation(
                sortOperation, groupOperation, projectionOperation
        );
        return mongoTemplate.aggregate(aggregation, "order", Document.class).getMappedResults();
    }

    public List<Document> qryOrdersByName(String nameKeyword) {
        // 子管道:先过滤用户姓名,再只投影需要的字段
        MatchOperation matchStage = Aggregation.match(
                Criteria.where("name").regex(nameKeyword, "i")
        );
        ProjectionOperation projectStage = Aggregation.project("name", "_id");

        // $lookup 关联,使用子管道
        LookupOperation lookupOperation = LookupOperation.newLookup()
                .from("user")
                .localField("userId")
                .foreignField("_id")
                //.pipeline(matchStage, projectStage)    //MongoDB 5.0+ / Spring Data MongoDB 3.3+ 才支持的语法
                .as("matchedUser");

        // $unwind 拆开数组,preserveNullAndEmptyArrays = true 表示没有关联数据时不丢弃主文档
        UnwindOperation unwindOperation = Aggregation.unwind("matchedUser", true);

        // 确保有关联到用户(非空)
        MatchOperation matchOperation = Aggregation.match(Criteria.where("matchedUser").ne(null));

        // 订单状态过滤
        MatchOperation statusMatch = Aggregation.match(Criteria.where("status").is("PAID"));

        // 只返回需要的字段
        ProjectionOperation projectionOperation = Aggregation.project()
                .andExpression("{ $toString: '$_id' }").as("orderId")
                .and("amount").as("amount")
                .and("status").as("status")
                .and("matchedUser.name").as("userName")
                .andExpression("{ $dateToString: { format: '%Y-%m-%d %H:%M:%S', date: '$createTime', timezone: 'Asia/Shanghai' } }").as("createTime")
                .andExclude("_id");

        Aggregation aggregation = Aggregation.newAggregation(
                lookupOperation, unwindOperation, matchOperation, statusMatch, projectionOperation
        );
        return mongoTemplate.aggregate(aggregation, "order", Document.class).getMappedResults();
    }
}

1.5.2 Controller层

java 复制代码
@RestController
@RequestMapping("/order/agg")
public class OrderAggController {

    @Autowired
    private OrderAggService orderAggService;

    /**
     * 按月份分组统计已支付订单的总金额
     * 如果输入的参数是字符串,则需要用 @DateTimeFormat 注解转换成 LocalDateTime 对象
     * 前端字符串 "2025-01-01 00:00:00"
     *       ↓ @DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss", iso = DateTimeFormat.ISO.NONE)
     * Java LocalDateTime 对象
     *       ↓(mongoTemplate 用这个对象去查询)
     * MongoDB 日期查询 { $gte: ISODate(...), $lte: ISODate(...) }
     */
    @GetMapping("/sumAmountByMonthForPaidOrders")
    public List<Document> sumAmountByMonthForPaidOrders() {
        return orderAggService.sumByMonthForPaidOrders();
    }

    /**
     * 查询所有订单信息关联用户姓名
     */
    @GetMapping("/qryOrderWithUser")
    public List<Document> qryOrderWithUser() {
        return orderAggService.qryOrderWithUser();
    }

    /**
     * 根据总金额筛选
     */
    @GetMapping("/filterByTotalAmount")
    public List<Document> filterByTotalAmount(@RequestParam BigDecimal minAmount) {
        return orderAggService.filterUserByTotalAmount(minAmount);
    }

    /**
     * 统计每个区间的订单数量和总金额
     */
    @GetMapping("/amountDistribution")
    public List<Document> amountDistribution() {
        return orderAggService.amountDistribution();
    }

    /**
     * 查询每个用户最新的一条订单信息
     */
    @GetMapping("/qryLastOrderPerUser")
    public List<Document> qryLastOrderPerUser() {
        return orderAggService.qryLastOrderPerUser();
    }

    /**
     * 查询用户姓名包含关键字且订单状态为 PAID
     */
    @GetMapping("/qryOrdersByName")
    public List<Document> qryOrdersByName(@RequestParam String keyword) {
        return orderAggService.qryOrdersByName(keyword);
    }
}

二、常用聚合命令详解

阶段 作用 类比 SQL
$match 过滤文档 WHERE
$group 分组聚合 GROUP BY
$sort 排序 ORDER BY
$project 字段投影 / 重命名 SELECT
limit/limit/limit/skip 分页 LIMIT/OFFSET
$lookup 左连接 LEFT JOIN
$unwind 拆解数组 -
$bucket 区间分桶 CASE WHEN+GROUP BY

三、高级配置与生产级优化

3.1 索引优化

javascript 复制代码
db.order.createIndex({ status: 1 });
db.order.createIndex({ createTime: -1 });
db.order.createIndex({ userId: 1 });
db.order.createIndex({ status: 1, createTime: -1 });

3.2 分页优化

  • 不推荐:$skip\+ $limit

  • 推荐:$match 范围查询 + $limit

3.3 大结果集处理

java 复制代码
AggregationOptions options = AggregationOptions.builder().allowDiskUse(true).build();
Aggregation aggregation = Aggregation.newAggregation(...).withOptions(options);

3.4 管道优化

  • $match 放最前面

  • 先过滤后分组

  • 避免 $group 后用 $match

四、常见问题排查

  1. 聚合结果为空

    • 检查 $match 条件、集合名、数据类型

    • 打印聚合命令,在 Shell 逐级验证

  2. $lookup 关联失败

    • 确保 localFieldforeignField 类型一致
  3. 查询缓慢

    • 建索引、提前过滤、避免大数组 $unwind

五、高频面试题

  1. MongoDB 聚合管道执行顺序
    $match$sort$limit$skip$unwind$group$project

  2. 聚合性能优化

    索引优化、$match 前置、精简返回字段、分片集群

  3. 聚合分页实现

    基础:$skip\+$limit;优化:范围查询分页

  4. project 与 group 区别

    • $group:分组统计,合并文档

    • $project:字段投影,修改文档结构

相关推荐
小江的记录本1 天前
【JVM虚拟机】垃圾回收GC:四种引用类型:强引用、软引用、弱引用、虚引用(附《思维导图》+《面试高频考点清单》)
java·jvm·spring boot·后端·python·spring·面试
小马爱打代码1 天前
Spring源码 第四篇:Spring 5 源码深度拆解:AOP 全流程核心原理
java·后端·spring
ServBay1 天前
2026 Mac 本地大模型部署深度解析与混合架构指南
后端·macos·aigc
一拳一个娘娘腔1 天前
【SRC漏洞挖掘系列】第10期:GraphQL & API 安全 —— 现代 API 的“裸奔”时代
后端·安全·graphql
ZhengEnCi1 天前
01-如何监听接口调用情况?
java·spring boot·后端
苏渡苇1 天前
Spring Cloud Alibaba:将 Sentinel 熔断限流规则持久化到 Nacos 配置中心
数据库·spring boot·mysql·spring cloud·nacos·sentinel·持久化
小马爱打代码1 天前
Spring源码 第九篇:Spring 5 源码深度拆解 - Spring 事件驱动模型
java·后端·spring
ForgeAI码匠1 天前
ForgeAdmin|Spring Boot 3 后台框架的自动配置设计:少写配置,多做组合
java·spring boot·后端
IT_陈寒1 天前
为什么 Java 的 Optional 让我调试到深夜?
前端·人工智能·后端
用户8356290780511 天前
用 Python 实现 Excel 散点图绘制与定制
后端·python