02. MongoDB 文档模型设计:嵌套 vs 引用

MongoDB 文档模型设计方法论: 一对一、一对多、多对多关系建模,嵌套与引用的权衡

MongoDB 的灵活性是其最大优势也是最大陷阱——没有固定的 schema 意味着可以自由设计,但也意味着设计不当会导致性能灾难。文档模型的核心决策是何时嵌套、何时引用。

1. 设计原则

1.1 嵌套 vs 引用决策树

数据是否一起查询?
  ├── 是 → 一起修改?
  │         ├── 是 → 嵌套(Embedded)
  │         └── 否 → 数据量小且不增长?
  │                   ├── 是 → 嵌套
  │                   └── 否 → 引用(Reference)
  └── 否 → 引用

1.2 黄金法则

场景推荐方案原因
主从关系,从数据小且稳定嵌套一次查询获取全部
主从关系,从数据大或频繁增长引用避免文档膨胀
多对多关系引用避免重复数据
需要独立查询从数据引用嵌套后无法单独索引
原子更新需求嵌套单文档原子操作

2. 一对一关系

// 方案 A: 嵌套(推荐,数据总是一起访问)
db.users.findOne({ _id: ObjectId("...") })
// {
//   _id: ObjectId("..."),
//   name: "Alice",
//   profile: {
//     bio: "Developer",
//     avatar: "https://...",
//     preferences: { theme: "dark", lang: "zh" }
//   }
// }

// 方案 B: 分离集合(profile 经常独立查询/更新)
db.users.findOne({ _id: uid })
db.user_profiles.findOne({ user_id: uid })

3. 一对多关系

3.1 子文档数组(1:Few)

// 一个用户有少量地址(5个以内)
db.users.insertOne({
    _id: ObjectId("..."),
    name: "Alice",
    addresses: [
        { type: "home", city: "Beijing", zip: "100000", default: true },
        { type: "work", city: "Shanghai", zip: "200000" }
    ]
});

// 查询默认地址
db.users.find(
    { "addresses.default": true },
    { "addresses.$": 1 }  // 位置操作符只返回匹配元素
);

// 新增地址
db.users.updateOne(
    { _id: userId },
    { $push: { addresses: newAddress } }
);

// 更新指定地址
db.users.updateOne(
    { _id: userId, "addresses.type": "home" },
    { $set: { "addresses.$.city": "Shenzhen" } }
);

3.2 父引用(1:Many)

// 一个作者有多篇文章(数量无上限)
// 文章集合存储作者引用
db.articles.insertOne({
    _id: ObjectId("..."),
    title: "MongoDB Design Patterns",
    author_id: ObjectId("author_id_here"),  // 引用作者
    content: "...",
    publishedAt: new Date()
});

// 查询作者的所有文章
db.articles.find({ author_id: authorId }).sort({ publishedAt: -1 });

// 带作者信息的聚合查询
db.articles.aggregate([
    { $match: { author_id: authorId } },
    { $lookup: {
        from: "authors",
        localField: "author_id",
        foreignField: "_id",
        as: "author"
    }},
    { $unwind: "$author" }  // 将数组展开为对象
]);

3.3 子引用(Denormalization)

// 冗余存储常用字段,避免 $lookup
db.articles.insertOne({
    title: "MongoDB Design",
    author_id: ObjectId("..."),
    author_name: "Alice Chen",      // 冗余作者名(读多写少场景)
    author_avatar: "https://...",   // 冗余头像
    content: "..."
});

// 作者改名时更新所有文章(写放大)
db.articles.updateMany(
    { author_id: authorId },
    { $set: { author_name: "Alice Wang" } }
);

4. 多对多关系

// 学生和课程的多对多关系
db.students.insertOne({
    _id: ObjectId("s1"),
    name: "Alice",
    enrolled_courses: [ObjectId("c1"), ObjectId("c2")]  // 引用课程
});

db.courses.insertOne({
    _id: ObjectId("c1"),
    name: "MongoDB Basics",
    enrolled_students: [ObjectId("s1"), ObjectId("s2")]  // 引用学生
});

// 查询某学生的所有课程(双向引用,两查询)
const student = db.students.findOne({ _id: s1 });
db.courses.find({ _id: { $in: student.enrolled_courses } });

// 中间集合方式(更灵活,可存储关系属性)
db.enrollments.insertOne({
    student_id: ObjectId("s1"),
    course_id: ObjectId("c1"),
    enrolledAt: new Date(),
    grade: 95,
    semester: "2024-spring"
});

5. 设计模式

5.1 桶模式(Bucket Pattern)

用于时序数据,将一段时间的数据放入一个文档:

// 传统方式: 每条读数一个文档 → 集合爆炸式增长
db.readings.insertMany([
    { sensor_id: "s1", timestamp: ISODate("2024-01-01T00:00:00Z"), value: 23.5 },
    { sensor_id: "s1", timestamp: ISODate("2024-01-01T00:01:00Z"), value: 23.6 },
    // ... 每分钟一条
]);

// 桶模式: 每小时一个文档
db.sensor_buckets.insertOne({
    sensor_id: "s1",
    date: ISODate("2024-01-01T00:00:00Z"),  // 小时的起点
    measurements: [
        { ts: ISODate("2024-01-01T00:00:00Z"), temp: 23.5, humidity: 60 },
        { ts: ISODate("2024-01-01T00:01:00Z"), temp: 23.6, humidity: 61 },
        // ... 最多 60 条/小时
    ],
    count: 60,
    min_temp: 22.1,
    max_temp: 25.8,
    avg_temp: 23.7
});

桶模式优势:

  • 减少文档数量(1/60)
  • 减少索引大小
  • 聚合统计更高效(预计算 min/max/avg)

5.2 近似计数模式

// 维护计数器的近似值(避免高频并发更新)
db.products.updateOne(
    { _id: productId },
    { $inc: { view_count: 1, approximate_views: Math.random() > 0.9 ? 1 : 0 } }
    // 只有 10% 概率更新 approximate_views,降低写压力
);

5.3 预聚合模式

// 订单集合记录日汇总,避免实时聚合
db.daily_sales.insertOne({
    date: ISODate("2024-01-15"),
    total_amount: 158000,
    order_count: 320,
    avg_order_value: 493.75,
    category_breakdown: {
        electronics: { count: 120, amount: 96000 },
        clothing: { count: 200, amount: 62000 }
    }
});

6. Schema 验证与变更治理

MongoDB 从 3.6 起支持 JSON Schema 验证,在灵活性与结构约束之间取得平衡:

db.createCollection("users", {
    validator: {
        $jsonSchema: {
            bsonType: "object",
            required: ["email", "createdAt"],
            properties: {
                email: {
                    bsonType: "string",
                    pattern: "^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}$"
                },
                role: {
                    enum: ["user", "admin", "editor"]
                },
                age: {
                    bsonType: "int",
                    minimum: 0,
                    maximum: 150
                }
            }
        }
    },
    validationLevel: "moderate",
    validationAction: "error"
});

验证级别选择策略:

级别行为适用场景
strict所有操作都必须通过验证新集合或可以全量迁移的项目
moderate仅对已存在有效文档的更新做验证历史存量数据质量参差不齐
off关闭验证临时数据导入阶段

Schema 变更的最佳实践:字段采用版本化命名(v2_address),配合应用层多版本兼容读取,避免停机迁移。

7. 多文档事务与 ACID

MongoDB 4.0+ 支持副本集事务,4.2+ 支持分片集群事务,但仍有重要限制:

const session = db.getMongo().startSession();
session.startTransaction({
    readConcern: { level: "snapshot" },
    writeConcern: { w: "majority" }
});

try {
    const orders = session.getDatabase("shop").orders;
    const inventory = session.getDatabase("shop").inventory;

    orders.insertOne({ item: "laptop", qty: 1, status: "paid" });
    inventory.updateOne(
        { sku: "laptop" },
        { $inc: { qty: -1 } }
    );

    session.commitTransaction();
} catch (err) {
    session.abortTransaction();
    throw err;
} finally {
    session.endSession();
}

事务使用原则:

  • 事务有 60 秒默认超时,事务内文档修改不超过 1000 条
  • 优先用单文档原子操作(如 $inc、$set)替代多文档事务
  • 事务会显著影响性能(约 30–50% 延迟增加),仅在真正需要时使用

8. 索引策略与查询优化

文档模型设计必须与索引策略协同考虑:

// 复合索引:支持前缀匹配查询
db.orders.createIndex({ status: 1, createdAt: -1 });

// 多键索引(数组字段自动创建)
db.users.createIndex({ "addresses.city": 1 });

// 文本索引(全文搜索前置方案)
db.articles.createIndex({ title: "text", content: "text" });
db.articles.find({ $text: { $search: "mongodb indexing" } });

// 部分索引(仅索引符合条件的文档,节省空间)
db.sessions.createIndex(
    { createdAt: 1 },
    { expireAfterSeconds: 3600, partialFilterExpression: { active: true } }
);

索引设计检查清单:

  • 用 db.collection.explain("executionStats") 验证查询是否命中索引
  • ESR 规则:等值过滤(Equality)→ 排序(Sort)→ 范围(Range)
  • 避免过多索引(写放大),单个集合建议不超过 10–15 个

9. 工作集与内存管理

MongoDB 依赖 WiredTiger 缓存,工作集(频繁访问的数据子集)大小直接影响性能:

// 查看工作集估算(mongosh)
db.serverStatus().wiredTiger.cache
// 比值应 < 80%,超过则需扩容 RAM 或分片

// 查看集合工作集热点
db.collection.stats({ indexDetails: true })

工作集优化策略:

  • 冷热分离:热数据放在 SSD,历史归档数据定期迁移到廉价存储
  • 分片(Sharding):按时间或地域分片,缩小单个节点工作集
  • TTL 索引:自动清理过期数据,防止工作集膨胀
// 30 天后自动删除(适合日志、临时会话)
db.logs.createIndex({ createdAt: 1 }, { expireAfterSeconds: 2592000 });

延伸阅读

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「mongodb」更多文章

  1. 数据生命周期、TTL 与冷热归档
  2. $graphLookup 与层次结构建模
  3. GridFS 与大文件存储实践