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:字段投影,修改文档结构

相关推荐
Nyarlathotep01132 小时前
并发集合类(3):LinkedBlockingQueue
java·后端
Apifox2 小时前
Apifox 近期更新|AI Agent Debugger、A2A Debugger、Postman API 导入、Ask AI 侧边栏对话
前端·人工智能·后端
知识浅谈2 小时前
面向方面编程(AOP)VS 面向对象编程(OOP)
后端
bzmK1DTbd2 小时前
MongoDB聚合框架:Java驱动下的数据聚合操作
java·python·mongodb
IT空门:门主2 小时前
spring ai alibaba -流式+invoke的人工介入的实现
java·后端·spring
fliter3 小时前
4 个字节拿到 root 权限:Linux 内核漏洞"Copy Fail"与 Cloudflare 的应急处置全记录
后端
fliter3 小时前
Cloudflare 推出 Flagship:为 AI 时代重新设计的功能开关服务
后端·算法
掘金者阿豪3 小时前
折腾了两天,终于把SQLAlchemy连上了金仓数据库
后端
SamDeepThinking3 小时前
RocketMQ消息可靠性的三道关卡
java·后端·程序员