MongoDB 数据模型设计最佳实践 嵌入式文档与引用对比 常见错误解析 从电商订单到社交关系的实战案例
你第一次摸MongoDB时,是不是也被这玩意儿搞懵了?
先说句掏心窝子的话——我刚接触MongoDB的时候,脑子里全是MySQL那一套思维。字段要拆表、外键要关联、查询靠JOIN,习惯了”规范化”这三个字。结果换了MongoDB,发现事情完全不一样,甚至有点”反人类”。
MongoDB 是个文档数据库,它的设计哲学是:你的数据怎么来,就怎么存。 这句话听着像废话,但背后的含义深远得吓人。它意味着你需要重新思考”数据模型”这个概念。
今天咱不整那些虚头巴脑的理论,直接上干货。我会用最直白的话,讲清楚嵌入式文档和引用模式到底怎么选,踩过什么坑,然后带你们撸两个真实项目——电商订单系统和社交关系网络。
准备好了吗?咱们开干。
嵌入式文档:把”一家人”放在一起
什么是嵌入式文档?
说白了,就是把有强归属关系的数据,直接塞进同一个文档里。
举个例子。你有个博客系统,每篇文章下面有若干条评论。如果你用传统的关系型数据库,得建两张表:articles 和 comments,然后通过 article_id 关联。但在 MongoDB 里,你可以这样做:
{
"_id": "article_001",
"title": "MongoDB数据模型设计完全指南",
"author": "老马",
"content": "这是一篇非常硬核的技术文章...",
"createdAt": "2024-01-15T10:30:00Z",
"comments": [
{
"user": "小张",
"text": "写得太好了!",
"createdAt": "2024-01-15T11:00:00Z"
},
{
"user": "小李",
"text": "第三部分讲得不够清楚",
"createdAt": "2024-01-15T12:30:00Z"
}
]
}
看到没?评论直接嵌在文章文档里面。查询一篇文章及其评论,只需要一次查询,没有任何跨文档操作。
什么时候该用嵌入式?
记住这个原则:如果一组数据总是被一起查询、一起更新,并且有明确的从属关系,那就嵌进去。
典型的场景有:
- 订单和订单项——买一件商品时,商品明细跟着订单走,几乎不会单独查询订单项
- 用户配置和偏好设置——配置项是用户的附属,跟着用户走
- 地址簿和收货地址——一个用户有几个地址,这些地址就是他的,不会跟别人共享
- 博客文章和评论——评论是文章的附属,一般不会脱离文章存在
- 任务列表和子任务——子任务完全依附于父任务
我用大白话再总结一遍:“谁的孩子谁抱走”,孩子永远不会独立于父母之外存在。
引用模式:让数据”各走各路”
什么是引用模式?
跟嵌入式相反,引用模式就是把相关数据放在不同的文档里,通过 ID 来关联。这跟我们熟悉的MySQL JOIN 操作是类似的思路。
还是刚才的例子,如果评论数据量很大,或者评论需要被多个文章共享(比如被顶到首页的热门评论),那就不适合嵌入。你可以这样做:
// 文章文档
{
"_id": "article_001",
"title": "MongoDB数据模型设计完全指南",
"author": "老马",
"commentIds": ["comment_001", "comment_002"]
}
// 评论文档(独立存储)
{
"_id": "comment_001",
"articleId": "article_001",
"user": "小张",
"text": "写得太好了!",
"createdAt": "2024-01-15T11:00:00Z"
}
评论变成了独立的文档,通过 _id 和 articleId 来关联。查询文章的时候,你可以只加载评论ID,需要的时候再按需加载评论内容。
什么时候该用引用?
记住这个原则:如果数据量可能很大、数据可能被多方共享、或者数据有独立的生命周期,那就用引用。
典型的场景:
- 用户和订单——一个用户可能有成千上万个订单,全嵌进用户文档会把文档撑爆
- 商品和评价——一个商品可能有几万条评价,而且评价数据量增长很快
- 订单和商品库存——商品库存数据被多个订单引用,修改库存需要全局一致
- 社交关系中的用户——用户A关注了用户B,用户B的关注列表里也有用户A的引用
- 大型日志数据——日志数据量极大,不可能全部嵌入到某个主文档中
用大白话说:“数据太多装不下” 或者 “数据不止属于一个主人”,那就用引用。
嵌入式 vs 引用:一张图说清楚
| 维度 | 嵌入式 | 引用 |
|---|---|---|
| 数据量 | 适合小数据量,单文档不超过16MB | 适合大数据量,无单文档限制 |
| 查询性能 | 单次查询搞定,性能极佳 | 需要聚合或应用层JOIN,性能稍差 |
| 写入性能 | 写入快,无跨文档操作 | 写入时可能涉及多文档操作 |
| 数据更新 | 更新本地,一致性好 | 需要处理跨文档一致性 |
| 数据膨胀风险 | 嵌套层级过多会导致文档膨胀 | 不存在文档膨胀问题 |
| 共享性 | 数据不共享,每个从属文档独立存在 | 数据可被多方引用,适合共享场景 |
| 事务支持 | 单文档原子操作天然支持 | 多文档事务需要显式开启 |
这个表看着清楚,但我得跟你们说个事:实际项目中,嵌入式文档和引用模式往往是混着用的。 没有哪一种模式是万能的,关键看你的业务场景。
常见错误:这些坑我替你们踩过了
错误一:把”能用引用”的东西全部嵌入
我刚入行那会儿,有个同事做用户系统,把用户的所有社交关系、所有偏好设置、所有历史操作全部嵌进用户文档。结果呢?
用户文档越来越大,单用户文档轻松超过10MB。查询性能越来越差,更新也越来越慢。最要命的是,MongoDB的单文档限制是16MB,万一哪天某个大V用户的数据超过了这个限制,直接报错,数据库里一堆数据写不进去。
正确的做法是:用户的基本信息嵌入,社交关系用引用存储。 用户文档大概长这样:
{
"_id": "user_12345",
"username": "tech_guru",
"email": "tech@example.com",
"profile": {
"displayName": "技术达人",
"bio": "分享技术干货",
"avatar": "https://cdn.example.com/avatar/12345.jpg"
},
"preferences": {
"theme": "dark",
"language": "zh-CN",
"notifications": true
},
"contactInfo": {
"phone": "+86-138-0013-8000",
"address": "北京市海淀区"
},
"friendIds": ["user_001", "user_002", "user_003"],
"createdAt": "2023-06-15T08:00:00Z",
"lastLoginAt": "2024-01-20T14:30:00Z"
}
看到没?基本属性、配置、联系人信息全嵌入,但社交关系用 friendIds 引用存储。这样既保证了查询效率,又避免了文档膨胀。
错误二:嵌套层级过深
MongoDB支持嵌套文档,但嵌套层级最好不要超过5层。层级太深,查询和更新都会变得非常麻烦。
错误的做法:
{
"order": {
"items": {
"item1": {
"product": {
"category": {
"subcategory": {
"details": "..."
}
}
}
}
}
}
}
这嵌套了5层以上,查询的时候需要写很长的路径,一旦某个中间节点的数据结构变了,整个查询都要改。
正确的做法:嵌套层级控制在3层以内,超过3层就用引用。
错误三:用嵌入式处理一对多关系中的”多”端
这是个经典错误。比如有个”用户-订单”的关系,一个用户对应多个订单。如果把所有订单全嵌入用户文档:
{
"_id": "user_12345",
"username": "tech_guru",
"orders": [
{ "orderId": "order_001", "totalAmount": 299.00, "status": "completed" },
{ "orderId": "order_002", "totalAmount": 599.00, "status": "shipped" },
// ... 可能还有几千条
]
}
用户每下一个订单,就要更新这个嵌套数组。随着订单数量增加,更新操作会越来越慢。而且如果你要查某个订单,得先把整个用户文档读出来,再在应用层过滤,效率极低。
正确的做法:订单用引用存储,用户文档里只存订单ID列表(用于快速分页展示):
{
"_id": "user_12345",
"username": "tech_guru",
"orderIds": ["order_001", "order_002", "order_003"],
"createdAt": "2023-06-15T08:00:00Z"
}
{
"_id": "order_001",
"userId": "user_12345",
"items": [
{ "productId": "prod_101", "quantity": 1, "price": 299.00 }
],
"totalAmount": 299.00,
"status": "completed",
"createdAt": "2024-01-10T15:30:00Z"
}
错误四:忽视文档大小限制
MongoDB的单文档大小限制是16MB。这个限制看似很大,但在实际业务中很容易被忽略。
比如你做聊天系统,把某个用户的聊天记录全嵌进用户文档。假设每条消息500字节,16MB能存3万多条消息。看起来很多对吧?但如果你的用户是活跃用户,每天发100条消息,一年就是3万多条。半年就到头了。
所以:对于可能持续增长的数据,不要用嵌入式。 用引用,或者用专门的集合存储。
实战案例一:电商订单系统
业务背景
我们先来设计一个电商订单系统。这个系统需要支持:
- 用户下单,包含多个商品
- 订单状态流转(待支付 → 已支付 → 已发货 → 已完成)
- 查询某个用户的所有订单
- 查询某个商品的所有订单
- 订单修改(比如修改收货地址)
- 统计某个商家在某段时间的销售额
设计思路
这个场景里,有几个关键关系需要理清:
- 用户和订单——一对多关系。一个用户可以有多个订单,但一个订单只属于一个用户。
- 订单和订单明细——一对多关系。一个订单包含多个商品,但一个商品明细只属于一个订单。
- 商家和商品——一对多关系。一个商家可以卖多个商品。
- 订单和商家——间接关系。通过商品关联。
数据模型设计
用户文档:
{
"_id": "user_10001",
"username": "zhangsan",
"email": "zhangsan@example.com",
"phone": "13800138001",
"shippingAddresses": [
{
"addressId": "addr_001",
"recipient": "张三",
"phone": "13800138001",
"province": "北京市",
"city": "北京市",
"district": "海淀区",
"detail": "中关村大街1号",
"isDefault": true
},
{
"addressId": "addr_002",
"recipient": "张三",
"phone": "13800138002",
"province": "上海市",
"city": "上海市",
"district": "浦东新区",
"detail": "世纪大道100号",
"isDefault": false
}
],
"orderIds": ["order_20001", "order_20002"],
"createdAt": "2023-06-01T08:00:00Z",
"updatedAt": "2024-01-20T10:30:00Z"
}
用户文档里嵌入了收货地址(数据量小,跟着用户走),订单用引用(订单可能很多)。
商品文档:
{
"_id": "prod_5001",
"name": "机械键盘 K87",
"description": "87键机械键盘,青轴,RGB背光",
"categoryId": "cat_101",
"merchantId": "merchant_001",
"price": 399.00,
"originalPrice": 499.00,
"stock": 150,
"images": [
"https://cdn.example.com/prod/5001_1.jpg",
"https://cdn.example.com/prod/5001_2.jpg"
],
"specifications": {
"switch": "Cherry MX Blue",
"keyCount": 87,
"connection": "USB-C",
"weight": "650g"
},
"orderIds": ["order_20001"],
"createdAt": "2023-08-15T09:00:00Z",
"updatedAt": "2024-01-18T16:00:00Z"
}
商品文档里嵌入了规格参数(数据量小,跟着商品走),订单用引用。
订单文档(核心):
{
"_id": "order_20001",
"orderNo": "ORD2024012012345678",
"userId": "user_10001",
"merchantId": "merchant_001",
"status": "shipped",
"statusHistory": [
{
"status": "pending",
"time": "2024-01-20T12:00:00Z",
"remark": "订单创建"
},
{
"status": "paid",
"time": "2024-01-20T12:05:00Z",
"remark": "支付成功"
},
{
"status": "shipped",
"time": "2024-01-21T09:00:00Z",
"remark": "已发货,快递单号:SF1234567890"
}
],
"items": [
{
"productId": "prod_5001",
"productName": "机械键盘 K87",
"quantity": 1,
"unitPrice": 399.00,
"subtotal": 399.00,
"specifications": {
"switch": "Cherry MX Blue",
"color": "黑色"
}
},
{
"productId": "prod_5002",
"productName": "键盘保护套",
"quantity": 2,
"unitPrice": 29.00,
"subtotal": 58.00
}
],
"shippingAddress": {
"recipient": "张三",
"phone": "13800138001",
"province": "北京市",
"city": "北京市",
"district": "海淀区",
"detail": "中关村大街1号"
},
"paymentInfo": {
"paymentMethod": "alipay",
"transactionId": "TRA2024012012345678",
"paidAt": "2024-01-20T12:05:00Z"
},
"shippingInfo": {
"expressCompany": "顺丰速运",
"trackingNumber": "SF1234567890",
"shippedAt": "2024-01-21T09:00:00Z"
},
"amountInfo": {
"itemTotal": 457.00,
"shippingFee": 0.00,
"discount": 20.00,
"totalAmount": 437.00,
"paidAmount": 437.00
},
"createdAt": "2024-01-20T12:00:00Z",
"updatedAt": "2024-01-21T09:00:00Z"
}
这里有个关键设计决策:订单里的商品信息不是引用商品文档,而是直接把商品名称、价格、规格等信息复制到订单里。这是典型的数据冗余设计,为什么这么做?
- 历史数据保留:商品可能会改名、改价格、改规格。如果订单里只存商品ID,以后看历史订单时,看到的数据是商品最新的信息,而不是购买时的信息。这对于电商来说是不可接受的。
- 查询性能:查询订单详情时,不需要再查商品文档,直接就有完整信息。
- 数据一致性:订单金额是购买时的价格,应该锁定,不能随商品价格变动而变化。
索引设计
// 用户维度查询:查某个用户的所有订单
db.orders.createIndex({ userId: 1, createdAt: -1 })
// 订单号查询:唯一索引,用于精确查找
db.orders.createIndex({ orderNo: 1 }, { unique: true })
// 商家维度查询:查某个商家的订单
db.orders.createIndex({ merchantId: 1, createdAt: -1 })
// 商品维度查询:查某个商品被哪些订单购买过
db.orders.createIndex({ "items.productId": 1 })
// 状态查询:查某个状态的订单
db.orders.createIndex({ status: 1, createdAt: -1 })
常用查询场景
场景一:用户查看我的订单列表
// 先查用户的orderIds
const user = await db.users.findOne(
{ _id: "user_10001" },
{ projection: { orderIds: 1 } }
)
// 再批量查订单详情
const orders = await db.orders.find(
{ _id: { $in: user.orderIds } },
{ projection: { items: 1, status: 1, amountInfo: 1, createdAt: 1 } }
).sort({ createdAt: -1 })
这里用到了分页查询的技巧。如果订单很多,可以配合 limit 和 skip 来实现分页。
场景二:商家查看今日订单
const today = new Date()
today.setHours(0, 0, 0, 0)
const tomorrow = new Date(today)
tomorrow.setDate(tomorrow.getDate() + 1)
const orders = await db.orders.find({
merchantId: "merchant_001",
createdAt: { $gte: today, $lt: tomorrow }
}).sort({ createdAt: 1 })
场景三:修改订单收货地址
await db.orders.updateOne(
{ _id: "order_20001", status: { $in: ["pending", "paid"] } },
{
$set: {
"shippingAddress.recipient": "张三",
"shippingAddress.phone": "13800138001",
"shippingAddress.detail": "中关村大街2号"
},
$push: {
statusHistory: {
status: "address_updated",
time: new Date(),
remark: "收货地址已修改"
}
}
}
)
注意这里有个状态检查:只有待支付或已支付的订单才能修改地址。已发货的订单就不能改了,需要在应用层处理。
场景四:统计商家月度销售额
const result = await db.orders.aggregate([
{
$match: {
merchantId: "merchant_001",
createdAt: {
$gte: new Date("2024-01-01"),
$lt: new Date("2024-02-01")
},
status: { $in: ["paid", "shipped", "completed"] }
}
},
{
$group: {
_id: { $dateToString: { format: "%Y-%m-%d", date: "$createdAt" } },
orderCount: { $sum: 1 },
totalAmount: { $sum: "$amountInfo.totalAmount" }
}
},
{ $sort: { _id: 1 } }
])
用聚合管道做统计,是MongoDB的强项。
实战案例二:社交关系系统
业务背景
再来设计一个社交关系系统,需求如下:
- 用户可以关注其他用户
- 可以互相关注成为好友
- 用户发布动态(朋友圈)
- 动态可以被点赞和评论
- 用户可以查看自己的关注列表和粉丝列表
- 推荐系统需要查询好友的好友
设计思路
社交系统的核心挑战是关系的查询。关注关系是双向的(A关注B,B也关注A),而且关系数据量会随着用户增长而快速增长。
和电商系统不同,社交系统的特点是:
- 关系数据量巨大,一个活跃用户可能有几千个关注
- 关系查询频繁,尤其是”谁关注我”和”我关注谁”
- 动态 Feed 需要高效聚合
数据模型设计
用户文档:
{
"_id": "user_10001",
"username": "zhangsan",
"displayName": "张三",
"avatar": "https://cdn.example.com/avatar/10001.jpg",
"bio": "程序猿一枚",
"stats": {
"followingCount": 150,
"followerCount": 3200,
"postCount": 89
},
"followingIds": ["user_20001", "user_20002", "user_20003"],
"followerIds": ["user_30001", "user_30002"],
"createdAt": "2023-01-15T08:00:00Z"
}
关键设计:followingIds 和 followerIds 直接存在用户文档里。这样查询”我关注的人”和”关注我的人”只需要查一个文档,不需要额外的聚合查询。
注意:这里有个数据一致性问题。当A关注B时,需要同时更新A的followingIds和B的followerIds。这是两个文档的更新,需要用事务来保证一致性。
// 开启事务,保证两个文档更新的一致性
const session = await db.startSession()
try {
await session.withTransaction(async () => {
// A关注B
await db.users.updateOne(
{ _id: "user_10001" },
{ $push: { followingIds: "user_20001" } },
{ session }
)
await db.users.updateOne(
{ _id: "user_20001" },
{
$push: { followerIds: "user_10001" },
$inc: { "stats.followerCount": 1 }
},
{ session }
)
})
} finally {
await session.endSession()
}
动态文档:
{
"_id": "post_10001",
"userId": "user_10001",
"content": "今天学习了MongoDB的数据模型设计,收获颇丰!",
"mediaUrls": [
"https://cdn.example.com/post/10001_1.jpg",
"https://cdn.example.com/post/10001_2.jpg"
],
"tags": ["技术", "MongoDB"],
"likeCount": 42,
"commentCount": 8,
"likeUserIds": ["user_20001", "user_20002", "user_30001"],
"comments": [
{
"commentId": "comment_001",
"userId": "user_20001",
"username": "lisi",
"content": "写得不错,学习了",
"likeCount": 3,
"createdAt": "2024-01-20T14:00:00Z"
},
{
"commentId": "comment_002",
"userId": "user_30001",
"username": "wangwu",
"content": "能不能分享一下资料?",
"likeCount": 1,
"createdAt": "2024-01-20T15:30:00Z"
}
],
"visibility": "public",
"createdAt": "2024-01-20T13:00:00Z",
"updatedAt": "2024-01-20T15:30:00Z"
}
动态里嵌入了评论。为什么可以嵌入?因为:
- 单条评论数量通常不大(几十条到几百条)
- 评论是动态的附属数据,不会脱离动态存在
- 查询动态时需要同时展示评论,嵌入后一次查询搞定
但要注意:如果评论数量太多,会撑爆文档。可以加一个限制,比如超过100条评论就不再嵌入,而是单独存储。
话题文档(可选):
{
"_id": "tag_001",
"name": "MongoDB",
"slug": "mongodb",
"postCount": 1250,
"description": "MongoDB相关技术分享",
"createdAt": "2023-06-01T08:00:00Z"
}
话题用独立的文档存储,动态里通过 tag 字符串关联。
索引设计
// 用户维度:查某用户关注的人
db.users.createIndex({ _id: 1, followingIds: 1 })
// 用户维度:查某用户的粉丝
db.users.createIndex({ _id: 1, followerIds: 1 })
// 动态维度:查某用户的所有动态
db.posts.createIndex({ userId: 1, createdAt: -1 })
// 动态维度:查某动态的评论(如果用独立集合存储评论)
db.comments.createIndex({ postId: 1, createdAt: 1 })
// 动态维度:查某用户点赞的动态
db.posts.createIndex({ "likeUserIds": 1 })
常用查询场景
场景一:获取用户关注的人的最新动态(个人主页Feed)
// 1. 先获取用户关注的人的ID列表
const user = await db.users.findOne(
{ _id: "user_10001" },
{ projection: { followingIds: 1 } }
)
// 2. 获取关注用户的动态
const followingPosts = await db.posts.find(
{
userId: { $in: user.followingIds },
visibility: "public"
},
{
projection: {
userId: 1,
content: 1,
mediaUrls: 1,
likeCount: 1,
commentCount: 1,
createdAt: 1
}
}
).sort({ createdAt: -1 }).limit(20)
这里有个优化空间:如果用户关注的人很多(几千个),$in 查询可能会慢。可以考虑用专门的 Feed 集合,在用户发动态时预计算并写入关注者的 Feed 流。这就是写扩散和读扩散的选择。
场景二:获取用户自己的动态(带分页)
const posts = await db.posts.find(
{ userId: "user_10001" },
{
projection: {
content: 1,
mediaUrls: 1,
likeCount: 1,
commentCount: 1,
comments: { $slice: -20 } // 只取最新20条评论
}
}
).sort({ createdAt: -1 })
.skip(offset)
.limit(20)
场景三:查看某动态的评论列表
const post = await db.posts.findOne(
{ _id: "post_10001" },
{ projection: { comments: 1 } }
)
// 或者用 $slice 分页
const post = await db.posts.findOne(
{ _id: "post_10001" },
{
projection: {
comments: { $slice: [page * 20, 20] } // 分页取评论
}
}
)
场景四:检查两个用户是否互为好友
const [userA, userB] = await Promise.all([
db.users.findOne({ _id: "user_10001" }, { projection: { followingIds: 1 } }),
db.users.findOne({ _id: "user_20001" }, { projection: { followingIds: 1 } })
])
const isFollowing = userA.followingIds.includes("user_20001")
const isFollowed = userB.followingIds.includes("user_10001")
const isFriends = isFollowing && isFollowed
这里用到了 $in 或者应用层的 includes 来判断。如果关系数据量很大,可以考虑用专门的关注关系集合。
场景五:推荐”你可能认识的人”
// 获取用户的粉丝列表
const user = await db.users.findOne(
{ _id: "user_10001" },
{ projection: { followerIds: 1, followingIds: 1 } }
)
// 获取粉丝的粉丝(排除已关注的)
const candidates = await db.users.aggregate([
{ $match: { _id: { $in: user.followerIds } } },
{ $project: { followerIds: 1, _id: 1 } },
{ $unwind: "$followerIds" },
{ $match: { followerIds: { $nin: [...user.followingIds, "user_10001"] } } },
{ $group: { _id: "$followerIds", score: { $sum: 1 } } },
{ $sort: { score: -1 } },
{ $limit: 10 }
])
这个查询找的是”你粉丝的朋友”,也就是你的二度人脉。这是一个经典的社交推荐算法。
嵌入式文档和引用的混合使用策略
看了上面两个案例,你们可能注意到了:没有哪个系统是完全用嵌入式或完全用引用的。 实际项目中,两种模式往往是混合使用的。
我给你总结一个混合使用的基本原则:
原则一:查询为主,设计为辅
先想清楚你的查询模式是什么。用户最可能怎么查这些数据?查询路径最短的方案就是最好的方案。
比如电商订单系统,查询订单详情的场景最多,所以订单文档里嵌入商品信息(虽然冗余,但查询最快)。而查询用户订单列表的场景次之,所以用户文档里只存订单ID列表。
原则二:数据膨胀是最大敌人
嵌入式文档的文档大小不能超过16MB。如果你的数据可能超过这个限制,就不要嵌入。
怎么判断?估算一下最大可能的数据量。比如一个用户的关注列表,最多可能有多少个关注?如果可能超过10万,就别嵌入,用引用。
原则三:一致性要求决定存储方式
如果数据之间有强一致性要求(比如订单金额必须和商品实际价格一致),优先用嵌入式。如果允许最终一致性,可以用引用。
原则四:写操作频率高的数据用引用
嵌入式文档的写入操作可能需要更新整个文档,如果写操作很频繁,会造成较大的性能开销。引用模式可以只更新相关字段。
性能优化技巧
技巧一:合理设置投影,只取需要的字段
MongoDB支持投影,查询时只返回需要的字段,可以大大减少网络传输和内存消耗。
// 不好的做法:返回所有字段
const order = await db.orders.findOne({ _id: "order_20001" })
// 好的做法:只返回需要的字段
const order = await db.orders.findOne(
{ _id: "order_20001" },
{ projection: { items: 1, status: 1, amountInfo: 1, createdAt: 1 } }
)
技巧二:使用 $slice 处理大数组
如果嵌入的数组很大(比如评论列表有几百条),可以用 $slice 分页返回。
const post = await db.posts.findOne(
{ _id: "post_10001" },
{ projection: { comments: { $slice: [page * 20, 20] } } }
)
技巧三:定期归档历史数据
对于订单、日志等随时间增长的数据,可以定期把历史数据归档到单独的集合或数据库。
// 把一年前的订单归档
const oneYearAgo = new Date()
oneYearAgo.setFullYear(oneYearAgo.getFullYear() - 1)
const oldOrders = await db.orders.find({
createdAt: { $lt: oneYearAgo }
}).toArray()
await db.ordersArchive.insertMany(oldOrders)
await db.orders.deleteMany({ createdAt: { $lt: oneYearAgo } })
技巧四:利用TTL索引自动清理过期数据
对于日志、会话等有时效性的数据,可以用TTL索引自动清理。
// 会话数据24小时后自动删除
db.sessions.createIndex(
{ expiresAt: 1 },
{ expireAfterSeconds: 0 }
)
总结:没有银弹,只有最适合的方案
写到这里,我想跟你们分享一个观点:MongoDB数据模型设计没有绝对的正确或错误,只有适不适合。
嵌入式文档适合数据量小、查询频繁、一致性要求高的场景。引用模式适合数据量大、写操作频繁、需要共享的场景。混合使用是常态。
我给你们总结一个决策流程:
- 分析查询模式——你最常怎么查这些数据?
- 评估数据量——数据会增长到多大?会不会超过16MB?
- 判断一致性要求——数据更新是否需要强一致?
- 考虑共享性——数据是否会被多方引用?
- 选择模式——根据以上分析选择嵌入式或引用,或者混合使用
- 设计索引——根据查询模式设计合适的索引
- 测试验证——用真实数据量测试性能
最后送大家一句话:好的数据模型设计,是让查询路径最短,让写入成本最低,让数据膨胀可控。 做到了这三点,你的MongoDB设计就是成功的。
希望这篇文章能帮到你们。如果有任何问题,欢迎在评论区讨论。咱们下次见!
