SpringBoot4系列04 - 集成MongoDB

上一篇结尾我留过一个判断:OrderRepository 这个接口再换一次实现,如果还那么顺,分层这件事就算做对了。

这次换 MongoDB。

先把我的结论放这儿:接口契约又是一个字没改,Controller、DTO、状态机、错误响应的形状全部原样,连 /api/orders/{id} 路径都一样。唯一看得见的变化是主键从自增数字变成了 24 位十六进制的 ObjectId。

但从这一篇开始,「换个实现」这件事的性质变了。前两篇换的是同一个世界里的不同工具,第二篇是 MyBatis-Plus,第三篇是 JPA,底下都是表、行、列、事务。这次底下那套东西全都不在。

我踩到的最大的坑跟 MongoDB 本身没关系,出在连接属性的前缀上:我按错误的前缀写完配置,应用照样起来,接口全部正常,过了很久才发现连的并不是配置里那个库。

也没有报错,也没有警告,就是安安静静地连到了 localhost 的默认库。

https://static.xiongneng.me/config-silent-fallback-20260921220212.png

这事我后面单独拿一节说。它比第三篇那个「SQL 日志一行都没有」还难受:日志没有至少还能察觉,这个是全绿。

代码全部可跑,工程在 springboot4-mongodb 目录下。这一篇的 MongoDB 跑在我本机 Windows 上,不在树莓派上,原因我放在最后交代。

先分清文档型和关系型

表还是表、行还是行的日子在前两篇。这个概念从这一篇开始必须讲清楚,不然后面所有设计都看着别扭。

https://static.xiongneng.me/relational-vs-document-20260921220212.png

关系型这边,订单要有明细就得建两张表,用外键连起来。查一条完整订单要 join,写一条要写两张表,而且必须在事务里,不然中间失败会留下半条订单。

MongoDB 存的是文档,一串 BSON 对象,字段可以嵌套,明细直接嵌在订单里面,是个数组字段。

一条订单就是一条文档,一个集合就是一堆文档。没有 join,因为它压根不需要。

这里有个词要换掉:没有表,叫集合;没有行,叫文档;没有列,叫字段。平时混着叫没关系,你看官方文档时得知道它说的是什么。

还有就是没有 schema:MongoDB 不建表,我第一次往 orders 里写文档的时候,集合自己就出现了,库也不需要 create database。关系型要先建表、字段类型定死,改起来得写 DDL。

听着很自由,代价是约束没了:写进去 "299.50" 和 299.5 都行,读出来自己处理。后果我在聚合那一节展开。

单节点的 MongoDB 不支持多文档事务。 这一条直接决定 Service 层的写法,第三篇那一堆 @Transactional 到这里一个都用不上。

并发安全靠单文档原子操作:一条订单的所有改动都在同一个文档里,MongoDB 保证单文档更新原子,判断和写入能在服务端一次做完。我认为这是文档型建模的价值所在,不是为了少写 join。

代价也很实在:明细嵌进订单后,按商品维度统计得先拆数组,单个文档不能无限大,BSON 上限 16MB,明细几十万条就别嵌了。

主键方面,MongoDB 的 _id 默认是 ObjectId,12 字节、24 位十六进制,带时间戳和随机数,由客户端生成。

实体里主键我声明成 String 而非 ObjectId,/api/orders/{id} 和前三篇长得一样,不用为序列化多写一层;Spring Data 会自动转,不需要转换器。

ObjectId 不保证递增:按生成时间大致有序,同一秒内多个客户端生成的顺序是随机的。所以「按 id 倒序拿最新那条」在这里不成立,要按时间排就排 created_at。

4.1.1 在 Boot 4 上的几个变化

版本我先查清楚。spring-boot-dependencies:4.1.1 锁的是这些。

组件版本
Spring Data MongoDB5.1.1
MongoDB Java Driver5.8.1
Spring Data Commons4.1.1
MongoDB Server(本工程验证环境)8.0.32

驱动版本这一行我盯了一会儿:Spring Data MongoDB 5.x 要求驱动 5.6 以上,BOM 锁的 5.8.1 一致。这个话题后面还会碰到。

这一篇的第一个重点:MongoDB 的自动配置拆成了两个模块。

JPA 那三个模块界限清楚,MongoDB 这两个模块管「连接」和「数据访问」,而这两件事的属性前缀不一样。

模块里面装了什么对应的属性前缀
spring-boot-mongodbMongoAutoConfiguration、MongoProperties、MongoConnectionDetailsspring.mongodb.*
spring-boot-data-mongodbSpring Data 那一层,含索引解析与仓储自动配置spring.data.mongodb.*

第二列看仔细,这是本篇最要紧的一张表。

import MongoProperties 要写 org.springframework.boot.mongodb.autoconfigure.MongoProperties;写 org.springframework.boot.autoconfigure.mongo.* 这个包会直接编译失败,4.1.1 里没有这个包。我一开始就写错了,编译器当场拦下来的。

属性前缀与静默失效

连接属性统一在 spring.mongodb.* 前缀下,一共十几项,常用的是 spring.mongodb.uri、host、port、database、username、password、authentication-database。

健康检查和指标的前缀也不一样,是 management.health.mongodb.enabled 和 management.metrics.mongodb.*。

写成 spring.data.mongodb.uri 这类旧前缀,后果是静默失效。元数据里标了 level = error 和 since = 4.0.0,但运行期连警告都没有。

我翻了 spring-boot-mongodb 的配置元数据,那族旧属性躺在里面,但每一项只有一个 name 和一条 deprecation,没有 type,也没有 sourceType。这种条目只是 IDE 提示,管补全和标黄;绑定属性靠 sourceType 指向的配置类,而这里没有。

我再去翻 MongoProperties,字段只有一套,对应 spring.mongodb.*。后来我干脆把 Boot 的 jar 全量扫了一遍,含 spring.data.mongodb 这个字面量的 class 一个都没有。

静态推断容易翻车,我于是写了个测试:用 ApplicationContextRunner 只起 MongoAutoConfiguration,只配旧属性,看框架最终拿到什么连接串。

拿到的是 mongodb://localhost/test。不是报错,不是警告,是默认值,配置里那个地址、账号、库名全部落空。

这个测试我留在工程里,MongoPropertyRenameTest,六个用例专门验证这件事。踩过的坑写成断言比写成注释有用,注释会被忽略,测试会红。

取证的时候还有个坑

取证的时候我顺手加了一条断言,结果它挂了。

判断「配置生效了没有」,我最初用的是 MongoProperties.determineUri()。分字段那一组用例,host、port、database、username 逐个断言都过,字段全绑上了,但 determineUri() 返回的还是 mongodb://localhost/test。

我反编译这个方法,一共三行。

1
return (uri != null) ? uri : "mongodb://localhost/test";

它只认 uri 这一个字段,host、port、database、username、password 一概不看。分字段写法由另一个类消费:PropertiesMongoConnectionDetails#getConnectionString(),配了 uri 就用 uri,否则拿 host 和 port 拼一个连接串。

权威的是 MongoConnectionDetails#getConnectionString(),那才是 MongoClient 手里拿到的那个串。

断言我全部改到那个接口上,顺便留了一条 determineUriOnlyLooksAtUri 当化石,免得以后有人拿它做判断。

顺便记一个驱动行为,我在这里闹过一次笑话:ConnectionString#getHosts() 会把默认端口 27017 省掉。mongodb://localhost/test 拿到的是 ["localhost"],不是 ["localhost:27017"],非默认端口才会带上,断言照直觉写在这里就会挂。

留在 spring.data.mongodb 前缀下的属性

spring.data.mongodb.auto-index-creation 这个配置项不归连接层管,仍然在 spring.data.mongodb 前缀下。

连接层归 MongoProperties,前缀 spring.mongodb;Spring Data 层归 DataMongoProperties,前缀 spring.data.mongodb。两套属性各归各家。

同一个 yml 文件里会出现这种写法。

1
2
3
4
5
6
spring:
  mongodb:
    uri: mongodb://root:root123456@127.0.0.1:27017/springboot4_mongo?authSource=admin
  data:
    mongodb:
      auto-index-creation: true

上面是连接层,下面是数据层,两个都对。

这里有个很实际的后果,我提醒你一句:批量把 spring.data.mongodb. 前缀替换成 spring.mongodb. 时,auto-index-creation 会跟着被改错,然后它也会静默失效,结果是「代码里写着唯一索引、库里什么都没有」。

仍留在 spring.data.mongodb 前缀下的还有 field-naming-strategy、repositories.type、representation.big-decimal、gridfs.*,这几个本工程没用到,配置清单里你得记着。

测试注解的包名

@DataMongoTest 和 @AutoConfigureDataMongo 在 ...boot.data.mongodb.test.autoconfigure 包里。规律跟前三篇一致,包名就是模块路径加上 .test.autoconfigure:第一篇 webmvc.test.autoconfigure,第二篇 mybatis.test.autoconfigure,第三篇 data.jpa.test.autoconfigure。

每换一个技术栈,测试注解的包名都要重新确认一遍,照抄上一篇的 import 会直接编译失败,我就照抄过一回。

Spring Data MongoDB 5.x 的几条关键行为

官方迁移指南里有几条直接影响业务代码,我挑出来:BigDecimal 和 BigInteger 默认按 DECIMAL128 转换,且映射配置没有默认值,不显式配置会有告警,UUID 的表示同样没有默认值。这一条不报错,只是存进去的东西长得不一样,你项目里有金额字段的话,跑起来之后去库上看一眼类型。

@Meta.allowDiskUse 的类型是 String,管大聚合的内存限制,用到的项目不多。

JMX 这边没有支持,你手里要是有基于 JMX 的监控采集脚本,会直接失效。

还有一条,DefaultMessageListenerContainer 自动启动默认值是 true,属消息监听那边,跟 MongoDB 关系不大,顺带提一句。

依赖和配置

依赖比第三篇还短,官方 starter 直接用就够。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-webmvc</artifactId>
</dependency>
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-validation</artifactId>
</dependency>
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-mongodb</artifactId>
</dependency>
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-mongodb-test</artifactId>
    <scope>test</scope>
</dependency>

注意最后一个,测试 starter 是独立坐标 spring-boot-starter-data-mongodb-test,JPA 那篇没有这个东西。

配置长这样,这是我能跑通全篇的最小集。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
spring:
  mongodb:
    uri: mongodb://${MONGO_USER:root}:${MONGO_PASSWORD:root123456}@${MONGO_HOST:127.0.0.1}:${MONGO_PORT:27017}/${MONGO_DB:springboot4_mongo}?authSource=admin
  data:
    mongodb:
      auto-index-creation: true
  mvc:
    problemdetails:
      enabled: true
  jackson:
    default-property-inclusion: non_null

账号密码走环境变量,默认值是本机地址,你直接跑之前记得改。

auto-index-creation 这一行是这一篇能跑通的前提,下面单独讲。

problemdetails 和 jackson 那两行沿用前三篇,错误响应还是 RFC 9457 那套。

还有一行日志配置放在最后,平时别开。

1
2
3
logging:
  level:
    org.mongodb.driver.protocol.command: DEBUG

打开它能看到驱动发出去的每一条命令原文。这一篇有两处结论,就是靠它拿到的。

核心代码

索引这件事,默认是不做的

实体上声明索引很简单,但它最容易被忽略、代价也最实在。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
@Document(collection = "orders")
@CompoundIndex(name = "idx_status_created_at", def = "{'status': 1, 'created_at': -1}")
public class Order {

    @Indexed(unique = true, name = "uk_order_no")
    @Field("order_no")
    private String orderNo;

    @Indexed(name = "idx_status")
    @Field("status")
    private OrderStatus status;
}

三处声明,一个唯一索引,一个单字段索引,一个复合索引。

默认情况下,这三个索引一个都不会被创建。

https://static.xiongneng.me/index-not-created-by-default-20260921220212.png

@Indexed 只是一份索引定义,去建索引的是 spring.data.mongodb.auto-index-creation 这个开关,而它的默认值是 false。

这件事有多难受,我专门做了一遍给你看:开关关掉又跑了一遍,工程里留了一个反例测试 MongoIndexCreationOffTest,用另一个库做隔离。

结果:文档正常写入,findByOrderNo 正常返回,一切看着都好。我数了一下索引,除 MongoDB 自带的 _id 什么都没有。然后我拿同一个订单号再插一条,插进去了。唯一约束不存在。

代码里白纸黑字写着 @Indexed(unique = true),业务上却没有唯一性保证,全程没有任何地方会报错。日志干净,响应正常,只有数据是错的。

反向验证我也做了,把索引列出来看。

1
2
3
4
_id_                      {_id: 1}
idx_status_created_at     {status: 1, created_at: -1}
uk_order_no               {order_no: 1}  [unique]
idx_status                {status: 1}

三个都在,方向也对,uk_order_no 带着 unique 标记。

这三个索引不是凭空冒出来的。我把驱动命令日志打开,应用启动时发了三条 createIndexes。

1
2
3
4
5
6
7
8
{"createIndexes": "orders",
 "indexes": [{"key": {"status": 1, "created_at": -1}, "name": "idx_status_created_at"}]}

{"createIndexes": "orders",
 "indexes": [{"key": {"order_no": 1}, "name": "uk_order_no", "unique": true}]}

{"createIndexes": "orders",
 "indexes": [{"key": {"status": 1}, "name": "idx_status"}]}

服务端的回执我也一起看了,索引数依次是 1、2、3、4,第一条还顺带把集合建了出来。

从声明到建索引的每一步都对上了:@Indexed 和 @CompoundIndex 声明,映射层收集,索引解析器处理,启动时下发 createIndexes。中间任何一环断了,都是「代码里有、库里没有」这个结局。

还有一个细节,复合索引的 def 里必须写 created_at,不能写 createdAt:这个字符串原样发给 MongoDB,MongoDB 只认文档里真实存在的字段名,而这个类把 createdAt 映射到了 created_at。写成 createdAt 不会报错,索引照样建出来,只是永远用不上,唯一能发现的地方是 explain() 里看它没进 winningPlan。

字段名映射,以及混在同一个类里的两套规则

@Field("order_no") 这类注解把 Java 属性名和文档字段名对上。我刻意改成下划线风格,是为了让同一套数据模型在 MySQL 和 MongoDB 里字段名对得上,对照前两篇的 SQL 不用再做映射。

代价是,这个类里同时存在两套命名规则。

派生查询的方法名用 Java 属性名,findByOrderNo、countByStatus、findByTotalAmountGreaterThanEqual,这些都是属性名。手写的查询、聚合、索引定义,用的是 @Field 指定的存储名。

这个界线是这一篇最容易写错的地方,@Query 那一节有我翻车的实例。

@Transient 漏了会出一个幽灵字段

明细里有一个计算字段。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
public class OrderItem {

    @Field("product_name")
    private String productName;

    private BigDecimal price;

    private int quantity;

    @Transient
    public BigDecimal getAmount() {
        return price.multiply(BigDecimal.valueOf(quantity));
    }
}

amount 不在数据库里,它是算出来的,只出现在接口响应里。

注意这个 @Transient 来自 org.springframework.data.annotation,不是 jakarta.persistence.Transient:两个都合法,IDE 不提示,但用错了不生效。

后果是 amount 变成一个落库的真实字段,值是第一次写入时算出来的,之后再也不会更新。你改一下数量,库里那个 amount 还是老数字。一个不报错、不同步的幽灵字段,查库的时候直接看原始文档最清楚。

items 里只有 product_name、price、quantity,没有 amount。这是对的。

另外两处顺便看:字段名全是蛇形,接口返回的却是驼峰,说明 @Field 只管存储、不影响序列化;金额是 Decimal128 不是 Double,对应 5.x 的默认映射行为,299.50 不会变成 299.5,这一条对存金额的项目来说是稳稳的赚。

_class 那一项是 Spring Data 用来支持多态映射的,属正常现象。

仓储,以及手写的查询到底会不会被翻译

派生查询不用讲,跟第三篇一模一样,方法名即查询。

手写的部分挺有意思:我最初判断 @Query 里那段 JSON 是裸字符串,下发前不会有任何改动,就在仓储里写了两个方法做对照。

1
2
3
4
5
@Query("{ 'items.product_name': ?0 }")
List<Order> findByItemProductName(String productName);

@Query("{ 'items.productName': ?0 }")
List<Order> findByItemProductNameWithPropertyName(String productName);

一个写存储名,一个写属性名。我当时的判断:前者能查到,后者查不到。

测出来是两个都能查到。

Spring Data 在下发前把能对上属性的路径翻译成了存储名,items.productName 被翻成 items.product_name,「裸字符串原样下发」的假设不成立。

顺着往下试,结论一致:

Criteria.where("items.productName"),下发的是 items.product_name。

Update.set("customerName", ...),落到 customer_name 上,而且不会多出一个同义字段。

这里还有个反过来的陷阱。org.springframework.data.mongodb.core.MongoTemplate 的 DEBUG 日志打的是翻译前的 Query,不能当作「下发了什么」的证据。

这层日志打开,按 id 查一条订单,输出是这样:

1
2
findOne using query: { "id" : "6aad2e3f603b044e25ffe593"} fields: Document{{}}
    for class: class com.xncoding.mongo.domain.Order in collection: orders

id。库里那个字段叫 _id。我照着这行去 pymongo 里试,一条都查不出来。

报文要去驱动层看:

1
2
Command: {"find": "orders", "filter": {"_id": {"$oid": "6aad2e3f603b044e25ffe593"}},
          "limit": 1, "singleBatch": true, "$db": "springboot4_mongo"}

同一句话,两个日志两个样子。MongoTemplate 打的是转换之前的 Query 对象,属性名到存储名的转换发生在再往下一层,所以日志里 id 还是 id。

要拿日志当证据,只有 org.mongodb.driver.protocol.command 那一层算数:中间层的日志看着最像人话,最容易当成结论用,而它恰恰是转换之前那一步的快照。我现在只认驱动层的 Command: {...},这是我的铁证标准。

从属性名这一侧思考基本是安全的,例外是下面那个聚合别名的坑。

那个用例叫 queryAnnotationDoesTranslateFieldNames,断言反过来写,测「两种写法结果一致」。

聚合,以及这一篇最隐蔽的坑

状态统计那个查询我先用 @Aggregation 写。

1
2
3
4
5
@Aggregation(pipeline = {
        "{ $group: { _id: '$status', count: { $sum: 1 }, totalAmount: { $sum: '$total_amount' } } }",
        "{ $sort: { _id: 1 } }"
})
List<OrderStatusStat> statByStatus();

返回类型上要对两处做映射。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
public class OrderStatusStat {

    @Field("_id")
    private OrderStatus status;

    private long count;

    @Field("total_amount")
    private BigDecimal totalAmount;
}

_id 是固定的,$group 之后分组字段一定叫 _id,躲不掉。

total_amount 那个就出事了。

测试跑出来 totalAmount 是 null。我去查报文,打开驱动命令日志,抓下发给服务端的 $group 原文。

1
2
3
注解版:
{"$group": {"_id": "$status", "count": {"$sum": 1},
            "total_amount": {"$sum": "$total_amount"}}}

注解里写的是 totalAmount,下发出去变成了 total_amount。

@Aggregation 会把管道里的输出别名也当属性名翻译一遍。

同一个管道里的 count 没被改,因为实体里没有叫 count 的属性,翻译层只处理能对上的。

那 fluent API 呢,就是 Aggregation.group().sum().as() 那一套。

这里我犯过一个方法上的错误:为了让两条路径结果一致,我把 fluent 那侧的 .as() 也写成了存储名 total_amount,改完测试全绿。

再看报文,两条管道下发出去长得一模一样,什么都比不出来。

注解版被翻译之后的结果,恰好等于手写的那个正确值。这种「正确写法看不出问题」的情况,需要一个反例才能把差别暴露出来。

于是我加了个故意的反例接口。

1
2
3
4
5
6
7
8
9
public List<OrderStatusStat> statByStatusNaive() {
    Aggregation aggregation = Aggregation.newAggregation(
            Aggregation.group("status")
                    .count().as("count")
                    .sum("total_amount").as("totalAmount"),
            Aggregation.sort(Sort.Direction.ASC, "_id"));
    return mongoTemplate.aggregate(aggregation, Order.class, OrderStatusStat.class)
            .getMappedResults();
}

跟正例只差一个名字,.as() 里写的是实体属性名 totalAmount,看着比 total_amount 自然多了。

现在源码里两个地方写的都是 totalAmount,一个在注解里,一个在 .as() 里。报文是这样。

1
2
3
4
5
注解版:  {"$group": {"_id": "$status", "count": {"$sum": 1},
                     "total_amount": {"$sum": "$total_amount"}}}

反例代码版:{"$group": {"_id": "$status", "count": {"$sum": 1},
                     "totalAmount": {"$sum": "$total_amount"}}}

源码一样,发出去的报文不一样。

https://static.xiongneng.me/aggregation-alias-two-paths-20260921220212.png

答案清楚了:@Aggregation 那条路翻译输出别名,fluent API 不翻译,写什么发什么。

结论有点反直觉:注解版里可以写属性名,代码版里必须写存储名。 两套规则不一样。

反例接口留在工程里,/api/orders/stats/naive,你可以三个接口一起调。

1
2
3
4
5
6
7
8
9
注解版    :[{"count":1,"status":"CANCELLED","statusLabel":"已取消","totalAmount":86.00},
            {"count":2,"status":"CREATED","statusLabel":"已创建","totalAmount":1198.50},
            {"count":2,"status":"PAID","statusLabel":"已支付","totalAmount":2468.00}]

代码版    :(逐字一致)

反例代码版:[{"count":1,"status":"CANCELLED","statusLabel":"已取消"},
            {"count":2,"status":"CREATED","statusLabel":"已创建"},
            {"count":2,"status":"PAID","statusLabel":"已支付"}]

count 一样,status 一样,totalAmount 整列消失。

原因是 OrderStatusStat.totalAmount 上标着 @Field("total_amount"),映射层只认这个键,而 fluent API 把 totalAmount 原样发了出去,这个键就悬在结果文档里没人取。

没有报错,没有警告,字段就这么没了。

浏览器里打开这个反例接口,count 和 status 排得整整齐齐,金额那一列不存在。

https://static.xiongneng.me/browser-stats-naive-20260918202746.png

留这个接口就是让你自己 curl 一次,第三篇那个 /summary/projection 也是这么个角色。

模板层,以及没有事务之后怎么写

OrderMongoDao 里是几个我用 MongoTemplate 手写的地方,这一篇的并发安全全在这里。

分页还是老样子,Query 加 Pageable,我不把 Page 直接当契约,外面包一层统一的分页响应体。

状态流转是重点。

1
2
3
4
5
mongoTemplate.findAndModify(
        Query.query(Criteria.where("_id").is(id).and("status").is(expected)),
        new Update().set("status", target).inc("version", 1),
        FindAndModifyOptions.options().returnNew(true),
        Order.class);

第三篇里这个是「在事务里读出实体、判断、改、提交」,靠事务隔离挡并发。这里没有事务可用,所以我换成了把「读到的状态」变成「更新的条件」。

条件里带上 status,别人在这中间改了状态,这次更新就匹配不到任何文档,返回 null,服务层据此抛 422。

判断和写入之间没有任何窗口,因为它们在服务端是同一条命令。

明细的增删也一样。

1
2
3
4
5
6
7
8
// 追加
mongoTemplate.updateFirst(Query.query(Criteria.where("_id").is(id)),
        new Update().push("items", item), Order.class);

// 移除
mongoTemplate.updateFirst(Query.query(Criteria.where("_id").is(id)),
        new Update().pull("items", Query.query(Criteria.where("product_name").is(productName))),
        Order.class);

如果按前两篇的思路写,这两步会是「读出订单、改 items 列表、整体 save 回去」。差别不止是少传点数据:整份 save 回去就是拿旧快照覆盖新状态,两个人同时加明细,后提交的会覆盖先提交的。$push 没有这个问题。

$pull 按条件移除所有匹配的元素,同名商品有两条会一起被删掉。接口返回的 removed 是「影响了几条文档」,不是「删了几个元素」,本工程里这两个值恰好都是 1,容易看混。

商品销量排行那个查询也是模板层。

1
2
3
4
5
6
7
8
9
Aggregation aggregation = Aggregation.newAggregation(
        Aggregation.unwind("items"),
        Aggregation.group("items.product_name")
                .sum(ArithmeticOperators.Multiply
                        .valueOf("items.price")
                        .multiplyBy("items.quantity")).as("salesAmount")
                .sum("items.quantity").as("totalQuantity"),
        Aggregation.sort(Sort.Direction.DESC, "totalQuantity"),
        Aggregation.limit(limit));

这是文档型存储最不直观的一步:明细是订单里的数组字段,按商品粒度统计得先用 $unwind 把数组拆开,一条含 2 个商品的订单变成 2 条记录,每条带一个商品。

等价于关系型的 join t_order_item,但不需要提前把明细建模成独立实体。

代价是中间结果会变大,订单里明细越多越明显,所以 $unwind 之后通常紧跟着 $group 把行数收回来。

这个方法的 .as() 写成了 "total_amount" 这种存储名,看着别扭。原因就是前面那个别名坑,得跟注解版落在同一个键名上。

Service 层一个 @Transactional 都没有

第三篇的 Service 层每个写方法都有 @Transactional,读方法标 readOnly。这一篇一个都没有,不是漏了,是单节点 MongoDB 不支持多文档事务。

写了会怎样?不会报错,Spring 也认,事务管理器在那儿,但它拦不住任何东西。更糟的是它给人一种「有事务保护」的错觉。

那并发安全靠什么?

场景做法保证
整体更新save() 加 @Version乐观锁,版本冲突抛 409
状态流转findAndModify 加当前状态作条件判断与写入服务端原子完成
追加明细$push服务端改数组,不覆盖整份文档
移除明细$pull同上

四条全部依赖同一个前提,业务操作能收进一个文档里。这也是文档型建模的取舍所在。

https://static.xiongneng.me/single-document-atomicity-20260921220212.png

真要跨文档事务,得上副本集,还得显式配 MongoTransactionManager。本工程单节点,不涉及。

异常类型和第三篇对不上

场景JPA(第 3 篇)MongoDB(本篇)
乐观锁冲突ObjectOptimisticLockingFailureExceptionOptimisticLockingFailureException(父类)
唯一键冲突DataIntegrityViolationExceptionDuplicateKeyException(子类,错误码 11000)

乐观锁这一组是子类到父类,唯一键那一组是父类到子类,方向是反的。

我把第三篇的全局异常处理器复制过来改,getPersistentClassName() 那一行直接编译失败。父类没有这个方法。

这个报错比读继承关系快多了,父子关系自己写一遍比看十遍记得牢。

完整案例,跑起来看

这一节我从头走一遍,所有响应都是真实请求的原文,跟我贴的命令一一对应。

创建订单

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
curl -i -X POST http://localhost:8080/api/orders \
  -H "Content-Type: application/json" \
  -d '{
    "customerName": "熊大",
    "customerPhone": "13800138000",
    "totalAmount": 299.50,
    "remark": "演示下单",
    "items": [
      { "productName": "机械键盘", "price": 299.50, "quantity": 1 },
      { "productName": "腕托", "price": 25.00, "quantity": 2 }
    ]
  }'
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
HTTP/1.1 201
Location: http://localhost:8080/api/orders/6aad2e3f603b044e25ffe597
Content-Type: application/json

{"id":"6aad2e3f603b044e25ffe597","orderNo":"ORD202609182027433760839",
 "customerName":"熊大","customerPhone":"138****8000","totalAmount":299.50,
 "status":"CREATED","statusLabel":"已创建","remark":"演示下单",
 "items":[{"productName":"机械键盘","price":299.50,"quantity":1,"amount":299.50},
          {"productName":"腕托","price":25.00,"quantity":2,"amount":50.00}],
 "version":0,
 "createdAt":"2026-09-18T20:27:43.379",
 "updatedAt":"2026-09-18T20:27:43.379"}

Location 里那串就是 ObjectId。响应体里的 amount 是算出来的,库里没有这个字段。

乐观锁,两次更新

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
# 第一次,带上当前版本号 0,200,版本号变成 1
curl -i -X PUT http://localhost:8080/api/orders/6aad2e3f603b044e25ffe597 \
  -H "Content-Type: application/json" \
  -d '{"customerName":"熊大","customerPhone":"13900139000","totalAmount":888.00,
       "remark":"改了金额","version":0}'

# 第二次,还拿版本号 0,这个已经过期了,409
curl -i -X PUT http://localhost:8080/api/orders/6aad2e3f603b044e25ffe597 \
  -H "Content-Type: application/json" \
  -d '{"customerName":"熊大","customerPhone":"13900139000","totalAmount":666.00,
       "remark":"并发写入","version":0}'
1
2
3
4
5
6
HTTP/1.1 409
Content-Type: application/problem+json

{"detail":"订单已被其他人修改,请刷新后重试(当前版本 1,提交版本 0)",
 "instance":"/api/orders/6aad2e3f603b044e25ffe597","status":409,
 "title":"业务规则不满足","type":"urn:problem-type:business-rule-violation"}

抛的是 OptimisticLockingFailureException,不是 JPA 那边那个。

状态流转

1
2
3
4
5
6
7
# 合法:CREATED -> PAID,200
curl -i -X PATCH http://localhost:8080/api/orders/6aad2e3f603b044e25ffe597/status \
  -H "Content-Type: application/json" -d '{"status":"PAID"}'

# 非法:PAID -> CREATED,422
curl -i -X PATCH http://localhost:8080/api/orders/6aad2e3f603b044e25ffe597/status \
  -H "Content-Type: application/json" -d '{"status":"CREATED"}'
1
2
3
4
5
6
HTTP/1.1 422
Content-Type: application/problem+json

{"detail":"订单不允许从 已支付 变更为 已创建",
 "instance":"/api/orders/6aad2e3f603b044e25ffe597/status","status":422,
 "title":"业务规则不满足","type":"urn:problem-type:business-rule-violation"}

状态机本身跟第三篇是同一套,CREATED 换到 PAID 或者 CANCELLED,走完了就是终态。变的只是实现,第三篇靠事务,这里靠 findAndModify。

https://static.xiongneng.me/status-transfer-422-20260918202745.png

明细的原子增删

1
2
3
4
5
curl -i -X POST http://localhost:8080/api/orders/6aad2e3f603b044e25ffe597/items \
  -H "Content-Type: application/json" \
  -d '{"productName":"鼠标垫","price":18.00,"quantity":3}'

curl -i -X DELETE "http://localhost:8080/api/orders/6aad2e3f603b044e25ffe597/items?productName=鼠标垫"
1
2
3
4
HTTP/1.1 200
Content-Type: application/json

{"removed":1}

商品销量排行

1
curl -s "http://localhost:8080/api/orders/top-products?limit=3"
1
2
3
[{"productName":"腕托","totalQuantity":4,"salesAmount":100.00},
 {"productName":"机械键盘","totalQuantity":3,"salesAmount":1586.50},
 {"productName":"鼠标垫","totalQuantity":2,"salesAmount":36.00}]

这份数据里机械键盘的销售额比腕托高一个数量级,但销量少一个。5 条订单的明细摊平之后是 7 条中间记录,$group 收成 4 个商品,limit 只留前 3 个。

接口投影,以及它的反例

1
2
curl -s "http://localhost:8080/api/orders/summary?status=CANCELLED"
curl -s "http://localhost:8080/api/orders/summary/projection?status=CANCELLED"
1
2
3
4
5
6
接口投影:[{"customerName":"熊三","id":"6aad2e3f603b044e25ffe595",
           "orderNo":"ORD202609182027432781007","status":"CANCELLED"}]

手写 fields 反例:[{"id":"6aad2e3f603b044e25ffe595","orderNo":"ORD202609182027432781007",
           "customerName":"熊三","totalAmount":86.00,"status":"CANCELLED",
           "statusLabel":"已取消","items":[]}]

接口投影只查声明了的字段,换存储也能用。第三篇是同一套写法,那时「只查四列」,这里「只查四个字段」。

浏览器里打开接口投影那个地址,回来的就是这个,每个字段都在。

https://static.xiongneng.me/browser-interface-projection-20260918202746.png

反例这一版用手写 fields 投影只查了四个字段,却返回 OrderResponse。结果是 remark、version、createdAt、updatedAt 全是 null,items 是个空列表。

字段一个不少地出现了,里面没有内容。

留这个接口就是因为这件事很现实:一个看似正常、实为空壳的字段,比直接缺字段更容易骗过联调。 你前端拿到「客户名对、金额对、备注空」的数据,排查半天才发现是投影写窄了。

不合法的主键

1
curl -i http://localhost:8080/api/orders/not-an-objectid
1
2
3
4
5
6
HTTP/1.1 404
Content-Type: application/problem+json

{"detail":"订单不存在:not-an-objectid","instance":"/api/orders/not-an-objectid",
 "status":404,"title":"资源不存在","type":"urn:problem-type:resource-not-found",
 "resourceType":"订单","resourceId":"not-an-objectid"}

服务层先用 ObjectId.isValid 挡了一道。

不挡的话,驱动收到转不了的字符串会抛 IllegalArgumentException,结果是 500。一个拼错的 URL 换来一条服务器错误日志,查起来很浪费时间,这是主键换成 ObjectId 之后新增的一类问题,前两篇不会有。

https://static.xiongneng.me/invalid-objectid-404-20260918202748.png

唯一索引在拦

订单号是服务端生成的,接口层面造不出重复,所以这一步直接绕过应用写一次库。

1
2
3
被拦下:DuplicateKeyError: E11000 duplicate key error collection:
springboot4_mongo.orders index: uk_order_no dup key:
{ order_no: "ORD202609182027433760839" }

错误码 11000,对应 org.springframework.dao.DuplicateKeyException,全局异常处理器把它映射成 409。

同一个库,同一个 orders 集合,同一个插入动作。开关关掉的时候它成功了,打开的时候它被拦住。

查库对账

索引和原始文档都看过了,再对总数和字段类型。我本机是社区版服务端,bin 目录里只有 mongod 和 mongos,没有 mongosh,所以我用 pymongo 写了一段小脚本直接查,脚本留在工程里,下面贴的就是它的输出。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
_id                                ObjectId
order_no                           str
customer_name                      str
customer_phone                     str
total_amount                       Decimal128
status                             str
remark                             str
version                            Int64
created_at                         datetime
updated_at                         datetime
items                              array[1]
items[].product_name               str
items[].price                      Decimal128
items[].quantity                   int
_class                             str

全蛇形,全是对应类型,items 里没有 amount。我删掉一条之后总数是 4。

这一篇的测试怎么写

1
mvn test
1
2
Tests run: 63, Failures: 0, Errors: 0, Skipped: 0
BUILD SUCCESS
测试类技术覆盖内容
OrderRepositorySliceTest@DataMongoTest16 个用例,索引自动创建、唯一索引拦截、字段名映射、@Query 字段名翻译、Update 翻译、内嵌数组、$push/$pull、聚合两种写法对照、分页、两种投影
OrderApiIntegrationTest@SpringBootTest 加 MockMvcTester36 个用例,CRUD、分页与排序、乐观锁、状态机、原子增删、真删、参数校验、problem 文档结构
MongoPropertyRenameTestApplicationContextRunner6 个用例,旧属性静默失效、新属性正常绑定、uri 优先级
GlobalExceptionHandlerTest直连 handler4 个用例,DuplicateKeyException 到 409、OptimisticLockingFailureException 到 409
MongoIndexCreationOffTest@DataMongoTest 加关闭开关1 个用例,反面对照,索引一个都不建

测试写法上有四处跟第三篇不一样,都是被 MongoDB 本身逼出来的,我逐条说。

没法用 @Transactional 回滚。 第三篇的切片测试靠事务回滚隔离用例,这一篇单节点不支持多文档事务,事务管理器不介入,所有清理我都在 @BeforeEach 里手动清集合。

也不该换成内存库。 这一篇要验证的索引创建、唯一约束、驱动错误码、Decimal128 精度全是服务端行为,换成内存实现就是在测一个仿制品,嵌入式 MongoDB 的版本往往还比服务端旧好几个大版本。

连不上就整体跳过。 沿用前几篇的处理,用例执行前先探一次 ping,连不上直接 skip。你机器上没配 MongoDB 的话,跑起来会看到 Skipped,不会看到一片红。

@DataMongoTest 不扫自定义 @Repository。 这上面我栽过一次。

OrderMongoDao 是自定义的类,标了 @Repository。我按直觉在切片测试里注入它,直接报 NoSuchBeanDefinitionException。

@DataMongoTest 收的是「Spring Data 仓储」,即 OrderRepository 这种由框架生成代理的接口,自己写的数据访问类一个都不扫,要显式加一行。

1
2
3
@DataMongoTest
@Import(OrderMongoDao.class)
class OrderRepositorySliceTest {

避坑指南

按我踩到的顺序整理成十三条。

坑一,连接属性写成旧前缀是静默失效,不是「还能用但会警告」。 验证不能看配置写得对不对,得看框架最终拿到什么:写成 spring.data.mongodb.uri 会安安静静连到 mongodb://localhost/test,表现为「连不上库」或「连上了但数据不见了」,而 yml 看上去完全正确。第一次碰到这种「连不上库」,九成是这个原因。

坑二,MongoProperties.determineUri() 不能用来判断配置生效了没有。 它只有三行,只认 uri 这一个字段,一律返回 mongodb://localhost/test;权威的是 MongoConnectionDetails#getConnectionString()。

坑三,spring.data.mongodb.auto-index-creation 不归连接层管。 批量把 spring.data.mongodb. 替换成 spring.mongodb. 时它会跟着被改错、静默失效;同样留在老前缀下的还有 field-naming-strategy、repositories.type、representation.big-decimal、gridfs.*。

坑四,@Indexed 默认什么索引都不建。 auto-index-creation 默认值是 false,忘了打开唯一约束就不存在,重复数据能进去,全程没有提示。上线前拿 db.orders.getIndexes() 核对一遍最省事。

坑五,聚合输出的别名,两条实现路径的规则不一样。 @Aggregation 翻译别名,fluent API 写什么发什么,写错的表现是金额那一列静默消失,只能看报文发现。

坑六,取证方案本身要设计对,不然什么都证明不了。 对照那侧改成「正确的值」,两条路径就一模一样;想证明某一层做了转换,得让输入相同、机制不同,反例不能省。

坑七,@Transient 要用 Spring Data 那个包。 jakarta.persistence.Transient 不生效,计算字段会变成落库的真实字段且永不同步,查一次原始文档就能发现。

坑八,复合索引的 def 里必须写存储名。 写 createdAt 不报错但索引永远用不上,只能在 explain() 里发现它没进 winningPlan。

坑九,单节点没有跨文档事务,别写 @Transactional 图安心。 它拦不住任何东西,只会给人「有事务保护」的错觉;并发安全全部放在单文档原子操作上,收不进一个文档就得考虑副本集或重新建模。

坑十,异常类型和 JPA 侧对不上,而且方向是反的。 乐观锁是子类到父类,唯一键是父类到子类;照抄第三篇的处理器,getPersistentClassName() 会直接编译失败。

坑十一,主键换成 ObjectId 之后多出来一类问题。 不合法的 id 会让驱动抛 IllegalArgumentException 变 500,服务层先用 ObjectId.isValid 挡成 404;ObjectId 也不保证递增。

坑十二,金额字段的默认表示是 Decimal128。 同一字段混进字符串类型数据时比较和聚合会出偏差,库里已有别的类型表示的金额要先确认;这个错误不报,只会让结果差一点点。

坑十三,中间层的 DEBUG 日志不是「下发了什么」。 MongoTemplate 打的是翻译前的 Query,findById 显示成 id,照着查库一条都对不上;拿日志当证据只认驱动层 org.mongodb.driver.protocol.command 的 Command: {...}。

小结

这一篇的核心是一句话:MongoDB 这条链上「不报错的隐式行为」比关系型多得多。说实话,写完这一篇我回头看关系型那两篇的报错,都觉得亲切了,人家至少肯给你一条异常。

三处静默组成了这一篇的主要篇幅。配置静默失效,写成 spring.data.mongodb.uri 它不读,也不提示,直接连默认库。索引静默不建,@Indexed 躺在那儿,库里什么都没有,重复数据照收。别名静默被改,注解里写 totalAmount 下发成 total_amount,换一种写法又不变了。关系型那两篇的坑多数会给一个异常或一条错日志,能顺着往上查;这一篇的三个连痕迹都没有,只有对比才能发现,所以我留了两个反例接口和一个专门验属性前缀的测试类。

还有一处静默是给排查本身设的:中间层日志打的是转换前的 Query,findById 显示成 id,看着完全正常,照着它去查库一条都对不上。取证那一步也得留个心眼,只认驱动层报文。

Spring Boot 4 下这个主题的现状,整理成一张表,你选型时对着查。

主题现状
自动配置拆在 spring-boot-mongodb 和 spring-boot-data-mongodb 两个模块里
连接属性spring.mongodb.* 前缀;写成 spring.data.mongodb.* 静默失效
数据层属性auto-index-creation 等仍在 spring.data.mongodb 前缀下
健康与指标management.health.mongodb、management.metrics.mongodb.*
测试注解org.springframework.boot.data.mongodb.test.autoconfigure
测试 starterspring-boot-starter-data-mongodb-test(4.1.1 提供)
BigDecimal / BigInteger默认 DECIMAL128,映射配置没有默认值
UUID 表示没有默认值
@Meta.allowDiskUseString
JMX不支持
DefaultMessageListenerContainer默认自动启动

选型建议我是这么给的:如果你的模型能收进单个文档、并发冲突靠单文档原子操作就兜得住,MongoDB 这条链在 Boot 4 上是成熟的,starter、测试注解、异常体系都齐了,放心用,工程放在 springboot4-mongodb,从建库到查库对账的完整命令都在 README 里。要是你的写操作经常跨两个以上的「集合级实体」,或者强依赖跨文档事务,别硬上,要么上副本集加 MongoTransactionManager,要么回关系型。带金额字段的项目,跑起来之后去库里确认一眼 Decimal128 的映射,那是 5.x 唯一一个不显式配置会告警的地方。

真正没解决的事有两件。一是我的树莓派跑不了新版 MongoDB:那台是 4B,Cortex-A72,ARMv8.0-A,没有 LSE 原子指令,而 MongoDB 从 5.0 开始要求 ARMv8.2-A,容器起不来,退出码 132;降到 4.4.18 能起来,但 4.4 已经 EOL,我不愿意为一台机器锚一个停止维护的版本,所以这一篇的 MongoDB 在我本机 Windows 上,127.0.0.1:27017,MySQL、Redis、RabbitMQ 还是在那台 Pi 上,拓扑跟前几篇不一样。二是单节点之外的副本集事务我没有实测,MongoTransactionManager 那条路留到我有多机环境再补。

参考链接