Spring Boot 4 全家桶教程 · 第 4 篇配套工程:换成 MongoDB,把订单接口再写一遍。
接口路径、状态码、请求与响应结构跟前三篇保持一致,换掉的只有持久层。 前三篇的订单主键是自增数字,这一篇是 ObjectId 字符串——这是接口层面唯一看得见的差别。 真正不一样的东西都在底下:没有表、没有 join、没有跨文档事务,一整套建模思路都要换。
| 主题 | 具体做法 |
|---|---|
| 文档映射 | @Document(collection = "orders") + @Field 声明存储名,字段用下划线风格和前三篇对齐 |
| 内嵌数组 | 明细不建独立集合,直接作为 List<OrderItem> 嵌在订单文档里 |
| 主键 | 声明成 String,由驱动生成 24 位 ObjectId,不写转换器 |
| 索引 | @Indexed / @CompoundIndex 声明索引,并且知道默认不会自动创建 |
| 唯一约束 | @Indexed(unique = true) + 驱动错误码 11000,落到 DuplicateKeyException |
| 审计字段 | @EnableMongoAuditing + @CreatedDate / @LastModifiedDate,时间戳由框架回填 |
| 计算字段 | Spring Data 的 @Transient,让 amount 只出现在响应里、不落库 |
| 派生查询 | findByOrderNo / countByStatus,方法名即查询 |
@Query |
手写 JSON 条件,含点号路径钻进内嵌数组按商品反查 |
@Aggregation |
管道写死在注解里,$group 做状态统计 |
| fluent 聚合 | MongoTemplate 手搭管道,含 $unwind 摊平明细后按商品汇总 |
| 模板层更新 | findAndModify / $push / $pull,把并发安全压在单文档原子操作上 |
| 乐观锁 | @Version + save(),以及它和 JPA 侧不是同一个异常这件事 |
| 分页 | Pageable + 统一的分页响应体,不把 Page 直接当契约 |
| 投影 | 接口投影(可移植)与手写 fields(不可移植的反例)并排对照 |
| 测试 | @DataMongoTest 切片测试 + @SpringBootTest 集成测试,都真连 MongoDB |
| 组件 | 版本 |
|---|---|
| JDK | 21 |
| Maven | 3.9+ |
| Spring Boot | 4.1.1(Spring Framework 7.0.9) |
| Spring Data MongoDB | 5.1.1 |
| MongoDB Java Driver | 5.8.1(同步版 mongodb-driver-sync) |
| MongoDB Server | 8.0+(本工程在 8.0.32 上验证) |
驱动版本值得留意:Boot 4.1.1 的管理依赖锁的是 5.8.1,
而 Spring Data MongoDB 5.x 要求驱动 5.6 以上,两者是一致的。
版本号别抄二手资料,mvn dependency:tree 跑一遍最快。
需要一台可连的 MongoDB。本工程用单节点,不涉及副本集,因此没有多文档事务 —— 这一点会直接影响 Service 层的写法,见第八节。
MongoDB 是 schema-less 的,没有建表脚本。库和集合都会在第一次写入时自动出现。
# 建库和账号(如果还没有的话),在 mongosh 里执行
use admin
db.createUser({ user: "root", pwd: "root123456", roles: ["root"] })连接信息通过环境变量覆盖,不写死在代码里:
| 变量 | 默认值 |
|---|---|
MONGO_HOST |
127.0.0.1 |
MONGO_PORT |
27017 |
MONGO_DB |
springboot4_mongo |
MONGO_USER |
root |
MONGO_PASSWORD |
root123456 |
库名带 springboot4_ 前缀,不会和别的工程撞。测试用的是另外一个库
springboot4_mongo_test,索引反例测试用 springboot4_mongo_indexdemo,
跑测试不会把演示数据洗掉。
spring:
data:
mongodb:
auto-index-creation: true这个开关的默认值是 false。 也就是说,不写这一行,
实体上那一堆 @Indexed(unique = true)、@CompoundIndex 全部只是注释:
- 应用正常启动
- 文档正常写入
- 派生查询正常返回
- 唯一约束不存在,同一个订单号能插进去两条
- 全程没有任何报错、任何警告
本工程打开它,然后在第六节用一次真实的重复插入把这个差别打出来。
cd springboot4-mongodb
# 方式一:Maven 直接运行
mvn spring-boot:run
# 方式二:打包后运行
mvn clean package
java -jar target/springboot4-mongodb-1.0.0.jar启动日志里看到这一行即成功:
Tomcat started on port 8080 (http) with context path '/'
Started MongoApplication in 2.86 seconds
| 方法 | 路径 | 说明 | 成功状态码 |
|---|---|---|---|
| POST | /api/orders |
创建订单,订单号由服务端生成 | 201 + Location |
| GET | /api/orders/{id} |
按主键查询,带明细 | 200 |
| GET | /api/orders |
分页 + 条件查询 | 200 |
| GET | /api/orders/stats |
按状态聚合统计(@Aggregation) |
200 |
| GET | /api/orders/stats/by-template |
同一个统计,MongoTemplate 手搭管道 |
200 |
| GET | /api/orders/stats/naive |
反例:别名写错,金额整列丢失 | 200 |
| GET | /api/orders/top-products |
商品销量排行($unwind + 二次分组) |
200 |
| GET | /api/orders/summary |
订单摘要(接口投影,只查四个字段) | 200 |
| GET | /api/orders/summary/projection |
反例:手写 fields 投影,未查字段全为 null |
200 |
| GET | /api/orders/count |
某状态下的文档数 | 200 |
| GET | /api/orders/statuses |
出现过的所有状态(服务端 distinct) | 200 |
| GET | /api/orders/expensive |
金额不低于指定值的订单 | 200 |
| GET | /api/orders/by-item |
按内嵌明细里的商品名反查订单 | 200 |
| PUT | /api/orders/{id} |
整体替换业务字段 | 200 |
| PATCH | /api/orders/{id}/status |
状态流转 | 200 |
| POST | /api/orders/{id}/items |
追加一条明细($push) |
200 |
| DELETE | /api/orders/{id}/items |
移除指定商品的明细($pull) |
200 |
| DELETE | /api/orders/{id} |
真删,不是软删除 | 204 |
| 参数 | 默认值 | 约束 |
|---|---|---|
keyword |
无 | 模糊匹配订单号或客户姓名 |
status |
无 | CREATED / PAID / SHIPPED / COMPLETED / CANCELLED |
page |
0 |
≥ 0 |
size |
10 |
1 ~ 100 |
sort |
createdAt,desc |
字段取自白名单,方向为 asc/desc |
sort 传的是实体属性名,不是文档里的存储名。 白名单在 OrderPageQuery 里,
只认 orderNo、customerName、totalAmount、status、createdAt、updatedAt。
有意思的是,传 created_at 也能跑出正确结果——因为 MongoDB 允许按任意文档路径排序,
白名单恰好放行了这个字符串,Sort 下发过去正好对上了存储名。
但接口只承诺一种口径,别拿这个巧合当特性。
CREATED ──> PAID ──> SHIPPED ──> COMPLETED
│ │
└──────────┴──────> CANCELLED
和前三篇是同一个枚举、同一套规则。区别只在实现:
JPA 那边靠事务 + 脏检查,这里靠 findAndModify 把「读到的状态」变成「更新的条件」,
在服务端一次完成判断和写入。见第六节「状态流转」。
以下响应都是在本机真实跑出来的,可以直接对照。应用用的是默认端口 8080。
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 }
]
}'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 里那串 6aad2e3f603b044e25ffe597 是 24 位十六进制的 ObjectId,
不是自增数字。接口本身没变,但所有假设「主键是递增整数」的客户端代码都得改
——比如「按 id 倒序就是最新的」这条,在 ObjectId 上不成立。
amount 这个字段是算出来的,price × quantity。它不在数据库里,
只存在于响应里。靠的是 OrderItem.amount 上的 Spring Data @Transient。
忘了写这个注解,它会变成一个落库的真实字段,而且永远是第一次写入时的值,
再也不会更新——一个不报错的幽灵字段。
# 带上正确版本号更新,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}'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"}
这里抛的异常是 org.springframework.dao.OptimisticLockingFailureException,
不是 JPA 那边的 ObjectOptimisticLockingFailureException。
两者是父子关系(后者继承前者),差别很实在:
父类没有 getPersistentClassName() 方法。把第 3 篇的异常处理器原样复制过来,
编译就过不去。这个错误比读文档快多了。
# 合法: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"}'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"}
这一条的实现值得单独说。JPA 那边是「在事务里读出实体、判断、改、提交」, 靠事务隔离把并发挡住。MongoDB 单机没有事务可用,所以换成:
// 把"读到的状态"变成"更新的条件",一次 findAndModify 完成判断与写入
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。判断和写入之间没有任何窗口,
因为它们在服务端是同一条命令。
# 追加一条明细,服务端 $push
curl -i -X POST http://localhost:8080/api/orders/6aad2e3f603b044e25ffe597/items \
-H "Content-Type: application/json" \
-d '{"productName":"鼠标垫","price":18.00,"quantity":3}'
# 移除指定商品的明细,服务端 $pull
curl -i -X DELETE "http://localhost:8080/api/orders/6aad2e3f603b044e25ffe597/items?productName=鼠标垫"HTTP/1.1 200
Content-Type: application/json
{"removed":1}
这两条如果按前三篇的思路写,会是「读出订单、改 items 列表、整体 save 回去」。
文档型存储有更好的办法:$push / $pull 直接在服务端改数组元素,
不需要把整份文档传回来再写回去。
差别不只是"少传点数据"。整份 save 回去意味着用旧快照覆盖新状态——
两个人同时加明细,后提交的那个会把先提交的那条吃掉。$push 没有这个问题。
$pull 的语义要留意:它按条件移除所有匹配的元素。同名商品有两条时会被一起删掉,
返回值 removed: 1 指的是「影响了一条文档」,不是「删了一个元素」。
curl -s http://localhost:8080/api/orders/stats # @Aggregation 注解版
curl -s http://localhost:8080/api/orders/stats/by-template # MongoTemplate 代码版
curl -s http://localhost:8080/api/orders/stats/naive # 反例注解版 :[{"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":"已支付"}]
前两个必须逐字一致,这是切片测试里钉住的一条断言。第三个是故意的反例,
totalAmount 整列消失了,而且没有任何报错。
这是本篇最隐蔽的一个坑,值得花点篇幅讲清楚。
返回类型 OrderStatusStat 里,金额字段上标的是 @Field("total_amount"):
public class OrderStatusStat {
@Field("_id")
private OrderStatus status;
private long count;
@Field("total_amount")
private BigDecimal totalAmount;
}两条实现路径对「输出别名」的处理规则不一样。打开驱动命令日志就能看到:
注解版(注解里写的是 totalAmount)
{"$group": {"_id": "$status", "count": {"$sum": 1},
"total_amount": {"$sum": "$total_amount"}}}
代码版(.as("total_amount"))
{"$group": {"_id": "$status", "count": {"$sum": 1},
"total_amount": {"$sum": "$total_amount"}}}
反例代码版(.as("totalAmount"))
{"$group": {"_id": "$status", "count": {"$sum": 1},
"totalAmount": {"$sum": "$total_amount"}}}
第一条和第三条的源码里写的都是 totalAmount,下发出去却不一样:
注解版被翻译成了 total_amount,代码版原样是 totalAmount。
@Aggregation声明的管道,会走一遍实体映射,连输出别名也当属性名翻译;- fluent API(
Aggregation.group().sum().as())不翻译,写什么发什么。
所以结论有点反直觉:注解版里可以写属性名,代码版里必须写存储名。 两套规则不一样,写错的表现是「字段静默消失」。
想自己复现这组报文,把 org.mongodb.driver.protocol.command 的日志级别调成 DEBUG
(application.yml 里已经写好注释,取消注释即可)。别忘了关掉,输出量很大。
注意别用中间层日志取证。 org.springframework.data.mongodb.core.MongoTemplate
那层打的是翻译前的 Query:findById 在日志里是 { "id" : "..." },
而实际发出去的是 filter: {"_id": {"$oid": "..."}}。照着中间层日志去库里查,
一条都对不上。只有驱动层的 Command: {...} 是权威报文。
curl -s "http://localhost:8080/api/orders/top-products?limit=3"[{"productName":"腕托","totalQuantity":4,"salesAmount":100.00},
{"productName":"机械键盘","totalQuantity":3,"salesAmount":1586.50},
{"productName":"鼠标垫","totalQuantity":2,"salesAmount":36.00}]
这是文档型存储最不直观的一步。明细是订单里的一个数组字段,
想按「商品」这个粒度统计,得先用 $unwind 把数组拆开——
一条含 2 个商品的订单会变成 2 条记录,每条带一个商品——然后再分组。
等价于关系型的 join t_order_item,但不需要提前把明细建模成独立实体。
代价是 $unwind 之后中间结果会变大,订单里明细越多越明显,
所以这一步之后通常紧跟着 $group 把行数收回来。
curl -s "http://localhost:8080/api/orders/summary?status=CANCELLED"
curl -s "http://localhost:8080/api/orders/summary/projection?status=CANCELLED"接口投影:[{"customerName":"熊三","id":"6aad2e3f603b044e25ffe595",
"orderNo":"ORD202609182027432781007","status":"CANCELLED"}]
手写 fields 反例:[{"id":"6aad2e3f603b044e25ffe595","orderNo":"ORD202609182027432781007",
"customerName":"熊三","totalAmount":86.00,"status":"CANCELLED",
"statusLabel":"已取消","items":[]}]
接口投影(OrderSummary 接口 + getter)只查声明了的字段,而且换存储也能用——
JPA 那边是同一套写法(第 3 篇演示过)。
反例这一版用手写 fields 投影,只查了四列,却返回 OrderResponse。
结果是 remark、version、createdAt、updatedAt 全是 null,
items 是一个空列表。字段一个不少地出现了,但里面没有内容。
一个看起来正常、其实是空壳的字段,比直接缺字段更容易骗过联调。 前一版的分页接口如果误用了这个查询,前端会拿到一堆「客户名对、金额对、备注空」的数据, 排查半天才发现是投影写窄了。
curl -i -X POST http://localhost:8080/api/orders \
-H "Content-Type: application/json" \
-d '{"customerName":"","customerPhone":"123","totalAmount":-1}'HTTP/1.1 400
Content-Type: application/problem+json
{"detail":"请求体字段校验未通过,逐字段原因见 errors",
"instance":"/api/orders","status":400,"title":"请求体校验失败",
"errors":[{"message":"订单金额必须大于 0","field":"totalAmount"},
{"message":"客户姓名不能为空","field":"customerName"},
{"message":"客户手机号格式不正确","field":"customerPhone"}]}
嵌套校验也是有效的,明细列表上标了 @Valid 之后,错误路径形如 items[0].price。
上面那段响应体,errors 里每个条目的键顺序、以及数组本身的顺序,在不同 JVM 启动之间不保证一致:
条目是 Map.of 构造的,而校验框架返回的违规集合本身无序。
写断言时按 field 找,不要按下标取,否则会得到一条只在某些启动里红的测试。
顺带一个和前三篇一样的规矩:Controller 类上不要写 @Validated。
写了之后方法参数校验改走 AOP 代理,抛的是 ConstraintViolationException,
全局异常处理器接不到,400 会变成 500。
curl -i http://localhost:8080/api/orders/not-an-objectidHTTP/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 换来一条服务器错误日志,
查起来很浪费时间。
curl -i -X DELETE http://localhost:8080/api/orders/6aad2e3f603b044e25ffe596HTTP/1.1 204
第 3 篇用 @SoftDelete 实现软删除,deleteById 生成的是一条 UPDATE ... SET deleted=true。
MongoDB 没有对应的注解,deleteById 就是一条 delete 命令,文档真的消失了。
删完再查是 404,不是「被标记位过滤掉」。
要做软删除只能自己加一个字段再在查询里带上它。
代价是所有查询都得记得加这个条件,漏一个就是一个 bug——这也是 JPA 那边
用 @SoftDelete 更省事的原因。
应用层造不出重复订单号(订单号是服务端生成的),所以这一步直接绕过应用写一次库:
被拦下:DuplicateKeyError: E11000 duplicate key error collection:
springboot4_mongo.orders index: uk_order_no dup key:
{ order_no: "ORD202609182027433760839" }
错误码 11000,对应 Spring 的 org.springframework.dao.DuplicateKeyException,
本工程的全局异常处理器把它映射成 409。
JPA 那边同样的场景抛的是 DataIntegrityViolationException,
而 DuplicateKeyException 是它的子类。父子方向和第 3 篇相反,
处理器里两个都接上最省心。
# 索引
db.orders.getIndexes()
_id_ {_id: 1}
idx_status_created_at {status: 1, created_at: -1}
uk_order_no {order_no: 1} [unique]
idx_status {status: 1}文档的原始形态(字段顺序照 BSON 原样):
{"_id": {"$oid": "6aad2e3f603b044e25ffe593"},
"order_no": "ORD202609182027431545824",
"customer_name": "熊大",
"customer_phone": "13800138000",
"total_amount": {"$numberDecimal": "299.50"},
"status": "CREATED",
"remark": "单件键盘",
"version": 0,
"created_at": {"$date": "2026-09-18T12:27:43.202Z"},
"updated_at": {"$date": "2026-09-18T12:27:43.202Z"},
"items": [{"product_name": "机械键盘",
"price": {"$numberDecimal": "299.50"},
"quantity": 1}],
"_class": "com.xncoding.mongo.domain.Order"}三处值得看的地方:
字段名全是蛇形,接口返回的却是驼峰。 order_no / customer_name / total_amount /
created_at 这些存储名是 @Field 指定的,@Field 只影响存储,
序列化给客户端时用的还是 Java 属性名。一层映射,两边都干净。
没有 amount 字段。 这正是那个 @Transient 的效果。上面接口返回里有 amount,
库里没有——计算字段就该是这样。
金额是 Decimal128 而不是 Double。 BigDecimal 在 5.x 里默认映射到
Decimal128(旧版默认是 STRING)。这是 5.x 的破坏性变更之一,
换成 Double 会出现 299.50 变成 299.5 这种情况,看起来一样但比较起来不等。
还有 _class 那一项,是 Spring Data 用来支持多态映射的,属正常现象。
mvn test预期结果:
Tests run: 63, Failures: 0, Errors: 0, Skipped: 0
BUILD SUCCESS
| 测试类 | 技术 | 覆盖内容 |
|---|---|---|
OrderRepositorySliceTest |
@DataMongoTest + 真实 MongoDB |
16 个用例:索引自动创建、唯一索引拦截、字段名映射、@Query 字段名翻译、Update 翻译、内嵌数组、$push/$pull、聚合两种写法对照、分页、两种投影、distinct |
OrderApiIntegrationTest |
@SpringBootTest + MockMvcTester + 真实 MongoDB |
36 个用例:CRUD、分页与排序、乐观锁、状态机、原子增删、真删、参数校验、problem 文档结构 |
MongoPropertyRenameTest |
ApplicationContextRunner |
6 个用例:旧属性静默失效、新属性正常绑定、uri 优先级 |
GlobalExceptionHandlerTest |
直连 handler | 4 个用例:DuplicateKeyException → 409、OptimisticLockingFailureException → 409 |
MongoIndexCreationOffTest |
@DataMongoTest + 关掉索引开关 |
1 个用例:反面对照,索引一个都不建、唯一约束不存在 |
关于测试有四点说明:
- 测试直连真实 MongoDB,不换成嵌入式版本。 本篇要验证的索引创建、唯一约束、
驱动错误码、
Decimal128精度,全是服务端行为,换成内存实现就成了「测了个仿制品」。 - 库连不上时整体跳过,而不是报错。 用例执行前先探测一次
ping, 连不上就直接 skip。在一台没配 MongoDB 的机器上跑mvn test会看到Skipped,不会看到一片红。 - 没有
@Transactional,也没有@Rollback。 单节点 MongoDB 不支持多文档事务, Spring 的事务管理器根本不会介入。所有测试的清理都是在@BeforeEach里手动清集合。 这是本篇和第 3 篇在测试写法上最大的差别。 @DataMongoTest不扫自定义@Repository。 它收的是「Spring Data 仓储」, 即由框架生成代理的接口。自己写的OrderMongoDao必须显式@Import进来, 否则注入直接报NoSuchBeanDefinitionException。
src/main/java/com/xncoding/mongo/
├── MongoApplication.java 启动类,@EnableMongoAuditing + 自定义 DateTimeProvider
├── controller/
│ └── OrderController.java 资源入口,含 sort 表达式解析与两个反例接口
├── service/
│ └── OrderService.java 业务规则 + 单文档原子操作,没有任何 @Transactional
├── repository/
│ ├── OrderRepository.java 派生查询 / @Query / @Aggregation
│ ├── OrderMongoDao.java MongoTemplate:findAndModify / $push / $pull / 聚合
│ ├── OrderSummaryRepository.java 只读仓储,只暴露一个方法
│ └── OrderSummary.java 接口投影
├── domain/
│ ├── Order.java 聚合根,@Document + 索引 + 审计字段 + @Version
│ ├── OrderItem.java 内嵌明细,含 @Transient 计算字段
│ ├── OrderStatus.java 状态枚举 + 状态机
│ └── OrderPageQuery.java 查询条件,含排序白名单
├── dto/ 请求体与响应体,与前三篇保持一致
└── exception/ 404 / 409 / 422 / 400 统一输出
src/main/resources/
└── application.yml 连接、审计、索引开关、problem details
OrderService 里一个 @Transactional 都没有。 这不是漏了,是本篇的核心结论之一:
单节点 MongoDB 不支持多文档事务,写了也不会生效,反而会给人一种「有事务保护」的错觉。
那并发安全靠什么?全部压在单文档原子操作上:
| 场景 | 做法 | 保证 |
|---|---|---|
| 整体更新 | save() + @Version |
乐观锁,版本冲突抛 409 |
| 状态流转 | findAndModify + 当前状态作条件 |
判断与写入在服务端原子完成 |
| 追加明细 | $push |
服务端改数组,不覆盖整份文档 |
| 移除明细 | $pull |
同上 |
这套做法的前提是业务操作都能收进一个文档里。这也是文档型建模的取舍所在:
把明细嵌进订单,换来了单文档原子性,代价是「按商品维度统计」要多写一个 $unwind、
而且单个文档不能无限大(BSON 上限 16MB)。
真要跨文档事务,得上副本集并且显式配置 MongoTransactionManager。
本工程单节点,不涉及。
-
连接属性整体改名,而且旧名是静默失效。
Boot 3 Boot 4 spring.data.mongodb.urispring.mongodb.urispring.data.mongodb.host/port/databasespring.mongodb.*spring.data.mongodb.username/passwordspring.mongodb.*spring.data.mongodb.authentication-databasespring.mongodb.authentication-databasemanagement.health.mongo.enabledmanagement.health.mongodb.enabledmanagement.metrics.mongo.*management.metrics.mongodb.*大约 13 项连接层属性全部改名,元数据里标了
level = error、since = 4.0.0。但「静默失效」这四个字比「改名」重要得多。 旧名不是「还能用但会告警」, 而是彻底不绑定:写了等于没写,客户端会悄悄退回默认的
mongodb://localhost/test, 日志里一句提示都没有。表现出来是「连不上库」或者「连上了但数据不见了」, 而配置项看上去明明是对的。本工程的
MongoPropertyRenameTest专门把这件事钉住了:只配旧属性, 断言最终拿到的连接串仍然是默认值。取证方法有个坑,记一下。 别用
MongoProperties.determineUri()去判断 「配置生效了没有」。这个方法只有三行,只认uri一个字段:return uri != null ? uri : "mongodb://localhost/test";分字段配置(host/port/username)时它一概不看,永远返回默认值。 权威来源是MongoConnectionDetails#getConnectionString(), 那才是MongoClient真正拿到的连接串。 -
spring.data.mongodb.auto-index-creation等一批属性没有跟着改名。同一个 yml 里会出现这种写法:
spring: mongodb: uri: mongodb://... # 连接层,新名字 data: mongodb: auto-index-creation: true # 数据层,还是老名字
原因是 Boot 4 把 MongoDB 自动配置拆成了两个模块:
spring-boot-mongodb管连接(对应MongoProperties,即spring.mongodb.*),spring-boot-data-mongodb管 Spring Data 层(对应DataMongoProperties, 即spring.data.mongodb.*)。两套属性各自归类,改名只发生在连接层。所以迁移时不能一把全局替换
spring.data.mongodb.→spring.mongodb.。 除了auto-index-creation,还留在老名字下的有field-naming-strategy、repositories.type、representation.big-decimal、gridfs.*。改错了同样是静默失效。 -
@Indexed默认不会创建索引。auto-index-creation的默认值是false, 这一点十篇教程有九篇不提。忘了打开开关,代码里写着@Indexed(unique = true), 库里什么都没有,而且不报错。见第三节与第六节。 -
聚合输出的别名,两条实现路径规则不同。
@Aggregation会按实体映射翻译输出别名, fluent API 不会。详见第六节「聚合统计」。这一条是从 Spring Boot 3 迁过来也不会变的 Spring Data 行为,但很容易被忽略,因为错了不报错。 -
手写的
@Query、Criteria、Update都会翻译字段名。 这和我最初的假设相反——我以为「手写 JSON 就是原样下发」。 实测是:Criteria.where("items.productName")会被翻成items.product_name,Update.set("customerName", ...)会落到customer_name上,而且不会多出同义字段。@Query更是两种写法结果一致。切片测试里三个方向的用例都在。 -
测试注解又一次换了包名。
注解 Boot 3 Boot 4.1.1 @DataMongoTest...boot.test.autoconfigure.data.mongo...boot.data.mongodb.test.autoconfigure@AutoConfigureDataMongo同上 同上 规律和第 1、3 篇一致:包名 = 模块路径 +
.test.autoconfigure。 01 篇是webmvc.test.autoconfigure,03 篇是data.jpa.test.autoconfigure, 这一篇是data.mongodb.test.autoconfigure。 -
乐观锁与唯一键的异常类型和 JPA 侧不同,照抄编译不过。
场景 JPA(第 3 篇) MongoDB(本篇) 乐观锁冲突 ObjectOptimisticLockingFailureExceptionOptimisticLockingFailureException(父类)唯一键冲突 DataIntegrityViolationExceptionDuplicateKeyException(子类,错误码 11000)乐观锁这一组是子类 → 父类,唯一键这一组是父类 → 子类,方向相反。 把上一章的处理器复制过来,
getPersistentClassName()那一行会直接编译失败。 -
没有
@SoftDelete,删除是真删。 需要软删除只能自己加字段并保证每个查询都带上它。 -
BigDecimal的默认表示方式变了。 Spring Data MongoDB 5.x 里,BigDecimal/BigInteger的默认转换从STRING改成了DECIMAL128, 而且取消了默认值——不显式配置会有告警。UUID 的表示方式同样取消了默认值。 这两条都在官方 4.x → 5.x 迁移指南里。 -
@Transient要用对包。 必须用org.springframework.data.annotation.Transient, 不是jakarta.persistence.Transient。用错的话注解不生效, 计算字段会变成落库的真实字段。这个错误 IDE 不会提示,因为两个注解都合法。 -
@Meta.allowDiskUse的类型变了。 由boolean改成String(5.x 破坏性变更)。管大聚合的内存限制,同一条变更里还有 JMX 支持被移除。 -
Date精度。LocalDateTime.now()带纳秒,BSON 日期是毫秒整数。 不截断的话,响应里的时间戳和落库值会差一截——内存里的对象还留着纳秒。 本工程用自定义DateTimeProvider把时间截到毫秒。这一条第 2、3 篇也遇到过, 只是原因不同(那边是 MySQL 的DATETIME(3)丢掉纳秒,这边是驱动丢掉纳秒)。
MIT License,作者 Xiong Neng。