MongoTemplate实战:Spring Boot中MongoDB复杂查询与聚合操作指南
1. 项目概述与使用场景定位MongoTemplate 是 Spring Data MongoDB 提供的最核心的操作入口。说直白点它就是你在 Java 代码里操作 MongoDB 的“瑞士军刀”。但凡项目里用了 Spring Boot 又接了 MongoDB你就绕不开它——要么直接用 MongoTemplate 写业务逻辑要么用 MongoRepository 封装底层但真正复杂的需求最后还得回到 MongoTemplate 上。这一篇是 MongoDB 操作与 Java 系列的第四篇前面我们聊了 MongoDB 的基础概念、Java 驱动的原生 API、以及 Spring Data 的 Repository 方式。这次咱们重点解决一个非常现实的问题当 Repository 不够用的时候MongoTemplate 怎么顶上我先说说适合读这篇的人对 Spring Boot 基本了解、会用 MongoRepository 做简单增删改查、但遇到动态条件查询、聚合报表、批量更新就不知道怎么写的人。如果你是刚接触 MongoDB 的 Java 新手这篇同样能帮你把整个操作体系打通。MongoTemplate 解决的痛点很明确Repository 那套“方法名派生查询”看起来很爽但一碰到查询条件不确定、需要动态拼装条件、要用聚合管道、要跑 MapReduce 这类场景方法名那一套就彻底玩不转了。MongoTemplate 把 MongoDB 的查询语法完整暴露给了 Java你可以像在命令行里写 db.collection.find(...) 一样在 Java 里用 Query 和 Criteria 对象构建出等价的原生查询。另外MongoTemplate 还做了一层非常关键的事它负责 Java 对象和 MongoDB 底层 Document 之间的互相转换。你往里存的时候传一个普通的 POJO它会把字段映射成 MongoDB 文档你查出来的时候它又把 Document 映射回你的实体类。这层映射比我们手写 Document 转换要方便太多尤其在嵌入文档、数组、ObjectId 这类结构上处理得很稳。2. 环境准备与基础配置2.1 引入依赖和连接配置我们用 Spring Boot 的话引入 MongoDB 的 starter 就够了它会自动把 MongoTemplate 装配进容器里dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-data-mongodb/artifactId /dependencySpring Boot 2.x 和 3.x 在配置上有一些差异但连接配置基本一样。值得提醒一句网上很多教程还在用 3.x 之前的配置方式如果你用的是 Spring Boot 3 以上的版本spring.data.mongodb.uri这个配置项没问题但部分 auto-configuration 的内部机制变了依赖传递的 mongodb-driver 版本也不一样这个后面遇到问题排查再说。spring: data: mongodb: uri: mongodb://admin:passwordlocalhost:27017/my_database如果你的 MongoDB 不需要认证uri 简化为mongodb://localhost:27017/my_database就行。还有一种老的配置方式拆开配 host、port、databasespring: data: mongodb: host: localhost port: 27017 database: my_database username: admin password: password两种方式效果一样。我个人的习惯是尽量用 uri因为 MongoDB Atlas 连接串和一些自建集群的连接串都是 uri 格式直接用 uri 换环境时只改一个配置就行不容易漏。而且 uri 可以带上authSourceadmin、replicaSet这些参数拆开配置反而不方便。2.2 实体与集合的映射使用 MongoTemplate 时实体类一般用Document注解标注说明这个类对应 MongoDB 里的哪个集合。如果集合名跟实体类名不一致一定要显式指定Document(collection orders) public class Order { Id private String id; private String orderNo; private String userId; private BigDecimal totalAmount; private Integer status; private ListOrderItem items; private LocalDateTime createTime; private LocalDateTime updateTime; }这里有几个点容易踩坑第一Id字段。MongoTemplate 保存时如果 id 为 null会自动生成一个 ObjectId 并写回实体对象的 id 字段。如果你定义成 String 类型MongoDB 底层存的是 ObjectId 格式查询的时候用字符串关联是没问题的因为 Spring Data 在底层做了转换。第二时间字段。LocalDateTime 是 Java 8 的类型MongoDB 的 Java 驱动是支持的底层会转成 BSON 的 Date 类型存储查询出来又自动变回 LocalDateTime这个映射链路很顺。但如果你用的是java.util.Date也能存只是拿到手的是 Date 而不是 LocalDateTime处理时区时要格外小心。第三嵌套文档。OrderItem 不需要加任何注解MongoTemplate 会把它默认映射成嵌入的子文档存到 MongoDB 里就是一个对象数组查询的时候也能自动映射回来。2.3 注入 MongoTemplate 的几种方式Spring Boot 项目里最省事的方式是直接用Autowired注入Service public class OrderService { Autowired private MongoTemplate mongoTemplate; }不过我更推荐构造器注入虽然写法上略多一点但对单元测试友好也避免 Spring 循环依赖的隐患Service public class OrderService { private final MongoTemplate mongoTemplate; public OrderService(MongoTemplate mongoTemplate) { this.mongoTemplate mongoTemplate; } }在 Spring Boot 中MongoTemplate的 bean 会被自动创建所以你不需要手动配 bean。只有在某些自定义配置场景下——比如你要连接多个 MongoDB、或者要自定义转换器映射规则——才需要手动 new 一个MongoTemplate的 bean。3. CRUD 基础操作的开发实战3.1 插入操作的三种姿势与注意细节插入数据MongoTemplate 提供了 save 和 insert 两类方法。表面上看差不多但行为有本质区别我建议按这个标准来选save如果文档 id 存在就替换整个文档不存在就插入。语义是“保存”。insert如果文档 id 存在则报 DuplicateKeyException 异常。语义是“新增”。业务上创建新单子我一般用 insert防止误触覆盖保存修改过的记录用 save 更合适省去先查再更新的麻烦。基础插入代码public Order createOrder(Order order) { return mongoTemplate.insert(order); }插入多条的批量写法public ListOrder batchCreateOrder(ListOrder orders) { // 指定集合名效率更高 return mongoTemplate.insertAll(orders); }这里有个经验insert 单条时mongoTemplate.insert(order)会通过实体类上的Document注解自动确定集合名也可以指定集合名mongoTemplate.insert(order, orders)。如果数据结构比较灵活或者接收的是第三方接口传过来的 Map我们也可以直接插入 Document不定义实体类MapString, Object data new HashMap(); data.put(orderNo, NO20240001); data.put(totalAmount, 199.99); data.put(status, 1); mongoTemplate.insert(data, orders);这种方式适合数据格式变化频繁的轻量场景但字段名容易写错而且类型全推成了 Object后续查询出来还要手动转型不建议在核心业务中大量使用。3.2 查询操作findById 与条件查询按主键查询是最简单的Order order mongoTemplate.findById(6601a2b3c4d5e6f7a8b9c0d1, Order.class);查不到就返回 null。这里要提示参数是 String 类型的 idMongoTemplate 会转成 ObjectId 去匹配。但如果你存的 id 不是 ObjectId而是业务上自己生成的长整型或者 UUID 字符串那就得用Query条件查否则类型匹配不上。刚写 MongoDB 的 Java 开发容易在这个地方困惑我解释一下底层逻辑。MongoDB 默认的主键_id类型是 ObjectIdSpring Data 把实体类里的 String 类型的id字段与_id字段做了映射。存的时候 String 会被转成 ObjectId查的时候字符串参数也会尝试转成 ObjectId。但如果入库时你手动指定了_id为一个普通字符串那查询的时候用字符串直接查是没问题的可是要注意格式转换的时机。带条件查询的基础写法Query query new Query(); query.addCriteria(Criteria.where(status).is(1)); ListOrder orders mongoTemplate.find(query, Order.class);只取一条用findOneQuery query new Query(); query.addCriteria(Criteria.where(orderNo).is(NO20240001)); Order order mongoTemplate.findOne(query, Order.class);如果条件命中了多条记录findOne 默认返回第一条排序规则可以附加在 Query 上不加的话是数据库默认的物理顺序一般不建议依赖这个顺序。3.3 更新操作updateFirst 与 updateMulti 的选择更新操作是 MongoTemplate 相对 Repository 的优势集中区。Repository 方式做复杂更新非常别扭而 MongoTemplate 的 Update 对象可以精确控制要改哪些字段、怎么改。Query query new Query(Criteria.where(orderNo).is(NO20240001)); Update update new Update(); update.set(status, 2); update.set(updateTime, LocalDateTime.now()); UpdateResult result mongoTemplate.updateFirst(query, update, Order.class); System.out.println(匹配条数: result.getMatchedCount()); System.out.println(修改条数: result.getModifiedCount());updateFirst 只更新命中的第一条适用于业务上唯一键定位的场景需要更新所有满足条件的记录时用 updateMultiQuery query new Query(Criteria.where(userId).is(U10001)); Update update new Update(); update.set(status, 3); UpdateResult result mongoTemplate.updateMulti(query, update, Order.class);更新操作还有一个高频需求字段自增。MongoDB 原生的$inc操作符在 Java 里这样写Update update new Update(); update.inc(version, 1); // 自增 1 update.inc(viewCount, 5); // 自增 5push操作用于向数组追加元素Update update new Update(); update.push(tags, 热点); mongoTemplate.updateFirst(query, update, Article.class);pull操作用于从数组删除元素Update update new Update(); update.pull(tags, 热点); mongoTemplate.updateFirst(query, update, Article.class);这些操作符用熟了之后你会发现很多业务完全可以绕过“先查出来改完再存回去”的低效流程一条更新语句直接搞定既省流量又不会有并发覆盖的问题。3.4 删除操作与物理删除的注意事项删除相对简单Query query new Query(Criteria.where(orderNo).is(NO20240001)); mongoTemplate.remove(query, Order.class);RemoveResult 可以拿到删除条数DeleteResult result mongoTemplate.remove(query, Order.class); System.out.println(删除条数: result.getDeletedCount());这里要说一个很多程序员踩过的坑真正做企业级项目时业务数据尽量不要物理删除。你当时觉得删得干净后面报表对不上账、审计查不到记录就得跪着导数据。常规做法是加一个deleted字段做逻辑删除查询条件默认拼上deleted: false删除操作本质是更新操作Query query new Query(Criteria.where(id).is(orderId)); Update update new Update(); update.set(deleted, true); update.set(deleteTime, LocalDateTime.now()); mongoTemplate.updateFirst(query, update, Order.class);这也从一个侧面说明理解 MongoTemplate 的更新操作对做可靠业务系统尤其重要。4. 复杂查询场景的实现与剖析4.1 动态条件构建多条件组合查询日常业务里最容易碰到的场景是前端传过来一堆筛选条件哪些条件有值就拼到查询里哪些没有就忽略。用 Repository 的方法名派生查询做不到这种动态拼装但 MongoTemplate 的 Query Criteria 天然支持动态添加这就是它最大的优势。我的做法是封装一个查询条件构建方法public ListOrder searchOrders(String userId, Integer status, String keyword, BigDecimal minAmount, BigDecimal maxAmount, LocalDateTime startTime, LocalDateTime endTime) { Query query new Query(); if (StringUtils.hasText(userId)) { query.addCriteria(Criteria.where(userId).is(userId)); } if (status ! null) { query.addCriteria(Criteria.where(status).is(status)); } if (StringUtils.hasText(keyword)) { // 模糊搜索订单号不区分大小写 query.addCriteria(Criteria.where(orderNo).regex(keyword, i)); } if (minAmount ! null maxAmount ! null) { query.addCriteria(Criteria.where(totalAmount).gte(minAmount).lte(maxAmount)); } else if (minAmount ! null) { query.addCriteria(Criteria.where(totalAmount).gte(minAmount)); } else if (maxAmount ! null) { query.addCriteria(Criteria.where(totalAmount).lte(maxAmount)); } if (startTime ! null endTime ! null) { query.addCriteria(Criteria.where(createTime).gte(startTime).lt(endTime)); } return mongoTemplate.find(query, Order.class); }这段代码看着简单但有几个细节你是从官方文档里很难直接学到的第一时间查询一定要用lt而不是lte做结束边界。如果用lte(endTime)假设 endTime 是“2024-12-31T23:59:59.999”实际存的时间可能因为精度问题被排除或者多出几毫秒的记录。更稳妥的方式是用半开区间[startTime, endTime)配合lt使用这也是业界统计报表的通用做法。第二正则查询 keyword 时要小心。用户输入的关键词如果含有正则保留字符比如(、[、*、.这些会直接破坏查询逻辑甚至造成性能问题。稳妥的做法是对用户输入做一次正则转义import java.util.regex.Pattern; public String escapeRegex(String keyword) { return Pattern.quote(keyword); } // 使用 query.addCriteria(Criteria.where(orderNo).regex(escapeRegex(keyword), i));4.2 排序、分页与只查指定字段分页查询是接口开发的必修课。MongoTemplate 的 Pageable 和 Sort 可以和 Spring Data 通用public PageOrder pageQuery(int page, int size, String userId, Integer status) { // Pageable 从第 0 页开始 Pageable pageable PageRequest.of(page, size, Sort.by(Sort.Direction.DESC, createTime)); Query query new Query(); if (StringUtils.hasText(userId)) { query.addCriteria(Criteria.where(userId).is(userId)); } if (status ! null) { query.addCriteria(Criteria.where(status).is(status)); } query.with(pageable); long total mongoTemplate.count(query, Order.class); ListOrder list mongoTemplate.find(query, Order.class); return new PageImpl(list, pageable, total); }这里的 count 和 find 使用了同一个 query但注意 mongoTemplate.count 只统计满足条件的记录数不受分页影响所以顺序是先 count 再 find避免 count 之后 query 被修改的问题。字段筛选也是一个实用技巧。查询时如果只关心订单号和金额不关心 items 列表这种大字段可以用 include/exclude 控制Query query new Query(Criteria.where(userId).is(U10001)); query.fields().include(orderNo); query.fields().include(totalAmount); query.fields().exclude(items); ListOrder orders mongoTemplate.find(query, Order.class);相应地字段筛选能显著减少网络传输和内存占用特别是集合里存在 Text 类型大文本、大数组时这个优化立竿见影。但注意include 和 exclude 不能混用在非 _id 字段上这是 MongoDB 的限制。4.3 数组查询与嵌入文档的条件匹配项目里有一个很经典的场景查询包含某个标签的文章、查询商品包含某个 SKU 的记录这类“判断数组是否包含某个元素”的需求在 MongoTemplate 里写起来很自然Query query new Query(Criteria.where(tags).in(热点, 推荐)); ListArticle articles mongoTemplate.find(query, Article.class);注意in的语义是“数组字段中有任意一个元素匹配即可”跟 SQL 里的 IN 不一样。如果要求同时包含多个标签用allQuery query new Query(Criteria.where(tags).all(热点, 必读));针对嵌入文档的字段查询MongoDB 的“点路径”语法在 Java 里照样适用// 查询订单里包含商品编码为 SKU001 的订单 Query query new Query(Criteria.where(items.sku).is(SKU001)); ListOrder orders mongoTemplate.find(query, Order.class);这一手在订单、购物车等包含明细行的场景中格外实用。用 Repository 方法名表示这种嵌套条件会相当痛苦但 Criteria 直接支持字段点路径。4.4 封装自己的 BaseDAO避免重复编码多写几个项目之后你会发现查询逻辑的重心其实不在于“怎么写”而在于“怎么复用”。我常常在项目里封装一个抽象的 BaseDAO把模板查询、动态排序、分页这些通用逻辑沉淀下来public abstract class BaseMongoDAOT { Autowired protected MongoTemplate mongoTemplate; protected abstract ClassT getEntityClass(); public ListT findByCondition(Query query) { return mongoTemplate.find(query, getEntityClass()); } public long countByCondition(Query query) { return mongoTemplate.count(query, getEntityClass()); } public PageT pageQuery(Query query, Pageable pageable) { long total mongoTemplate.count(query, getEntityClass()); query.with(pageable); ListT content mongoTemplate.find(query, getEntityClass()); return new PageImpl(content, pageable, total); } }具体业务 DAO 继承并指定实体类型Repository public class OrderDAO extends BaseMongoDAOOrder { Override protected ClassOrder getEntityClass() { return Order.class; } // 业务自定义方法 }这样做的收益显而易见统一的分页排序逻辑统一异常处理位置复用方式简单。一个新同事接手你的代码时不需要满世界找各种风格的查询代码。5. 聚合操作与分组统计的实现5.1 聚合框架在 Java 中的落地MongoDB 的聚合管道是数据分析的利器。写聚合之前先在 Mongo Shell 里测试 SQL确认没问题后再翻译成 Java 代码这种以 Shell 为蓝本的方式最靠谱。MongoTemplate 里对应的是Aggregation类和AggregationOperation接口。以“按用户统计订单总金额”为例AggregationOperation group Aggregation.group(userId) .sum(totalAmount).as(totalAmount) .count().as(orderCount); AggregationOperation sort Aggregation.sort(Sort.by(Sort.Direction.DESC, totalAmount)); Aggregation aggregation Aggregation.newAggregation(group, sort); AggregationResultsMap results mongoTemplate.aggregate(aggregation, orders, Map.class); ListMap list results.getMappedResults();这里注意聚合结果实体不一定要跟原集合的实体类找齐。如果只是简单统计可以直接用一个 DTO 类来接public class UserOrderStats { private String userId; private BigDecimal totalAmount; private long orderCount; // getter/setter }使用方式不变把最后的Map.class换成UserOrderStats.classSpring Data 会尝试按字段名映射。字段名对不上时需要检查一下命名策略一般建议关闭 Spring Boot 的spring.data.mongodb.auto-index-creation时也顺手把事情弄清楚。5.2 多阶段管道的编写技巧实际业务需求很少是一个 group 就能搞定的。比如需求是“统计最近7天每天的订单数和总金额按日期升序输出”。在 Mongo Shell 里db.orders.aggregate([ { $match: { createTime: { $gte: ISODate(2024-12-24T00:00:00Z) } } }, { $project: { date: { $dateToString: { format: %Y-%m-%d, date: $createTime } }, totalAmount: 1 } }, { $group: { _id: $date, orderCount: { $sum: 1 }, totalAmount: { $sum: $totalAmount } } }, { $sort: { _id: 1 } } ])翻译成 JavaLocalDateTime startTime LocalDateTime.now().minusDays(7); MatchOperation match Aggregation.match( Criteria.where(createTime).gte(startTime) ); ProjectionOperation project Aggregation.project() .andExpression(dateToString(%Y-%m-%d, createTime)).as(date) .and(totalAmount).as(totalAmount); GroupOperation group Aggregation.group(date) .count().as(orderCount) .sum(totalAmount).as(totalAmount); SortOperation sort Aggregation.sort(Sort.by(Sort.Direction.ASC, _id)); Aggregation aggregation Aggregation.newAggregation(match, project, group, sort);在 Shell 里调通的语句翻译过来基本不会出错。需要注意$dateToString里的时区默认是 UTC如果业务库存的是本地时间而 MongoDB 服务时区是 UTC统计出来的日期可能会偏移。稳妥的做法是在 Shell 层指定时区{ $dateToString: { format: %Y-%m-%d, date: $createTime, timezone: Asia/Shanghai } }Java 里这样写ProjectionOperation project Aggregation.project() .andExpression(dateToString(%Y-%m-%d, createTime, Asia/Shanghai)).as(date) .and(totalAmount).as(totalAmount);这个时区坑我见过不少团队在接手维护老项目时才发现非常痛。建议在一开始就统一约定。5.3 使用 $unwind 展开数组并分组统计聚合中的$unwind用于把数组字段拆成多条文档使用场景很广统计每个商品品类下有多少订单明细、分析用户最近 N 次行为等。Java 对应写法UnwindOperation unwind Aggregation.unwind(items); GroupOperation group Aggregation.group(items.sku) .sum(items.quantity).as(totalQuantity) .avg(items.price).as(avgPrice); Aggregation aggregation Aggregation.newAggregation(unwind, group); AggregationResultsMap results mongoTemplate.aggregate(aggregation, orders, Map.class);这段聚合会先把每个订单的 items 数组拆开再按 sku 分组。实际效果就是“统计所有订单里各商品卖了多少件”。注意$unwind之后的文档数量会暴涨合并前的数据量如果特别大建议先$match缩小范围再 unwind。5.4 $lookup 实现表关联查询MongoDB 的$lookup相当于 SQL 里的 left join允许在聚合管道里关联另一个集合。Java 这样写LookupOperation lookup Aggregation.lookup(users, userId, _id, userInfo); Aggregation aggregation Aggregation.newAggregation( Aggregation.match(Criteria.where(status).is(1)), lookup, Aggregation.unwind(userInfo, true) );这里有个关键点Aggregation.unwind(userInfo, true)的第二个参数表示 preserveNull——如果关联不到用户保留订单文档userInfo 为 null。这对应 SQL 中的 LEFT JOIN如果你不传 true关联不到用户的话整条订单会被丢弃等于是 INNER JOIN 的语义了。$lookup 虽然方便但性能开销也不小要特别注意关联集合最好要有索引而且尽量在 lookup 之前先做 match 缩小数据量。6. 批量操作与索引管理的工程实践6.1 用 bulkOps 告别逐条插入的低性能业务上经常遇到一批数据需要写入的场景比如订单导入、日志上报、商品批量上架。如果普通的 insert 一条条插入不仅网络往返次数多性能也差。MongoTemplate 提供了bulkOps来做批量操作ListOrder orders new ArrayList(); // 初始化订单列表... BulkOperations bulkOps mongoTemplate.bulkOps(BulkOperations.BulkMode.ORDERED, Order.class); for (Order order : orders) { bulkOps.insert(order); } BulkWriteResult result bulkOps.execute();这里有个注意点BulkMode 分成 ORDERED 和 UNORDERED 两种。ORDERED 模式遇到某条失败会中止后续操作保证数据一致性UNORDERED 模式会跳过失败尽量执行完所有操作适合日志、指标这类允许部分失败的数据。除了批量插入还有批量更新这个常见优化点。比如要把一批订单的状态从“待支付”改成“已取消”普通做法是循环 updateFirst性能差且非原子批量方式的写法是BulkOperations bulkOps mongoTemplate.bulkOps(BulkOperations.BulkMode.UNORDERED, Order.class); for (String orderId : orderIds) { Query query new Query(Criteria.where(id).is(orderId)); Update update new Update().set(status, 5).set(updateTime, LocalDateTime.now()); bulkOps.updateOne(query, update); } BulkWriteResult result bulkOps.execute();更新条件各自不同的时候这个写法就体现优势了。6.2 让索引为查询提速MongoDB 的慢查询大多是没建好索引。在 Java 里用 MongoTemplate 管理索引可以这样做// 创建单字段索引升序 mongoTemplate.indexOps(orders).ensureIndex(new Index().on(status, Sort.Direction.ASC)); // 创建复合索引userId 升序 createTime 降序通常用于“查某用户最新订单”的场景 mongoTemplate.indexOps(Order.class) .ensureIndex(new Index().on(userId, Sort.Direction.ASC) .on(createTime, Sort.Direction.DESC));建索引这个动作在生产环境要注意小集合无所谓但是大集合几千万级在线建索引会阻塞写操作。常见做法是在业务低峰期手动在 Shell 里建或者用后台建索引的方式让 Spring Boot 自动建索引仅用于开发和测试环境。生产环境更推荐的还是用 SQL 脚本或者专门的命令db.orders.createIndex({ userId: 1, createTime: -1 }, { background: true })6.3 追踪慢查询是性能调优的出发点把慢查询找出来的最好工具是 MongoDB 自己的日志或 profile 功能。Java 项目定位到具体慢操作之后可以用 MongoTemplate 的 explain 去分析执行计划Query query new Query(Criteria.where(userId).is(U10001) .and(status).is(1)); query.with(Sort.by(Sort.Direction.DESC, createTime)); Document explainResult mongoTemplate.executeCommand( new Document(explain, new Document(find, orders) .append(filter, query.getQueryObject())) );拿到 explain 结果后重点看winningPlan里的 stage 类型。如果是COLLSCAN说明查询走了全表扫描没命中索引赶紧优化如果显示IXSCANFETCH说明索引生效了再进一步看 keysExamined 和 docsExamined 的数量差是否合理。这个排查顺序是我每次定位 MongoDB 慢查询的固定动作先看集合量级再看过滤条件再 explain 看执行计划最后决定是加索引还是改写查询条件。利用 MongoTemplate 直接调 explain省得再去命令行奔命。7. 转换与定制深入 MongoTemplate 的映射机制7.1 字段命名策略的坑实体字段的驼峰命名与 MongoDB 的下划线命名之间如何转换是常见的困惑来源。Spring Data MongoDB 默认情况下是“字段名跟 Java 属性名保持一致”也就是createTime会存成createTime而不是create_time。如果你希望数据库里用下划线风格的字段名可以在启动类上启用 camelCase 到 snake_case 的转换Configuration public class MongoConfig { Bean public MongoTemplate mongoTemplate(MongoDatabaseFactory factory, MongoConverter converter) { MongoTemplate template new MongoTemplate(factory, converter); return template; } Bean public MongoCustomConversions customConversions() { ListConverter?, ? converters new ArrayList(); // 自定义转换器可以加在这里 return new MongoCustomConversions(converters); } }不过这种全局命名策略对老项目迁移不太友好因为历史上已经存了大量驼峰字段的数据改了命名策略会导致新旧数据字段不一致。这时候比较稳妥的是在个别实体字段上用Field注解指定存储名public class Order { Field(create_time) private LocalDateTime createTime; Field(total_amount) private BigDecimal totalAmount; }Field的优先级高于全局策略这样同一个集合里哪些字段用什么名字完全由你控制风险降到最低。7.2 与 MongoRepository 的配合使用这一节说清楚很多人纠结的问题MongoTemplate 和 MongoRepository 到底选哪个我的答案是两者不冲突而是搭配使用。Repository 擅长极简 CRUD 和翻页代码看起来非常清爽而涉及到动态条件、聚合、批量更新、explain 分析等就用 MongoTemplate。最常见的架构是 Service 层里同时注入 Repository 和 MongoTemplate。Service public class OrderService { private final OrderRepository orderRepository; private final MongoTemplate mongoTemplate; public OrderService(OrderRepository orderRepository, MongoTemplate mongoTemplate) { this.orderRepository orderRepository; this.mongoTemplate mongoTemplate; } public Order findById(String id) { return orderRepository.findById(id).orElse(null); } public ListOrder searchOrders(OrderQueryDTO dto) { Query query buildDynamicQuery(dto); // 动态拼条件 return mongoTemplate.find(query, Order.class); } }Repository 太厚、MongoTemplate 太薄的时候都是坏味道。比如你把所有查询逻辑都在 Service 里用 MongoTemplate 硬拼会造出一个几百行的 Service反之你用 Repository 硬抠动态条件代码会变成一堆 if else 拼接方法名更加反人类。正确的姿势是把“简单操作走 Repository复杂操作走 MongoTemplate”当成团队规范并在代码 review 时留意这条线。7.3 自定义 Converter 处理特殊类型有时你的实体里会有一些 MongoDB 内置转换器不支持的类型比如 AES 加密后的字符串包装类、自定义的货币类型。这时就要自定义 Converter。假设订单金额字段是一个自定义的 Money 类型public class Money { private BigDecimal amount; private String currency; // getter/setter }我们需要一个 Converter 告诉 Spring Data 怎么把 Money 转成 Document再把 Document 转回 MoneyWritingConverter public class MoneyWriteConverter implements ConverterMoney, Document { Override public Document convert(Money source) { Document document new Document(); document.put(amount, source.getAmount()); document.put(currency, source.getCurrency()); return document; } } ReadingConverter public class MoneyReadConverter implements ConverterDocument, Money { Override public Money convert(Document source) { return new Money((BigDecimal) source.get(amount), (String) source.get(currency)); } }注册方式在上面 MongoConfig 的customConversions()里加入这两个 converter 即可。这里想强调一点自定义转换器虽然灵活但也会让整个链路变复杂除非必要能不用就不用。8. 高频报错与排查实录8.1 不支持读取的字段类型或转换异常有些项目会报错org.springframework.core.convert.ConversionFailedException或者Failed to convert property value of type java.lang.String to required type org.bson.types.ObjectId。原因是查询条件里用了字符串去匹配 ObjectId 类型的_id字段但 Spring Data 没有自动转换成功。解决办法有两种一种是把实体 id 类型改成 ObjectId另一种是手动构造Query query new Query(Criteria.where(_id).is(new ObjectId(id)));其实大多数情况下 Spring Data 会自动转但如果你曾经在数据里存过 String 类型的 _id就很容易触发这个问题。遇到时先检查一下集合里 _id 的真实类型再决定怎么改。8.2 MongoTemplate 无法获取 bean 报错报错信息大概是No qualifying bean of type org.springframework.data.mongodb.core.MongoTemplate。原因基本都是没有引入 spring-boot-starter-data-mongodb 依赖或者 MongoDB 连接配置不对导致自动配置失效。检查思路确认 pom 或 gradle 里有没有 starter 依赖确认 application.yml 里有没有配置 spring.data.mongodb.uri确认本地有没有启动 MongoDB 服务端口是否冲突如果配了多数据源那需要手动注入多个 MongoTemplate用Qualifier区隔这个场景较复杂需要时再单独展开。8.3 聚合结果字段映射不上聚合后的字段名如果和实体类的属性名不一致比如聚合返回的_id对应的是 group 的 key而你的 DTO 里是userId就会出现映射后字段为 null。解决办法是给输出字段指定 andExpression 里的 alias 名称GroupOperation group Aggregation.group(userId) .sum(totalAmount).as(totalAmount) .count().as(orderCount); // 然后把 DTO 里的字段名和 alias 一致另外聚合结果用 Map 接收它是不会映射字段名的key 就是聚合输出字段名这时要自己去 get这也是一种兜底方案。8.4 大数据量下内存溢出的优化mongoTemplate.find 一次拉取全部结果在几百上千万的集合里非常容易导致内存溢出。解决方法通常是流式游标// 流式获取数据分批处理 Query query new Query(); query.addCriteria(Criteria.where(status).is(1)); mongoTemplate.executeQuery(query, orders, document - { // 对每一条 document 做处理一次只处理一条内存占用非常低 handleDocument(document); });executeQuery 内部就是 MongoCursor 的迭代方式适合全量导出、批量清洗等场景。如果只是前端展示走正常的分页查询就行。9. 我踩过的一些坑和这次想特别强调的体会写到这里你可能已经发现MongoTemplate 不是一个需要“背诵 API”的东西而是一个需要理解其运行机制和映射规则之后才能真正用得顺手的工具。它和 MongoRepository 真正的分界线在于你是在用对象思维操作数据还是在用数据库思维操作数据。我个人在实际项目中最大的一个体会是在动手写代码之前先在 Mongo Shell 里把自己的查询和聚合验证一遍再翻译成 Java。这不是降低效率反而是提升效率。因为 Shell 里的反馈是即时的你能立刻看到数据形态而用 Java 写完再跑往往要经过编译、启动、接口调试好几个环节定位问题的时间翻倍。第二个体会是关于实体设计与字段规范。很多人只把 MongoTemplate 当作数据库操作工具却没有在实体设计中预留扩展空间。MongoDB 是文档型数据库它的核心优势是灵活性而 MongoTemplate 的映射机制恰恰吃这套灵活性。如果你一上来就纠结数据库表结构的三范式把 MongoTemplate 用得跟 MyBatis 一样别扭那还不如去用 MySQL。第三个体会是在生产环境尽量少依赖 Spring Boot 的自动建索引索引的创建、修改、删除都应该通过脚本管理和评审流程。MongoTemplate 在代码里建索引虽然方便但团队协作过程中容易出现重复建、漏删、索引膨胀的问题。最后再分享一个小技巧在处理大批量导入时可以先删除集合上的部分索引除了 _id 唯一索引等导入完成后再重建索引。这样导入耗时能从原来的以小时计降到以分钟计。这个操作我自己实测过效果非常明显但只适用于可以短时间容忍查询变慢的离线导入场景。MongoTemplate 的功能还有很多没有细讲比如事务支持、会话、Change Streams 监听、地理空间查询等。这些都是建立在今天讲的这些基本能力之上先把查询、更新、聚合这些基础打扎实后面的进阶功能用起来会顺很多。希望这期的内容对你有用也欢迎在评论区聊聊你自己在用 MongoTemplate 时踩到的有意思的坑。