一、关系型 vs 文档型建模:别急着建表,先想想数据怎么读
做后端久了,大家都有路径依赖:一上来就画 er 图,拆表,定外键,恨不得把三范式背出来。关系型数据库这套设计在互联网场景下越来越别扭,尤其是分库分表之后,跨表 join 要么被禁用,要么性能惨不忍睹。mongodb 的思路完全反过来,它鼓励你把一个业务聚合成一个文档,数据物理上放在一起,读的时候一次拿全,不需要拼接。
拿电商订单举例。关系型要拆订单表、订单明细表、商品表、客户表,查询时 join 三张表,一旦数据量上来,sql 优化起来能掉不少头发。mongodb 直接一个订单文档塞下所有信息:商品快照、数量、价格、总额,都在一个嵌套文档里:
{
"_id": "order_1001",
"userid": "user_888",
"createdat": "2024-06-01t10:30:00z",
"items": [
{ "productid": "p_1", "name": "iphone 15", "price": 6999, "qty": 1 },
{ "productid": "p_2", "name": "airpods pro", "price": 1899, "qty": 2 }
],
"totalamount": 10797
}
这样建模不追求范式化,只关心业务上“读的时候需要什么”。订单被加载时,所有相关数据已经在文档里了,还天然支持分片。
关系型的强项是严格 schema、复杂事务、多表联合分析;mongodb 的强项是灵活、迭代快、高并发读写。没有谁全面碾压谁,关键看业务形态。如果实体间关系强、动不动需要复杂 sql,关系型依然靠谱;如果业务对象聚集性强、表结构总是变,mongodb 能省很多事。
二、内嵌还是引用?一句话:看你怎么读
内嵌和引用是 mongodb 建模里最常被问的问题。没有标准答案,但有个朴素的原则:如果读父文档时总是要同时读子数据,而且子数据量有限,也不独立变化,就内嵌;如果子数据会被单独查询、可能无限增长、或者被多个父文档共享,就引用。
内嵌的优势是局部性:一次 io,整个对象树都拿到。适合订单明细、用户地址、产品属性这种“一个主对象带几个子对象”的模型。但内嵌不能滥用,评论、日志、消息这种无限增长的数组,硬塞进去很快顶到 16mb 单文档上限,而且每次更新整个数组都要重写整个文档,代价极高。
引用就是存个 _id,运行时二次查询。适合文章和评论、用户和角色、商品和库存这种关系。特别是库存,它高频更新,独立事务,如果内嵌到商品文档里,会导致商品文档频繁重写,放大写入放大。
举个实际例子:社交平台帖子。帖子里内嵌作者信息 { userid: "u_1", nickname: "alice" },评论用引用 commentsref: ["c_1", "c_2"]。因为作者信息是快照,用户改昵称后,老帖子还显示旧昵称也没啥毛病,不必同步更新所有帖子。这就是有意识的冗余,比 join 省心得多。
三、spring data mongodb:repository 和 mongotemplate 怎么选
spring boot 集成 mongodb 非常简单,依赖一加,配置一写,两个核心工具就都在手边了。
依赖:
<dependency>
<groupid>org.springframework.boot</groupid>
<artifactid>spring-boot-starter-data-mongodb</artifactid>
</dependency>配置(顺便把连接池参数也写上,后面会讲):
spring:
data:
mongodb:
uri: mongodb://user:pass@host1:27017,host2:27018,host3:27019/dbname?replicaset=rs0
auto-index-creation: true实体定义,注意 @document 和 @id:
@document(collection = "orders")
public class order {
@id
private string orderid;
private string userid;
private list<orderitem> items;
private bigdecimal totalamount;
private instant createdat;
// getters/setters
}
public class orderitem {
private string productid;
private string productname;
private bigdecimal price;
private integer qty;
}
repository 接口,简单场景够用:
public interface orderrepository extends mongorepository<order, string> {
list<order> findbyuserid(string userid);
page<order> findbycreatedatbetween(instant start, instant end, pageable pageable);
}
复杂查询、聚合管道、动态条件、批量更新,直接上 mongotemplate:
@service
public class orderqueryservice {
@autowired
private mongotemplate mongotemplate;
public list<order> findrecentorders(int limit) {
query query = new query();
query.with(sort.by(sort.direction.desc, "createdat"));
query.limit(limit);
return mongotemplate.find(query, order.class);
}
}
我的习惯是:简单语义化查询走 repository,复杂聚合和动态条件走 mongotemplate,两者可以混用,没必要二选一。
四、聚合管道:复杂统计就用它,别把数据拉回应用层
聚合框架是 mongodb 特别能打的能力,一套管道式的数据处理模型,过滤、分组、投影、展开数组、排序、计算都能做。spring data mongodb 的 aggregation 对象让你用 java dsl 写管道,不用手拼 json。
常用的几个阶段:
$match:过滤$group:分组聚合$project:投影计算$unwind:展开数组$lookup:跨集合左连接
举个例子,订单文档里有 items 数组,每个 item 有 category 和 amount,要按分类统计销售额:
public map<string, bigdecimal> sumsalesbycategory() {
aggregation agg = aggregation.newaggregation(
aggregation.unwind("items"),
aggregation.group("items.category")
.sum("items.amount").as("totalamount"),
aggregation.sort(sort.by(sort.direction.desc, "totalamount"))
);
aggregationresults<categorysales> results =
mongotemplate.aggregate(agg, "orders", categorysales.class);
return results.getmappedresults().stream()
.collect(collectors.tomap(categorysales::getcategory,
categorysales::gettotalamount));
}
再比如按月份和地区统计 uv。这里有个小技巧:聚合框架不支持嵌套 group,可以先把年份、月份投影出来,再组合分组:
aggregation agg = aggregation.newaggregation(
aggregation.project("region", "userid")
.andexpression("year(createdat)").as("year")
.andexpression("month(createdat)").as("month"),
aggregation.group("region", "year", "month")
.addtoset("userid").as("uvset"),
aggregation.project("region", "year", "month")
.and("uvset").size().as("uv")
);
addtoset 天然去重,size 得到 uv 数。
想搞个 top n 排行,就是 match + unwind + group + sort + limit:
aggregation agg = aggregation.newaggregation(
aggregation.match(criteria.where("status").is("paid")),
aggregation.unwind("items"),
aggregation.group("items.productid")
.sum("items.qty").as("totalqty")
.avg("items.price").as("avgprice"),
aggregation.sort(sort.by(sort.direction.desc, "totalqty")),
aggregation.limit(5)
);
聚合引擎会尽量把管道下推,配合索引,百万级数据量毫秒级返回没啥问题。
五、多文档事务:能用,但别当万能药
mongodb 4.0 才支持副本集内多文档 acid 事务,4.2 才扩展到分片集群。spring data mongodb 2.x 开始支持 @transactional,用起来跟 jpa 差不多:
@transactional
public void placeorder(order order, string userid, product product, int qty) {
orderrepository.save(order);
productrepository.decreasestock(product.getproductid(), qty);
paymentrepository.insert(new payment(...));
}
但有前提:mongodb 必须是副本集或分片集群,单机 standalone 不行,存储引擎得是 wiredtiger(默认)。另外,事务操作在分片集群里如果涉及跨分片,约束会更多。
事务是有开销的。事务开始要协调时间戳,要处理锁冲突和快照,每个文档还要记录意向锁信息。所以别把事务当万能药,能不用就不用。比如扣库存 + 生成订单 + 扣款,这种强一致场景必须事务;但订单创建后异步发短信、更新统计报表,完全不需要事务,用 mq 异步慢慢处理就行,最终一致就够了。
还要学会规避事务:
- 重新设计文档边界。把库存、订单、支付想办法塞进一个文档,用
$inc原子更新。mongodb 单文档操作天然原子,不需要跨文档事务。 - 状态机模式。订单状态从“待付款”到“已付款”,用条件更新
findandmodify+$set保证只有特定状态才能流转。 - 补偿机制。如果后续步骤失败,用补偿任务把前面已成功的操作回退,保证最终一致。
六、索引:没有索引的 mongodb 就是全表扫描,迟早出事
查询性能全靠索引,这一点 mongodb 和关系型没区别。spring data 可以通过注解自动创建,但生产环境还是建议手动管理。
复合索引很关键。设计原则:先等值字段,再排序字段,再范围字段。比如订单查询经常按 userid 等值 + createdat 倒序:
@document(collection = "orders")
@compoundindex(def = "{'userid': 1, 'createdat': -1}")
public class order {
// ...
}
也可以动态建:
mongotemplate.indexops("orders")
.ensureindex(new index().on("userid", sort.direction.asc)
.on("createdat", sort.direction.desc)
.named("idx_user_created"));
注意字段顺序:把区分度高的、等值匹配的字段放前面,能更高效裁剪数据。这样的复合索引也能覆盖 userid 单字段查询。
聚合管道里,$match 和 $sort 尽量命中索引。比如 $match 过滤 status,就建 status 索引;按 createdat 排序统计,就建 createdat 索引。$group 阶段索引帮不上大忙,主要靠 $match 前置减少输入量。
ttl 索引做数据自动过期很好用,日志、会话、验证码这类场景:
@document(collection = "sessions")
public class usersession {
@id
private string id;
private string userid;
@indexed(expireafterseconds = 3600)
private instant lastaccess;
}
mongodb 后台定期扫描 ttl 字段,过期就删。注意字段必须是日期类型,索引单字段。
索引也别建太多,写入时要同步维护索引,开销不小。一个集合建议不超过 5 个索引。基很低的字段(比如布尔值)建了基本没用。线上有疑问就 explain() 看查询计划,别靠猜。
七、连接池与游标:高并发下别把连接和内存打爆
mongodb java 驱动默认就有连接池,spring boot 里直接配 uri 参数:
spring:
data:
mongodb:
uri: mongodb://localhost:27017/db?maxpoolsize=100&minpoolsize=10&maxidletimems=120000&waitqueuemultiple=5&waitqueuetimeoutms=10000或者编程式配置:
@bean
public mongoclientsettings mongoclientsettings() {
return mongoclientsettings.builder()
.applytoconnectionpoolsettings(builder ->
builder.maxsize(100)
.minsize(10)
.maxconnectionidletime(120, timeunit.seconds)
.maxconnectionlifetime(1, timeunit.hours))
.build();
}
maxpoolsize 不建议太大,默认 100 够用,太大的话文件描述符和内存都扛不住。maxidletimems 负责回收空闲连接,防止连接失效。waitqueuemultiple 控制等待队列,超过就快速失败,避免线程堆积。
查询结果集特别大,几十万上百万条,直接全捞到内存必然 oom。用游标流式处理,别攒着:
try (closeableiterator<order> iterator = mongotemplate.stream(query, order.class)) {
while (iterator.hasnext()) {
process(iterator.next());
}
}
repository 也能返回 stream,同样要记得关:
try (stream<order> stream = orderrepository.findbycreatedatafter(start)) {
stream.foreach(this::process);
}
游标使用期间会占用连接,所以处理动作要快,别在循环里做耗时的远程调用,长时间占用连接会把连接池耗尽。
用户可见的列表老老实实分页:
page<order> page = orderrepository.findbyuserid(userid, pagerequest.of(0, 20, sort.by("createdat").descending()));
千万别 skip(1000000).limit(20) 这么翻,翻得越深越慢。海量列表要用基于游标的分页,也就是按照上次查询的最后一条 _id 或排序值继续往后取:
query query = new query();
query.addcriteria(criteria.where("_id").gt(lastid));
query.limit(20);
query.with(sort.by(sort.direction.asc, "_id"));
这种方案的性能与翻页深度无关,适合大数据量场景。
总结
mongodb 不是来替代关系型的,它是用来解决关系型在特定场景下的痛点的。spring boot 生态里,spring data mongodb 从实体映射到聚合管道、事务控制、索引管理都提供了完善的支持。关键还是想清楚自己的业务:
- 建模时,围绕数据怎么读来决定内嵌还是引用,尽量减少 join 和关联查询;
- 查询统计用聚合管道,别把数据拉到应用层去算;
- 事务能不用就不用,优先单文档原子操作和最终一致;
- 性能优化从索引和连接池着手,海量数据用游标和基于游标的分页。
在业务快速迭代、数据量越来越大的今天,mongodb + spring boot 这套组合还是相当能打的。希望这些实践能帮你在实际项目中少踩些坑。
以上就是springboot集成mongodb的高扩展存储实践的详细内容,更多关于springboot集成mongodb存储实践的资料请关注代码网其它相关文章!
发表评论