299 lines
9.4 KiB
Markdown
299 lines
9.4 KiB
Markdown
# 数据建模
|
||
|
||
与关系型数据库不同,MongoDB 灵活的文档模型为数据建模提供了更多的选择。本章节将介绍 MongoDB 数据建模的核心思想、常见模式以及如何根据应用需求选择合适的模型,以实现最佳的性能和可扩展性。
|
||
|
||
---
|
||
|
||
## 数据建模核心思想
|
||
|
||
### 嵌入(Embedding) vs. 引用(Referencing)
|
||
|
||
这是 MongoDB 数据建模中最核心的决策点。
|
||
|
||
- **嵌入 (Embedding / Denormalization)**
|
||
- **描述**: 将相关的数据直接嵌入到主文档中,形成一个嵌套的文档结构。
|
||
- **优点**:
|
||
- **性能好**: 只需一次数据库查询即可获取所有相关数据,减少了读操作的次数。
|
||
- **数据原子性**: 对单个文档的更新是原子操作。
|
||
- **缺点**:
|
||
- **文档体积大**: 可能导致文档超过 16MB 的大小限制。
|
||
- **数据冗余**: 如果嵌入的数据在多处被使用,更新时需要修改所有包含它的文档。
|
||
- **适用场景**: “一对一”或“一对多”关系,且“多”的一方数据不经常变动,或者总是和“一”的一方一起被查询。
|
||
|
||
- **引用 (Referencing / Normalization)**
|
||
- **描述**: 将数据存储在不同的集合中,通过在文档中存储对另一个文档的引用(通常是 `_id`)来建立关系。
|
||
- **优点**:
|
||
- **数据一致性**: 更新数据时只需修改一处。
|
||
- **文档体积小**: 避免了数据冗余。
|
||
- **缺点**:
|
||
- **查询性能较低**: 需要多次查询(或使用 `$lookup`)来获取关联数据。
|
||
- **适用场景**: “多对多”关系,或者“一对多”关系中“多”的一方数据量巨大、经常变动或经常被独立查询。
|
||
|
||
### 决策依据
|
||
|
||
选择嵌入还是引用,主要取决于应用的**数据访问模式**:
|
||
|
||
- **读多写少**: 优先考虑嵌入,优化读取性能。
|
||
- **写操作频繁**: 优先考虑引用,避免更新大量冗余数据。
|
||
- **数据一致性要求高**: 优先考虑引用。
|
||
- **数据关联性强,总是一起访问**: 优先考虑嵌入。
|
||
|
||
---
|
||
|
||
## 常见数据建模模式
|
||
|
||
MongoDB 社区总结了一些行之有效的数据建模模式,可以作为设计的参考。
|
||
|
||
### 属性模式 (Attribute Pattern)
|
||
|
||
- **问题**: 当文档有大量字段,但大部分查询只关心其中一小部分时。
|
||
- **解决方案**: 将不常用或具有相似特征的字段分组到一个子文档中。
|
||
- **示例**: 一个产品文档,将详细规格参数放到 `specs` 子文档中。
|
||
|
||
```json
|
||
{
|
||
"product_id": "123",
|
||
"name": "Laptop",
|
||
"specs": {
|
||
"cpu": "Intel i7",
|
||
"ram_gb": 16,
|
||
"storage_gb": 512
|
||
}
|
||
}
|
||
```
|
||
|
||
### 扩展引用模式 (Extended Reference Pattern)
|
||
|
||
- **问题**: 在引用模型中,为了获取被引用文档的某个常用字段,需要执行额外的查询。
|
||
- **解决方案**: 在引用文档中,除了存储 `_id` 外,还冗余存储一些经常需要一起显示的字段。
|
||
- **示例**: 在文章(`posts`)集合中,除了存储作者的 `author_id`,还冗余存储 `author_name`。
|
||
|
||
```json
|
||
// posts collection
|
||
{
|
||
"title": "My First Post",
|
||
"author_id": "xyz",
|
||
"author_name": "John Doe" // Extended Reference
|
||
}
|
||
```
|
||
|
||
### 子集模式 (Subset Pattern)
|
||
|
||
- **问题**: 一个文档中有一个巨大的数组(如评论、日志),导致文档过大,且大部分时间只需要访问数组的最新一部分。
|
||
- **解决方案**: 将数组的一个子集(如最新的 10 条评论)与主文档存储在一起,完整的数组存储在另一个集合中。
|
||
- **示例**: 产品文档中存储最新的 5 条评论,所有评论存储在 `reviews` 集合。
|
||
|
||
```json
|
||
// products collection
|
||
{
|
||
"product_id": "abc",
|
||
"name": "Super Widget",
|
||
"reviews_subset": [
|
||
{ "review_id": 1, "text": "Great!" },
|
||
{ "review_id": 2, "text": "Awesome!" }
|
||
]
|
||
}
|
||
```
|
||
|
||
### 计算模式 (Computed Pattern)
|
||
|
||
- **问题**: 需要频繁计算某些值(如总数、平均值),每次读取时计算会消耗大量资源。
|
||
- **解决方案**: 在写操作时预先计算好这些值,并将其存储在文档中。当数据更新时,同步更新计算结果。
|
||
- **示例**: 在用户文档中存储其发布的帖子总数 `post_count`。
|
||
|
||
```json
|
||
{
|
||
"user_id": "user123",
|
||
"name": "Jane Doe",
|
||
"post_count": 42 // Computed value
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## Schema 验证
|
||
|
||
虽然 MongoDB 是 schema-less 的,但在应用层面保持数据结构的一致性非常重要。从 MongoDB 3.2 开始,引入了 Schema 验证功能。
|
||
|
||
- **作用**: 可以在集合级别定义文档必须满足的结构规则(如字段类型、必需字段、范围等)。
|
||
- **好处**: 确保写入数据库的数据符合预期格式,提高数据质量。
|
||
|
||
```javascript
|
||
db.createCollection("students", {
|
||
validator: {
|
||
$jsonSchema: {
|
||
bsonType: "object",
|
||
required: ["name", "major", "gpa"],
|
||
properties: {
|
||
name: {
|
||
bsonType: "string",
|
||
description: "must be a string and is required"
|
||
},
|
||
gpa: {
|
||
bsonType: ["double"],
|
||
minimum: 0,
|
||
maximum: 4.0,
|
||
description: "must be a double in [0, 4] and is required"
|
||
}
|
||
}
|
||
}
|
||
}
|
||
})
|
||
```
|
||
|
||
---
|
||
|
||
## 实践环节
|
||
|
||
### 需求描述
|
||
|
||
为一个博客系统设计数据模型,包含以下实体:`用户 (User)`、`文章 (Post)`、`评论 (Comment)` 和 `标签 (Tag)`。
|
||
|
||
1. **关系分析**: 分析这些实体之间的关系(一对一、一对多、多对多)。
|
||
2. **模型设计**: 讨论并设计至少两种不同的数据模型方案(例如,一种偏向嵌入,一种偏向引用)。
|
||
3. **优劣对比**: 对比不同方案的优缺点,并说明它们分别适用于哪些场景。
|
||
4. **Schema 定义**: 选择的最佳方案中的 `posts` 集合编写一个 Schema 验证规则。
|
||
|
||
### 实践细节和结果验证
|
||
|
||
```javascript
|
||
// 1. 关系分析与模型设计
|
||
|
||
// 方案一:偏向嵌入式(适合读多写少的场景)
|
||
// users 集合示例文档
|
||
db.users.insertOne({
|
||
"_id": ObjectId(),
|
||
"username": "john_doe",
|
||
"email": "john@example.com",
|
||
"posts": [{
|
||
"_id": ObjectId(),
|
||
"title": "MongoDB 入门",
|
||
"content": "MongoDB 是一个强大的 NoSQL 数据库...",
|
||
"created_at": ISODate("2024-01-15"),
|
||
"tags": ["MongoDB", "Database", "NoSQL"],
|
||
"comments": [{
|
||
"user_id": ObjectId(),
|
||
"username": "alice",
|
||
"content": "写得很好!",
|
||
"created_at": ISODate("2024-01-16")
|
||
}]
|
||
}]
|
||
});
|
||
|
||
// 方案二:偏向引用式(适合写多读少的场景)
|
||
// users 集合
|
||
db.users.insertOne({
|
||
"_id": ObjectId(),
|
||
"username": "john_doe",
|
||
"email": "john@example.com"
|
||
});
|
||
|
||
// posts 集合
|
||
db.posts.insertOne({
|
||
"_id": ObjectId(),
|
||
"author_id": ObjectId(), // 引用 users._id
|
||
"author_name": "john_doe", // 扩展引用
|
||
"title": "MongoDB 入门",
|
||
"content": "MongoDB 是一个强大的 NoSQL 数据库...",
|
||
"created_at": ISODate("2024-01-15"),
|
||
"tags": ["MongoDB", "Database", "NoSQL"]
|
||
});
|
||
|
||
// comments 集合
|
||
db.comments.insertOne({
|
||
"_id": ObjectId(),
|
||
"post_id": ObjectId(), // 引用 posts._id
|
||
"user_id": ObjectId(), // 引用 users._id
|
||
"username": "alice", // 扩展引用
|
||
"content": "写得很好!",
|
||
"created_at": ISODate("2024-01-16")
|
||
});
|
||
|
||
// 2. 性能测试
|
||
|
||
// 方案一:嵌入式查询(一次查询获取所有信息)
|
||
db.users.find(
|
||
{ "posts.tags": "MongoDB" },
|
||
{ "posts.$": 1 }
|
||
).explain("executionStats");
|
||
|
||
// 方案二:引用式查询(需要聚合或多次查询)
|
||
db.posts.aggregate([
|
||
{ $match: { tags: "MongoDB" } },
|
||
{ $lookup: {
|
||
from: "comments",
|
||
localField: "_id",
|
||
foreignField: "post_id",
|
||
as: "comments"
|
||
}}
|
||
]).explain("executionStats");
|
||
|
||
// 3. Schema 验证规则
|
||
|
||
// 为 posts 集合创建验证规则
|
||
db.createCollection("posts", {
|
||
validator: {
|
||
$jsonSchema: {
|
||
bsonType: "object",
|
||
required: ["author_id", "author_name", "title", "content", "created_at", "tags"],
|
||
properties: {
|
||
author_id: {
|
||
bsonType: "objectId",
|
||
description: "作者ID,必须是 ObjectId 类型且不能为空"
|
||
},
|
||
author_name: {
|
||
bsonType: "string",
|
||
description: "作者名称,必须是字符串且不能为空"
|
||
},
|
||
title: {
|
||
bsonType: "string",
|
||
minLength: 1,
|
||
maxLength: 100,
|
||
description: "标题长度必须在1-100字符之间"
|
||
},
|
||
content: {
|
||
bsonType: "string",
|
||
minLength: 1,
|
||
description: "内容不能为空"
|
||
},
|
||
created_at: {
|
||
bsonType: "date",
|
||
description: "创建时间,必须是日期类型"
|
||
},
|
||
tags: {
|
||
bsonType: "array",
|
||
minItems: 1,
|
||
uniqueItems: true,
|
||
items: {
|
||
bsonType: "string"
|
||
},
|
||
description: "标签必须是非空字符串数组,且不能重复"
|
||
}
|
||
}
|
||
}
|
||
},
|
||
validationLevel: "strict",
|
||
validationAction: "error"
|
||
});
|
||
|
||
// 4. 验证结果
|
||
|
||
// 测试有效文档(应该成功)
|
||
db.posts.insertOne({
|
||
author_id: ObjectId(),
|
||
author_name: "john_doe",
|
||
title: "测试文章",
|
||
content: "这是一篇测试文章",
|
||
created_at: new Date(),
|
||
tags: ["测试", "MongoDB"]
|
||
});
|
||
|
||
// 测试无效文档(应该失败)
|
||
db.posts.insertOne({
|
||
author_name: "john_doe", // 缺少 author_id
|
||
title: "测试文章",
|
||
content: "这是一篇测试文章",
|
||
created_at: new Date(),
|
||
tags: [] // 空标签数组,违反 minItems 规则
|
||
});
|
||
``` |