Database · #mongodb#nosql#schema-design

MongoDB Schema设计最佳实践

2025.02.12 9 min 3.5k
// 目录 · contents

引言

MongoDB作为最流行的文档数据库,其Schema设计理念与关系型数据库有着根本性的不同。在RDBMS中,我们追求的是数据的规范化(Normal Form),尽量减少冗余;而在MongoDB中,Schema设计的核心原则是”数据的存储方式应该与其被使用的方式一致”。好的Schema设计可以让查询变得简单高效,而糟糕的设计则会导致性能灾难。

文档模型基础

MongoDB vs RDBMS 概念对照

graph LR
    subgraph RDBMS
        A[Database] --> B[Table]
        B --> C[Row]
        C --> D[Column]
        B --> E[JOIN]
    end

    subgraph MongoDB
        F[Database] --> G[Collection]
        G --> H[Document]
        H --> I[Field]
        G --> J["Embedding<br/>(代替JOIN)"]
    end

    A -.-> F
    B -.-> G
    C -.-> H
    D -.-> I
    E -.-> J

文档结构示例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// MongoDB文档 vs RDBMS表
// RDBMS中需要3张表: users, addresses, phone_numbers
// MongoDB中可以用1个文档表达:
{
_id: ObjectId("507f1f77bcf86cd799439011"),
name: "Alice",
email: "[email protected]",
address: {
street: "123 Main St",
city: "Beijing",
zipcode: "100000"
},
phones: [
{ type: "home", number: "010-12345678" },
{ type: "mobile", number: "138-0013-8000" }
],
created_at: ISODate("2025-01-15T08:30:00Z")
}

嵌入 vs 引用

这是MongoDB Schema设计的核心决策。

嵌入(Embedding)

将关联数据作为子文档直接嵌入到父文档中。

graph TD
    subgraph "嵌入模式"
        A["Order Document"] --> B["_id: order001<br/>user_name: Alice<br/>total: 299.00"]
        A --> C["items: [<br/>  {sku: 'A001', name: 'T-Shirt', qty: 2, price: 99.5},<br/>  {sku: 'B002', name: 'Jeans', qty: 1, price: 100.0}<br/>]"]
        A --> D["shipping_address: {<br/>  street: '123 Main St',<br/>  city: 'Beijing'<br/>}"]
    end

适用场景:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// 1. 一对一关系: 用户 -> 个人资料
{
_id: "user001",
name: "Alice",
profile: { // 嵌入个人资料
bio: "Software Engineer",
avatar_url: "/images/alice.jpg",
social_links: {
github: "https://github.com/alice",
twitter: "https://twitter.com/alice"
}
}
}

// 2. 一对少量关系: 博客文章 -> 评论(评论数量有限)
{
_id: "post001",
title: "MongoDB Schema Design",
content: "...",
comments: [ // 嵌入评论(假设评论不超过几十条)
{ user: "Bob", text: "Great article!", date: ISODate("2025-03-01") },
{ user: "Carol", text: "Very helpful", date: ISODate("2025-03-02") }
]
}

嵌入的优缺点:

优点 缺点
一次读取获取全部数据 文档大小限制16MB
原子性更新(单文档事务) 嵌入数组过大影响性能
无需JOIN,查询简单高效 数据冗余,更新可能需要多处修改

引用(Referencing)

通过存储关联文档的_id来建立关系。

graph TD
    subgraph "引用模式"
        A["User Document<br/>_id: user001<br/>name: Alice"]
        B["Order 1<br/>_id: order001<br/>user_id: user001<br/>total: 299.00"]
        C["Order 2<br/>_id: order002<br/>user_id: user001<br/>total: 159.00"]
        D["Order 3<br/>_id: order003<br/>user_id: user001<br/>total: 520.00"]
    end

    B -->|user_id| A
    C -->|user_id| A
    D -->|user_id| A
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
// 用户集合
{
_id: "user001",
name: "Alice",
email: "[email protected]"
}

// 订单集合(引用用户ID)
{
_id: "order001",
user_id: "user001", // 引用
items: [...],
total: 299.00,
created_at: ISODate("2025-03-15")
}

// 查询时需要两次查询或使用 $lookup
db.orders.aggregate([
{ $match: { user_id: "user001" } },
{
$lookup: {
from: "users",
localField: "user_id",
foreignField: "_id",
as: "user_info"
}
}
]);

决策流程

flowchart TD
    A[设计数据关系] --> B{关系类型?}
    B -->|一对一| C[通常嵌入]
    B -->|一对少量 1:N, N<100| D{数据是否一起读取?}
    B -->|一对大量 1:N, N>100| E[引用]
    B -->|多对多 M:N| F[引用 + 数组]

    D -->|是| G[嵌入]
    D -->|否| H{子文档独立查询?}
    H -->|是| E
    H -->|否| G

    C --> I{子文档会频繁更新?}
    I -->|是| J[考虑引用]
    I -->|否| K[嵌入]

    E --> L[确保引用字段有索引]

一对多关系模式

模式一:嵌入数组(一对少量)

1
2
3
4
5
6
7
8
9
// 作者 -> 书籍地址 (一个作者通常只有少量地址)
{
_id: "author001",
name: "Zhang San",
addresses: [
{ label: "home", city: "Beijing", street: "Zhongguancun" },
{ label: "office", city: "Shanghai", street: "Lujiazui" }
]
}

模式二:子引用(一对多)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// 产品 -> 零部件 (一个产品有几十个零部件)
// 产品文档中存储零部件ID数组
{
_id: "product001",
name: "Widget X",
part_ids: ["part001", "part002", "part003", ...] // 引用数组
}

// 零部件集合
{ _id: "part001", name: "Screw M5", supplier: "SupplierA", cost: 0.5 }
{ _id: "part002", name: "Spring S2", supplier: "SupplierB", cost: 1.2 }

// 查询产品及其所有零部件
const product = db.products.findOne({ _id: "product001" });
const parts = db.parts.find({ _id: { $in: product.part_ids } });

模式三:父引用(一对大量)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// 主机 -> 日志 (一台主机可能有数百万条日志)
// 在子文档中存储父文档的ID

// 主机集合
{ _id: "host001", name: "web-server-01", ip: "192.168.1.10" }

// 日志集合 (每条日志引用主机)
{
_id: ObjectId("..."),
host_id: "host001", // 父引用
timestamp: ISODate("2025-03-15T10:30:00Z"),
level: "ERROR",
message: "Connection timeout"
}

// 查询某主机的最近日志
db.logs.find({ host_id: "host001" })
.sort({ timestamp: -1 })
.limit(100);

// 确保有索引
db.logs.createIndex({ host_id: 1, timestamp: -1 });

高级设计模式

多态模式(Polymorphic Pattern)

同一个集合中存储不同结构的文档,通过类型字段区分。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
// 不同类型的产品共享一个集合
// 手机
{
_id: "prod001",
type: "phone",
name: "iPhone 15",
brand: "Apple",
price: 5999,
specs: {
screen_size: 6.1,
ram: 8,
storage: 256,
camera: "48MP"
}
}

// 笔记本电脑
{
_id: "prod002",
type: "laptop",
name: "MacBook Pro 14",
brand: "Apple",
price: 14999,
specs: {
screen_size: 14.2,
ram: 18,
storage: 512,
cpu: "M3 Pro",
battery: "70Wh"
}
}

// 耳机
{
_id: "prod003",
type: "headphone",
name: "AirPods Pro 2",
brand: "Apple",
price: 1799,
specs: {
driver_size: "Custom",
noise_cancellation: true,
battery_life: "6h"
}
}

// 查询所有Apple产品,无论类型
db.products.find({ brand: "Apple" });

// 按类型查询
db.products.find({ type: "phone", "specs.ram": { $gte: 8 } });

桶模式(Bucket Pattern)

将时间序列数据按时间段分桶,减少文档数量,提升查询效率。

graph TD
    subgraph "传统方式: 每条数据一个文档"
        A1["doc1: {sensor: 's1', ts: 10:00:01, temp: 22.5}"]
        A2["doc2: {sensor: 's1', ts: 10:00:02, temp: 22.6}"]
        A3["doc3: {sensor: 's1', ts: 10:00:03, temp: 22.4}"]
        A4["...每分钟60个文档"]
    end

    subgraph "桶模式: 按时间段聚合"
        B1["bucket: {<br/>sensor: 's1',<br/>start: 10:00:00,<br/>count: 60,<br/>sum_temp: 1350.5,<br/>readings: [{ts: 01, v: 22.5}, {ts: 02, v: 22.6}, ...]<br/>}"]
    end
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
// 桶模式示例: IoT传感器数据
{
_id: ObjectId("..."),
sensor_id: "sensor_001",
bucket_start: ISODate("2025-03-15T10:00:00Z"),
bucket_end: ISODate("2025-03-15T11:00:00Z"),
count: 60,
sum_temperature: 1350.5,
min_temperature: 22.1,
max_temperature: 23.2,
readings: [
{ timestamp: ISODate("2025-03-15T10:00:00Z"), temperature: 22.5, humidity: 45 },
{ timestamp: ISODate("2025-03-15T10:01:00Z"), temperature: 22.6, humidity: 44 },
// ... 每分钟一条,共60条
]
}

// 使用 $push 和 $inc 原子性地添加新读数
db.sensor_data.updateOne(
{
sensor_id: "sensor_001",
count: { $lt: 60 }, // 桶未满
bucket_start: ISODate("2025-03-15T10:00:00Z")
},
{
$push: {
readings: {
timestamp: ISODate("2025-03-15T10:30:00Z"),
temperature: 22.8,
humidity: 43
}
},
$inc: { count: 1, sum_temperature: 22.8 },
$min: { min_temperature: 22.8 },
$max: { max_temperature: 22.8 }
},
{ upsert: true }
);

计算模式(Computed Pattern)

预计算频繁访问的汇总数据,避免重复计算。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
// 电商产品: 预计算评分统计
{
_id: "product001",
name: "Wireless Mouse",
price: 99.00,
// 预计算的统计数据
rating_stats: {
average: 4.3,
count: 1256,
distribution: {
"5": 680,
"4": 312,
"3": 156,
"2": 68,
"1": 40
}
}
}

// 新增评价时,原子性更新统计
db.products.updateOne(
{ _id: "product001" },
{
$inc: {
"rating_stats.count": 1,
"rating_stats.distribution.5": 1
}
}
);
// 定期用聚合管道重新计算average

扩展引用模式(Extended Reference Pattern)

在引用的同时冗余存储常用字段,减少JOIN查询。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// 订单中冗余存储用户和商品的核心信息
{
_id: "order001",
user: {
_id: "user001",
name: "Alice", // 冗余
phone: "138-0013-8000" // 冗余
},
items: [
{
product_id: "prod001",
name: "iPhone 15", // 冗余
price: 5999, // 下单时的价格(快照)
quantity: 1
}
],
total: 5999,
status: "paid",
created_at: ISODate("2025-03-15")
}
// 优势: 查询订单时不需要JOIN用户表和商品表
// 注意: 冗余字段的更新需要考虑一致性
// 对于订单这种场景,下单时的快照数据本身就不应该随源数据变化

索引策略

常用索引类型

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
// 单字段索引
db.users.createIndex({ email: 1 });

// 复合索引(遵循ESR规则: Equality, Sort, Range)
db.orders.createIndex({ user_id: 1, created_at: -1, status: 1 });
// user_id: Equality(等值查询放前面)
// created_at: Sort(排序字段放中间)
// status: Range(范围查询放后面)

// 多键索引(数组字段)
db.products.createIndex({ tags: 1 });

// 文本索引
db.articles.createIndex({ title: "text", content: "text" });

// 地理空间索引
db.restaurants.createIndex({ location: "2dsphere" });

// 部分索引(Partial Index)
db.orders.createIndex(
{ user_id: 1, created_at: -1 },
{ partialFilterExpression: { status: "active" } }
);

// TTL索引(自动过期删除)
db.sessions.createIndex(
{ created_at: 1 },
{ expireAfterSeconds: 3600 } // 1小时后自动删除
);

// 唯一索引
db.users.createIndex({ email: 1 }, { unique: true });

ESR 索引设计规则

graph LR
    A["ESR Rule"] --> B["E: Equality<br/>等值匹配的字段放最前"]
    B --> C["S: Sort<br/>排序字段放中间"]
    C --> D["R: Range<br/>范围查询字段放最后"]
1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 查询: 查找某用户的最近活跃订单
db.orders.find({
user_id: "user001", // Equality
status: "active", // Equality
amount: { $gte: 100 } // Range
}).sort({ created_at: -1 }) // Sort

// 最优索引
db.orders.createIndex({
user_id: 1, // E
status: 1, // E
created_at: -1, // S
amount: 1 // R
});

聚合管道

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
// 电商数据分析: 每月销售额Top10商品
db.orders.aggregate([
// Stage 1: 过滤时间范围
{
$match: {
status: "completed",
created_at: {
$gte: ISODate("2025-01-01"),
$lt: ISODate("2025-04-01")
}
}
},
// Stage 2: 展开订单项
{ $unwind: "$items" },
// Stage 3: 按月份和商品分组
{
$group: {
_id: {
month: { $dateToString: { format: "%Y-%m", date: "$created_at" } },
product_id: "$items.product_id",
product_name: "$items.name"
},
total_revenue: { $sum: { $multiply: ["$items.price", "$items.quantity"] } },
total_quantity: { $sum: "$items.quantity" },
order_count: { $sum: 1 }
}
},
// Stage 4: 按月份分组,取Top10
{
$sort: { "_id.month": 1, total_revenue: -1 }
},
{
$group: {
_id: "$_id.month",
top_products: {
$push: {
product_id: "$_id.product_id",
product_name: "$_id.product_name",
revenue: "$total_revenue",
quantity: "$total_quantity"
}
}
}
},
// Stage 5: 只取每月Top10
{
$project: {
month: "$_id",
top_products: { $slice: ["$top_products", 10] }
}
},
{ $sort: { month: 1 } }
]);
flowchart LR
    A["$match<br/>过滤条件"] --> B["$unwind<br/>展开数组"]
    B --> C["$group<br/>分组聚合"]
    C --> D["$sort<br/>排序"]
    D --> E["$project<br/>投影"]
    E --> F["$limit<br/>限制结果"]

    style A fill:#e3f2fd
    style B fill:#e8f5e9
    style C fill:#fff3e0
    style D fill:#fce4ec
    style E fill:#f3e5f5
    style F fill:#e0f7fa

聚合管道优化技巧:

  1. $match尽早放置:在管道开头过滤数据,减少后续阶段处理量
  2. **match *  * :match能使用索引
  3. $project提前裁剪字段:减少内存使用
  4. 避免$unwind产生笛卡尔积:大数组展开可能导致内存溢出
  5. 使用allowDiskUse:超过100MB内存限制时启用磁盘临时存储

总结

MongoDB Schema设计是一门平衡的艺术:

  • 嵌入 vs 引用是最核心的决策,需要根据数据关系、查询模式和更新频率综合判断
  • 一对少量用嵌入,一对大量用引用是基本原则
  • 多态模式适合同类但结构不同的数据共享集合
  • 桶模式是时序数据的高效存储方案,可以将文档数量减少一个数量级
  • 扩展引用模式通过适度冗余换取查询效率,特别适合订单快照等场景
  • 索引设计遵循ESR规则:等值-排序-范围的顺序
  • 聚合管道是MongoDB的数据分析利器,$match前置和合理的管道顺序是性能关键

记住MongoDB Schema设计的黄金法则:数据应该按照它被读取和使用的方式来建模,而不是按照它的逻辑关系来规范化。

作者 · authorzt
发布 · date2025-02-12
篇幅 · length3.5k 字 · 9 min
许可 · licenseCC BY-SA 4.0
$ echo "comments" · 评论