最近在整理一个商品中心项目时,把订单快照数据和商品扩展属性全部迁到了 MongoDB 上,微服务数量从两个涨到六个,数据访问层如果不统一,后面每个服务都要重新踩一遍连接管理、对象映射、聚合查询的坑。所以我这次把 Spring Cloud 集成 MongoDB 的完整姿势梳理一下,覆盖最常见的增删改查、分页、排序和聚合操作,也把线上踩过的几个问题一并列出来。这篇内容适合已经在用 Spring Cloud、正考虑把 MongoDB 引入某个微服务的人,也适合刚接触 Spring Data MongoDB 但被映射和聚合查询绕晕的同学。
1. 整体设计:微服务里怎么引入 MongoDB 才算合理
1.1 什么业务适合落到 MongoDB
MongoDB 不是用来替代 MySQL 的,它适合存储那些结构频繁变化、字段不确定、以文档为粒度的数据。我第一个接入的场景是订单快照:不同渠道来的订单,支付方式、优惠信息、配送要求都不一样,如果强行用关系型表结构,要么建一堆扩展字段,要么上线前还在改表。订单快照天然是一个 JSON 文档,下单时把整个订单快照存储下来,后续无论是展示、统计还是风控回溯,直接读这个文档就行。
另一个典型场景是商品库。同一个商品表里,手机类目有 CPU 型号、屏幕尺寸、电池容量,服装类目有尺码、材质、洗涤方式,用关系型建模需要拆属性表再 join,MongoDB 里直接往attributes里塞 Map 就行。
不太建议放到 MongoDB 的业务也有:账户余额、库存扣减这类强一致、频繁更新的场景。虽然 MongoDB 4.0 之后副本集支持多文档事务,但在微服务架构里,跨服务跨库的事务依然很重,不如把这些强一致业务继续留在关系型数据库里,MongoDB 承担它擅长的部分。
1.2 与关系型数据库的权衡
很多团队纠结要不要用 MongoDB,我用一个简单对比表来说清楚差别:
| 维度 | MySQL / PostgreSQL | MongoDB |
|---|---|---|
| 字段变化 | 需要 ALTER TABLE,大表 DDL 成本高 | 文档结构灵活,字段随意调整 |
| 数据关系 | 外键 + JOIN,模型稳定时更好用 | 推荐冗余 + 反规范化,避免跨文档关联 |
| 高并发写入 | 单机写入瓶颈明显,分库分表成本高 | 分片集群天然支持水平扩展 |
| 事务能力 | 成熟强一致事务 | 4.0 之后支持多文档事务,但场景要克制 |
| 查询能力 | SQL 成熟,复杂报表方便 | 聚合管道灵活,但 join($lookup)性能需谨慎 |
| 运维成本 | 团队一般较熟 | 需要理解副本集、分片、索引策略 |
核心判断标准是:你的数据模型更接近“对象快照”还是“关系网络”。如果每次读取都是把一个完整对象拿出去,那文档型存储就是合适的;如果数据之间强关联、每次查询都要跨多张表,那硬上 MongoDB 只会更痛苦。
1.3 在 Spring Cloud 体系里的集成姿势
Spring Cloud 里集成 MongoDB 不是简单加一个依赖就完事。首先要解决配置统一的问题。我这边用配置中心统一管理 MongoDB 连接串,不同环境 dev/test/prod 各自维护一份mongo.uri,微服务启动时从配置中心拉取。这样集群切换节点、密码轮换时,不用一个个服务去改配置文件。
其次,数据访问层要确定用哪套 API。直接拿官方 MongoDB Java Driver 手写 MongoClient 过于底层,异常处理、结果映射都自己做,项目后期很难维护。Spring Data MongoDB 已经提供了实体映射、方法名查询、分页抽象、异常转换,并且能跟 Spring Cloud Config、链路追踪无缝整合,所以我的建议是:除非有非常特殊的底层性能需求,否则一律用 Spring Data MongoDB。
2. 环境准备与依赖配置
2.1 版本选型
Spring Cloud、Spring Boot、Spring Data MongoDB 三者版本必须对上,否则启动阶段就会报一堆 NoSuchMethodError。我用的是一套比较稳定的组合:
| 组件 | 版本 |
|---|---|
| Spring Boot | 2.7.18 |
| Spring Cloud | 2021.0.8 |
| spring-boot-starter-data-mongodb | 跟随 Boot 2.7 自动管理 |
| MongoDB Server | 5.0 / 6.0 |
| Java | 8+(推荐 11) |
如果项目已经升到 Spring Boot 3.x,Spring Data MongoDB 的包名和初始化逻辑有变化,尤其是 Jakarta 命名空间的问题,迁移时不能只升级版本,代码也要跟着调。社区踩坑不少,我还是建议生产环境先用 Boot 2.7,等业务验证稳定后再评估升级。
2.2 pom 依赖引入
在项目的pom.xml里加入:
<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.cloud</groupId> <artifactId>spring-cloud-starter-bootstrap</artifactId> </dependency> <!-- 如果使用配置中心,可选 --> <dependency> <groupId>org.springframework.cloud</groupId> <artifactId>spring-cloud-starter-config</artifactId> </dependency>如果你的服务还要暴露 REST API,web starter 也一并加。这里特别注意,Spring Cloud 2021 之后默认不再启动 bootstrap 上下文,需要显式引入spring-cloud-starter-bootstrap,否则bootstrap.yml里的配置中心地址不生效。
2.3 application.yml 配置
基础配置长这样:
spring: data: mongodb: uri: mongodb://user:password@172.16.10.1:27017,172.16.10.2:27017/goods-center?replicaSet=rs0&authSource=admin database: goods-center auto-index-creation: true连接串最好放在配置中心,本地application.yml只留一个默认值方便开发。连接池参数在 Spring Data MongoDB 里通过 URI 参数配置:
spring: data: mongodb: uri: mongodb://user:pass@172.16.10.1:27017/goods-center?maxPoolSize=50&minPoolSize=10&maxIdleTimeMS=60000&waitQueueTimeoutMS=5000&serverSelectionTimeoutMS=3000maxPoolSize决定单个服务实例可以建立的连接数,理论上等于数据库句柄池的大小。线上按服务的并发峰值去推算,平均请求耗时 50ms、需要支撑 500 QPS,那每个请求占一个连接的时间约 50ms,也就是同时需要 25 个连接左右,我一般初期给 50,留一倍余量。serverSelectionTimeoutMS建议别设太长,否则节点故障时请求会在获取连接阶段卡死。
2.4 提前规划索引
MongoDB 里索引对查询性能的影响,远比很多关系型数据库明显。全表扫描在数据量小时看不出来,到了千万级文档,一个没有索引的排序或等值查询能直接把 CPU 打满。我习惯在建集合前就规划两类基础索引:
db.order_snapshot.createIndex({ userId: 1, createTime: -1 }) db.order_snapshot.createIndex({ orderNo: 1 }, { unique: true }) db.user_action_log.createIndex({ createTime: 1 }, { expireAfterSeconds: 7776000 })第一个索引服务“查某个用户最近订单”,第二个保证订单号唯一,第三个是 TTL 索引,让过期日志自动清理。聚合操作里的match、sort字段也建议尽量走索引,后面实操部分会再展开。
3. 实体设计与映射避坑
3.1 注解与文档映射
Spring Data MongoDB 通过注解把 Java 对象映射到 BSON 文档。一个典型实体:
@Document(collection = "order_snapshot") public class OrderSnapshot { @Id private String id; @Field("order_no") private String orderNo; @Field("user_id") private Long userId; @Field("total_amount") private BigDecimal totalAmount; @Field("status") private Integer status; @Field("items") private List<OrderItem> items; @Field("attributes") private Map<String, Object> attributes; @Field("create_time") private LocalDateTime createTime; @Field("update_time") private LocalDateTime updateTime; }@Document指定集合名,@Field可以自定义存储到 MongoDB 的字段名。如果 Java 字段名和 MongoDB 字段名不一致,没有@Field就会按驼峰字段名存储,后面写聚合管道时很容易因为字段名对不上而出问题。我统一约定:Java 用驼峰,MongoDB 字段名下划线,聚合管道里全部用下划线。
attributes直接存 Map 是 MongoDB 灵活性的核心。不同类目商品的动态属性往里塞就行,查询时如果想针对某个属性做过滤,可以建多键索引去支持。
3.2 ObjectId 与 String 的纠缠
这是最容易踩坑的地方。@Id我们用 String 接收时,Spring Data 默认会把 MongoDB 的ObjectId转成字符串,查询没问题。但在某些序列化场景,比如把实体直接返回给前端,id字段可能变成{"$oid": "..."}的结构,前端拿不到普通字符串。
我建议两种稳定方案:
方案一:实体内部用ObjectId类型接收,DTO 里用 String 返回。
@Document(collection = "order_snapshot") public class OrderSnapshot { @Id private ObjectId id; // 构造 DTO 时手动转字符串 public String getId() { return id == null ? null : id.toHexString(); } }方案二:继续用 String,但全局配置一个转换器,保证 JSON 序列化输出普通字符串。方案二省事,但要注意聚合管道返回的_id未必会经过实体的转换逻辑,后面聚合结果映射时会单独处理。
还有一个细节:如果查询参数从接口透传进来的是字符串id,用Criteria.where("_id").is(id)有时匹配不到,因为驱动把字符串判定为String而非ObjectId。稳妥写法是显式转换:
Criteria.where("_id").is(new ObjectId(id))3.3 嵌套文档与 DBRef 要不要用
嵌套文档建议直接内嵌。订单快照里的商品明细,本来就是订单的一部分,读取时需要完整拿到,不存在频繁独立修改的需求,内嵌是最合适的。
@DBRef我基本不用。它虽然能关联另一个集合,但 Spring Data 的懒加载在跨服务、跨线程的场景下容易踩坑,而且一次查询触发多次额外查询,性能不可控。与其为了所谓的“复用”去引用,不如把需要的字段冗余进当前文档,这也是 MongoDB 反规范化的常见做法。一致性靠写入方去保证。
3.4 LocalDateTime 的存储格式
Java 8 时间类型在 Spring Data MongoDB 中默认会转成java.util.Date存储,但 Jackson 序列化时可能又输出成数组或时间戳,前端解析不友好。我一般通过@JsonFormat统一:
@Field("create_time") @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8") private LocalDateTime createTime;同时建议在 application.yml 中配置 Jackson 的时间格式,否则每个字段都加注解,很啰嗦。
4. 增删改查:Repository 与 MongoTemplate 双轨实践
4.1 MongoRepository 的常用姿势
MongoRepository 适合字段固定、查询条件简单的场景。定义接口:
public interface OrderSnapshotRepository extends MongoRepository<OrderSnapshot, String> { Optional<OrderSnapshot> findByOrderNo(String orderNo); List<OrderSnapshot> findByUserIdAndStatus(Long userId, Integer status); Page<OrderSnapshot> findByUserId(Long userId, Pageable pageable); }方法名解析规则在文档里写得很全,findByUserIdAndStatus会自动生成等值查询。注意方法名长度,超过三个条件我建议直接用@Query,或者转用 MongoTemplate,否则一长串方法名既难读也难维护。
@Query支持 JSON 查询条件,还能结合 SpEL 引用参数:
public interface OrderSnapshotRepository extends MongoRepository<OrderSnapshot, String> { @Query("{ 'userId': ?0, 'status': { $in: ?1 } }") List<OrderSnapshot> findByUserIdAndStatuses(Long userId, List<Integer> statuses); @Query("{ 'orderNo': ?0 }") Optional<OrderSnapshot> findByOrderNo(String orderNo); }4.2 MongoTemplate 处理动态查询
真实业务里查询条件经常是动态拼出来的:前台筛选,传了状态就加状态条件,传了时间范围就加时间条件。用固定接口方法写就得枚举所有组合,非常痛苦。这种场景我直接用 MongoTemplate:
@Service public class OrderSnapshotQueryService { private final MongoTemplate mongoTemplate; public OrderSnapshotQueryService(MongoTemplate mongoTemplate) { this.mongoTemplate = mongoTemplate; } public List<OrderSnapshot> search(OrderQuery query) { Query q = new Query(); if (query.getUserId() != null) { q.addCriteria(Criteria.where("userId").is(query.getUserId())); } if (query.getStatus() != null) { q.addCriteria(Criteria.where("status").is(query.getStatus())); } if (query.getStartTime() != null && query.getEndTime() != null) { q.addCriteria(Criteria.where("createTime") .gte(query.getStartTime()) .lt(query.getEndTime())); } q.with(Sort.by(Sort.Direction.DESC, "createTime")); return mongoTemplate.find(q, OrderSnapshot.class); } }MongoTemplate 的好处是构建 Criteria 时完全代码化,逻辑分支清晰,不用拼 JSON 字符串,也规避了注入问题。
4.3 新增、更新、删除的关键细节
新增走save:
public void createOrder(OrderSnapshot order) { if (order.getOrderNo() == null) { throw new IllegalArgumentException("orderNo 不能为空"); } mongoTemplate.save(order); }如果希望重复调用不产生重复文档,可以在orderNo上建唯一索引,并在插入时处理DuplicateKeyException。save在文档不存在时执行 insert,存在时执行 replace(整体替换),不是部分字段更新。想部分更新就要用update:
public void updateStatus(String orderId, Integer targetStatus) { Update update = new Update(); update.set("status", targetStatus); update.set("updateTime", LocalDateTime.now()); Query query = new Query(Criteria.where("_id").is(new ObjectId(orderId))); UpdateResult result = mongoTemplate.updateFirst(query, update, OrderSnapshot.class); if (result.getMatchedCount() == 0) { throw new RuntimeException("订单不存在"); } }updateFirst只更新第一条匹配,updateMulti更新所有匹配。判断是否更新成功不能只看返回值,要看getMatchedCount()和getModifiedCount()。比如更新值和原值一样时,matchedCount 可能是 1,modifiedCount 是 0,不能把这两种情况混为一谈。
删除操作:
public void deleteById(String id) { Query query = new Query(Criteria.where("_id").is(new ObjectId(id))); mongoTemplate.remove(query, OrderSnapshot.class); }逻辑删除比物理删除更安全,天级任务批量物理删除。如果追求性能,删除条件索引建立后再批量跑,不然生产环境删大量数据可能导致锁集合。
4.4 Repository 与 MongoTemplate 的取舍
我自己的分界线是这样:单实体、条件固定、方法名短,用 Repository;动态条件、聚合管道、批量更新,用 MongoTemplate;复杂聚合查询用Aggregation配合 MongoTemplate 执行。两种方式在同一个服务里共存完全没问题。
5. 分页、排序与聚合操作的完整实战
5.1 分页怎么设计才不踩坑
MongoRepository 自带分页支持,但很多人忽略一个前提,Page<T>里的totalElements会额外执行一次 count 查询。数据量小时无所谓,数据量大时 count 查询可能成为瓶颈。
基础分页:
public Page<OrderSnapshot> pageByUser(Long userId, int pageNum, int pageSize) { Pageable pageable = PageRequest.of(pageNum, pageSize); return orderSnapshotRepository.findByUserId(userId, pageable); }pageNum从 0 开始,前端传 1 的话记得减一。我这里习惯统一封装一个分页 DTO:
public class PageResult<T> { private List<T> records; private long total; private int pageNum; private int pageSize; }查询类接口我建议第一版就预留分页参数,哪怕业务方现在说数据量小不需要,等数据涨起来再做兼容改造,前后端联调成本都比一开始加要高。
当数据量超过千万级,普通的skip+limit深分页会越来越慢,因为 MongoDB 需要从头扫描跳过大量文档。优化手法是“条件分页”,记住上一页最后一条记录的排序字段值,下一页用createTime < ?去查,从根上避免深分页。
5.2 排序规则与控制
排序通过Sort组合:
Sort sort = Sort.by( Sort.Order.desc("createTime"), Sort.Order.asc("status") ); Pageable pageable = PageRequest.of(0, 20, sort);需要提醒的是,排序字段如果有缺失值或者类型不一致,结果顺序可能不符合预期。比如status字段在部分旧文档里不存在,排序时这些文档会排在前面还是后面取决于实现,最好通过聚合project阶段统一处理缺省值。
中文排序在 MongoDB 里默认按 Unicode 编码排序,不是按拼音。如果业务需要拼音排序,要么存一个拼音字段,要么用 collation 指定 locale,性能也会相应变化,这点要提前和产品确认。
5.3 聚合管道核心 stage
聚合管道是 MongoDB 最有价值的功能,Spring Data 里用Aggregation类构建。一个常见的统计需求:统计每个商品分类的销售总额。
public List<CategorySalesStat> statCategorySales(LocalDateTime start, LocalDateTime end) { Aggregation agg = Aggregation.newAggregation( Aggregation.match( Criteria.where("createTime").gte(start).lt(end) .and("status").ne(99) ), Aggregation.unwind("items"), Aggregation.group("items.categoryId") .sum("items.amount").as("totalAmount") .count().as("orderCount"), Aggregation.project() .and("_id").as("categoryId") .and("totalAmount").as("totalAmount") .and("orderCount").as("orderCount"), Aggregation.sort(Sort.Direction.DESC, "totalAmount"), Aggregation.limit(20) ); AggregationResults<CategorySalesStat> results = mongoTemplate.aggregate(agg, "order_snapshot", CategorySalesStat.class); return results.getMappedResults(); }每个 stage 的作用:
match:先过滤,尽量减少进入后续管道的文档量。不要拖到 group 之后再过滤,那样 group 已经把所有数据聚合一遍,资源和时间都浪费了。unwind:把订单里的 items 数组展开成多行,再做分类统计。group:按items.categoryId分组,执行sum和count。字段名默认_id,后续用project重命名。project:重命名_id为categoryId,只保留需要的字段。sort+limit:排序取 Top N。
这是聚合管道的黄金法则:先过滤,再处理,后统计。
5.4 group 之后的分页困境与 $facet 解法
聚合管道执行顺序是串联的,group完成后再sort、skip、limit看似可行,但你没法在同一个管道里同时拿到过滤后的总数和分页后的列表。传统写法是执行两次聚合:一次只跑 match+group+count,另一次跑 match+group+sort+skip+limit。两次聚合之间如果数据在变动,总数和列表可能不一致,对多数报表场景可接受,但对实时性要求高的接口不行。
MongoDB 3.4 之后提供$facet,可以在一个聚合管道里并行执行多个子管道。Spring Data 里这样写:
public PageResult<CategorySalesStat> statCategorySalesPage( LocalDateTime start, LocalDateTime end, int page, int size) { Aggregation agg = Aggregation.newAggregation( Aggregation.match( Criteria.where("createTime").gte(start).lt(end) .and("status").ne(99) ), Aggregation.unwind("items"), Aggregation.facet( Aggregation.group("items.categoryId") .sum("items.amount").as("totalAmount") .count().as("orderCount"), Aggregation.sort(Sort.Direction.DESC, "totalAmount"), Aggregation.skip((long) page * size), Aggregation.limit(size) ).as("records") .and( Aggregation.group("items.categoryId") .sum("items.amount").as("totalAmount") .count().as("orderCount"), Aggregation.count().as("total") ).as("totalInfo") ); AggregationResults<Object> results = mongoTemplate.aggregate(agg, "order_snapshot", Object.class); // 手动解析 records 和 totalInfo 两个分组 }Aggregation.facet()的具体 API 在不同版本略有差异,老版本 Spring Data MongoDB 对$facet的支持不够好,直接写 MongoDB JSON 聚合管道更省心。我线上一次遇到复杂分页和 TopN 需求,就直接用mongoTemplate.getCollection("order_snapshot").aggregate(...)传递原生 BSON 管道对象,避免框架版本的约束。
$facet并不是银弹,它会让单个聚合执行更复杂,MongoDB 内部并行处理子管道,但内存和 CPU 开销会上升。业务上如果只是几十上百的分组结果,直接全量查出再内存分页也没问题,连聚合都不用。
5.5 聚合结果映射与 _id 处理
聚合结果默认字段名是_id,Java DTO 里的映射很容易出错。我习惯在 ADTO 里直接用@Field("_id")标注:
public class CategorySalesStat { @Field("_id") private String categoryId; @Field("totalAmount") private BigDecimal totalAmount; @Field("orderCount") private Long orderCount; }如果_id是复合条件,比如按“分类 + 日期”分组,那_id可能是个嵌套文档。遇到这种情况,我会手动解析:
public class CategoryDailyStat { private String categoryId; private String statDate; private BigDecimal totalAmount; public static CategoryDailyStat fromDocument(org.bson.Document doc) { Document id = (Document) doc.get("_id"); CategoryDailyStat stat = new CategoryDailyStat(); stat.categoryId = id.getString("categoryId"); stat.statDate = id.getString("statDate"); stat.totalAmount = new BigDecimal(doc.get("totalAmount", Number.class).toString()); return stat; } }聚合结果里的数值类型可能是 Integer、Long、Double 或 Decimal128,直接强转BigDecimal会抛异常,稳妥做法是先取Number再转字符串构造 BigDecimal。
5.6 时间维度聚合
运营报表里经常要按天、按小时统计订单量。MongoDB 的$dateToString可以把日期格式化成指定字符串:
Aggregation agg = Aggregation.newAggregation( Aggregation.match(Criteria.where("createTime").gte(start).lt(end)), Aggregation.project() .and(DateOperators.dateOf("createTime") .toString("%Y-%m-%d")) .as("day") .and("totalAmount").as("totalAmount"), Aggregation.group("day") .sum("totalAmount").as("totalAmount") .count().as("orderCount"), Aggregation.sort(Sort.Direction.ASC, "_id") );Spring Data 的DateOperators类封装了日期运算函数,在 project 阶段对日期格式化和时区转换很有用。时区必须注意,MongoDB 实例和服务可能在不同时区,统计口径不一致很容易被业务挑战。我统一做法是:存储时用 UTC,展示统计结果时在聚合阶段转换到目标时区,避免不同应用服务器时间差异导致边界数据不齐。
6. 线上常见问题与排查记录
6.1 连接与配置类问题速查
| 现象 | 可能原因 | 处理方式 |
|---|---|---|
| 启动报 Authentication failed | 用户名或密码错误,或 authSource 指向错误 | 检查连接串中 authSource=admin,密码含特殊字符需要 URL 编码 |
| 请求间歇性超时 | serverSelectionTimeoutMS 太短,或池被占满 | 适当调大超时,检查慢查询是否拖住连接 |
| 配置中心刷新后连接不更新 | MongoDB 连接池不会自动释放旧连接 | 通过 actuator 重启服务,或实现配置变更后的冷启动切换 |
| 副本集切换后大量写入失败 | 节点心跳超时,驱动未及时感知 | 确认副本集状态,合理设置 heartbeatFrequencyMS |
密码含特殊字符是最常见的坑。比如密码是p@ssw0rd,直接写进 URI 会导致解析异常或认证失败,必须转成p%40ssw0rd。我整理连接串时都习惯先跑一个new URI("mongodb://...") 的测试,确保自己拼接的字符串能正常解析。
6.2 查询数据映射问题
查询结果字段缺失、类型异常,排在第一位的原因是实体字段名与 MongoDB 下划线字段名不一致。用@Field统一标注后,这类问题能减少八成。
第二个高频问题是_id转换异常。前端传id字符串,拿去findById偶发失效,我曾排查过:因为某些文档的_id是插入时手动指定的字符串业务编号,某些是自动生成的 ObjectId,直接用is(id)去匹配,类型不一致导致漏数据。后来我在代码里根据ObjectId.isValid(id)决定是否转换,才彻底解决。
还有一个现象:查询接口第一次数据正常,第二次加上排序后数据异常,原因是排序的字段在部分文档里缺失,MongoDB 用 null 参与排序,顺序不稳定。解决方案是给缺失字段一个默认值,或者建索引后重新梳理数据。
6.3 聚合性能排查方法论
聚合查询慢,先看是不是没有走索引。用explain验证:
// 在 mongo shell 中执行 db.order_snapshot.explain("executionStats") .aggregate([{ $match: { userId: 1, createTime: { $gte: ISODate(...) } } }])重点关注executionStats里totalDocsExamined和totalKeysExamined两个数字。如果totalDocsExamined和目标结果集数量悬殊,说明索引没覆盖查询条件。
MongoDB 开启慢查询日志:
db.setProfilingLevel(1, { slowms: 200 }) db.system.profile.find({ millis: { $gt: 1000 } }).sort({ ts: -1 }).limit(20)生产环境不建议长期开 profiling,最好只在排查窗口期开,抓完慢查询就关。更优雅的方案是接入数据库命令监控,把mongoTemplate.getMongoClient()里每个 command 的耗时打出来,链路追踪里直接看具体操作。
6.4 微服务场景特有的注意事项
第一,配置脱敏。MongoDB 连接串放到配置中心后,密码不能明文暴露在日志和监控看板里。我见过同事把整个 URI 打到日志后,密码被扫码工具自动识别并上报的情况。建议配置中心使用加密存储,应用启动时解密后再设置给 Spring Data。
第二,多租户或按环境切换库。如果一套代码需要连接多个 MongoDB 实例,不要动态替换 MongoTemplate,维护成本极高。更好的做法是按租户拆分微服务实例,每个实例只连接自己的数据库;实在不行再用 ThreadLocal 缓存多个 MongoTemplate,但连接池和运维复杂度会成倍上升。
第三,事务边界。MongoDB 4.0 之后可以在副本集上做多文档事务,但 Spring Cloud 微服务之间的事务传播无法靠数据库事务解决。贷款下单这类跨服务的业务,我仍然建议用消息队列 + 本地消息表做最终一致性,MongoDB 事务只保单服务内部的原子性。
6.5 其他容易忽略的边界场景
写入时Insert和Save的语义差异,很多人写过一次就没再回头。save如果入参对象带了_id,且这个_id已经存在,会执行整体替换,覆盖掉其他字段的旧值。批量导入场景建议用insert,防止误更新旧文档。
批量写入性能方面,一次性insertMany比循环save快得多。但要注意单个批次大小,官方建议单批不超过 1000 条或单条不超过 16MB。如果批量插入大文本字段,可以先压缩再入库,MongoDB 本身是按文档存储,压缩后查询时再解码,整体 IO 能降一个量级。
还有个隐蔽问题:文档大小上限是 16MB,内嵌数组如果不断增长,迟早撑爆。用户购物车、操作日志这类持续增长的数组,应该拆到独立集合,或者设置数组最大长度,超过后滚动淘汰旧数据。
7. 写在最后的几条经验
前面几部分基本把 Spring Cloud 集成 MongoDB 的增删改查、分页排序和聚合操作讲透了,最后分享几个我自己实际项目中对这套方案的体会。
第一个经验:所有查询接口,第一版就加分页参数,哪怕数据量现在只有几百条。线上数据永远比你预估涨得快,而且等页面真的需要分页时,前后端联调改造的成本远高于一开始多写两个参数。
第二个经验:聚合管道一定要强制保持“先 match 再处理”的顺序。我线上出过一次事故,产品要一个全量统计报表,我图省事把 match 写到了 group 后面,结果千万级文档全部参与 group,直接把共享集群的 CPU 打满,十几个服务全部变慢。后来我在团队代码规范里加了一条规定:任何聚合,第一个 stage 必须是 match,没有例外。
第三个经验:Spring Data MongoDB 是个不错的封装,但它屏蔽了很多 MongoDB 底层的执行细节,遇到性能问题不要只在框架层排查,直接连到 Mongo Shell 里跑一条原生管道,用 explain 看执行计划,往往瞬间就能定位问题所在。
这套方案我在内部沉淀成了一套脚手架,新服务接入时先复制基础配置和通用 Repository,再按业务扩展实体类,基本一周就能完成数据访问层的搭建。MongoDB 不是万能的,但如果你清楚它适合解决什么问题,配合 Spring Cloud 微服务架构,写起来确实比关系型数据库在某些场景下轻松得多。