Rennen

Rennen

十一种常用的 MongoDB 应用程序设计模式

2025-08-24

最近在工作中接触到了 MongoDB 数据库。作为 NoSQL 数据库,MongoDB 在使用上和平常用得比较多的 MySQL 等数据库存在着比较大的差别,因此我觉得不能直接沿用传统关系型数据库的设计思维,有必要学习一下 MongoDB 的设计模式。

在《MongoDB 权威指南》的第九章中介绍了 MongoDB 中常用的设计模式。然而在原文中,每个设计模式只用了简单的一段话概括,没有给出具体的使用示例。在这篇文章中我将先引用书中原文介绍设计模式,然后再给出具体详细的使用示例。这篇文章借助 ChatGPT 的帮助来完成。

多态模式

这种模式适用于集合中的所有文档具有类似但不完全相同结构的情况。它涉及识别跨文档的公共字段,而这些文档需要支持应用程序的公共查询。跟踪文档或子文档中的特定字段将有助于识别数据与不同代码路径或类/子类之间的差异,我们可以在应用程序中编码以管理二者的差异。这允许在文档不完全相同的单个集合中使用简单查询来提高查询性能。

我们构建一个通知系统,有三种类型的通知,最主要的区分方式是看它们的 type 字段:

  1. Email 通知
  2. SMS 通知
  3. App 推送通知
{
  "_id": ObjectId("..."),
  "type": "email",
  "to": "[email protected]",
  "subject": "Welcome!",
  "body": "Thank you for registering.",
  "createdAt": ISODate("2025-08-01T12:00:00Z")
}

{
  "_id": ObjectId("..."),
  "type": "sms",
  "phoneNumber": "+1234567890",
  "message": "Your code is 123456",
  "createdAt": ISODate("2025-08-01T12:01:00Z")
}

{
  "_id": ObjectId("..."),
  "type": "push",
  "deviceId": "abc123",
  "title": "New Message",
  "content": "You have a new message in your inbox.",
  "createdAt": ISODate("2025-08-01T12:02:00Z")
}

我们可以使用如下简单查询获取所有通知的公共信息:


db.notifications.find(
  { createdAt: { $gte: ISODate("2025-08-01T00:00:00Z") } },
  { type: 1, createdAt: 1 }
)

我们也可以通过 type 字段对文档进行分类处理:


// 查询所有 email 通知
db.notifications.find({ type: "email" })

// 查询所有 push 通知的内容字段
db.notifications.find(
  { type: "push" },
  { content: 1, deviceId: 1 }
)

属性模式

这种模式非常适合于文档中部分字段具有希望对其进行排序或查询的公共特性,或者需要排序的字段仅存在于部分文档中,或者这两个条件都满足。它包括将数据重塑为键–值对数组,并在该数组中的元素上创建索引。限定符可以作为附加字段添加到这些键–值对中。此模式有助于查询那些存在许多相似字段的文档,因此需要的索引更少,查询也更容易编写。

传统文档结构如下(字段静态):

{
  "_id": 1,
  "height": 180,
  "weight": 75,
  "blood_pressure": "120/80"
}

应用属性模式后改为:

{
  "_id": 1,
  "attributes": [
    { "name": "height", "value": 180, "unit": "cm" },
    { "name": "weight", "value": 75, "unit": "kg" },
    { "name": "blood_pressure", "value": "120/80", "unit": "mmHg" }
  ]
}

这样我们可以只对 attributes.nameattributes.value 建一个复合索引,而不用为 heightweightblood_pressure 等字段分别建索引。

举一个具体的例子:健康档案系统

在一个健康档案系统中,不同用户的体检项目不同,例如:

  • 有的人测了 BMI,有的人没测;
  • 有的人做了肝功能检查,有的人没做。

使用静态字段存储如下:

{
  "_id": 1001,
  "bmi": 22.3,
  "liver_function": "正常"
}

但如果你需要支持查询如:

  • 查找 BMI > 25 的人;
  • 查找 liver_function = "异常" 的人;
  • 查找某项指标排序(如血糖值从高到低);

那就很麻烦,索引很多,查询语句冗长。

使用属性模式结构:

{
  "_id": 1001,
  "attributes": [
    { "name": "bmi", "value": 22.3 },
    { "name": "liver_function", "value": "正常" }
  ]
}

另一个用户:

{
  "_id": 1002,
  "attributes": [
    { "name": "blood_sugar", "value": 6.3 },
    { "name": "bmi", "value": 26.1 }
  ]
}

🔍 查询与索引示例

  1. 建立复合索引优化查询:
    db.records.createIndex({ "attributes.name": 1, "attributes.value": 1 })
    
    
  2. 查询 BMI > 25 的用户:
    db.records.find({
      "attributes": {
        $elemMatch: {
          name: "bmi",
          value: { $gt: 25 }
        }
      }
    })
    
    
  3. 查询肝功能为“异常”的用户:
    db.records.find({
      "attributes": {
        $elemMatch: {
          name: "liver_function",
          value: "异常"
        }
      }
    })
    
    

这样就可以支持大多数字段的查询和排序了,无需对每个字段单独建索引。

分桶模式

这种模式适用于时间序列数据,其中数据在一段时间内被捕获为数据流。在 MongoDB 中,将这些数据“分桶”存储到一组文档中,每个文档会保存特定时间范围内的数据,这比在每个时间点/数据点创建一个文档更高效。例如,可以使用一小时的存储桶,并将该时间内的所有数据都放到文档的一个数组中。文档本身有开始和结束时间,以表明这个“桶”涵盖的时间段。

在 MongoDB 中,假如遇到了写入量比较大的情况(比如说传感器时序数据),这个时候可能存在三种方案:

  • 所有数据都存入一个文档的内嵌数组当中
    不推荐,由于 MongoDB 有单个文档不能超过 16 MB 的限制,当插入的数据越来越多时,会超出文档大小限制
  • 每条数据单独作为一个文档存储
    可以考虑,代价是整个集合中的索引可能会变得越来越大,查询速度变慢
  • 将一定范围内(例如一小时)的数据存入一个文档的内嵌数组当中
    可以考虑,代价是查询可能变得麻烦一些

MongoDB 5+ 版本引入了原生时序集合,可以像这样创建一个时序集合:

db.createCollection("sensor_data", {
  timeseries: {
    timeField: "timestamp",      // 必选:时间戳字段
    metaField: "sensorMetadata", // 可选:元数据(如设备ID)
    granularity: "minutes",      // 分桶粒度(seconds/minutes/hours)
    bucketMaxSpanSeconds: 300,   // 自定义桶最大时间跨度(秒)
    bucketRoundingSeconds: 300   // 自定义桶对齐时间
  },
  expireAfterSeconds: 2592000    // 自动过期数据(30天)
});

异常值模式

这种模式用以解决少数文档的查询超出应用程序正常模式的情况。这是一种高级设计模式,当流行程度作为一个因素时尤其适用。这一点可以在有影响力的社交网络、图书销售、电影评论等地方看到。它使用一个标志来表示文档是异常值,并将额外的溢出存储到一个或多个文档中,这些文档通过 "_id" 引用第一个文档。应用程序代码将使用该标志进行额外查询,以检索溢出的文档。

异常值模式我认为仍然是为了避免单个文档超过 16 MB 的限制而设计的。直接看使用示例:

假设一个社交网络用户文档,普通用户的帖子数量有限,帖子存储在数组字段中。但某些大V用户的帖子极多,直接把所有帖子都存进主文档数组会导致文档膨胀:

{
  "_id": "user123",
  "name": "Alice",
  "isOutlier": true,      // 标记为异常用户
  "basicInfo": {...},
  "postsCount": 10000     // 帖子数量非常大
}

对应的溢出帖子文档可能是:

{
  "_id": "posts_user123_1",
  "userId": "user123",      // 关联主文档
  "posts": [ ... ]          // 存储部分帖子
}
{
  "_id": "posts_user123_2",
  "userId": "user123",
  "posts": [ ... ]          // 存储另一部分帖子
}

应用查询时:

  • 如果 isOutlier == false,直接从主文档读取全部帖子。
  • 如果 isOutlier == true,先读取主文档,再额外查询 posts_user123_* 文档集合,拼接完整帖子数据。

计算模式

这种模式可以在需要频繁计算数据时使用,也可以在读取密集型的数据访问模式下使用。此模式建议在后台执行计算,并定期更新主文档。这提供了计算字段或文档的有效近似值,而不必为单个查询连续生成这些字段或文档。这样可以通过避免重复相同的计算来显著减少 CPU 的压力,特别是在读操作会触发计算并且读写比较高的情况下。

这种模式我觉得就不单单是数据建模模式了,应该给归到整个应用系统设计的范畴中。想法很美好,实战中比较难打出来。

计算模式可以避免每次查询的时候都要使用聚合来计算结果,减少 CPU 压力。例如用户经常想要查询自己所有订单的总金额,我们可以在每次插入或者更新订单的时候,就把总金额计算出来并写入到用户文档 users 的字段里:

{
  "_id": 123,
  "name": "Alice",
  "totalSpent": 5200  // 预计算字段
}

如果数据更新频率高,且对聚合结果的实时性要求低,比如社交媒体的评论、点赞数量,这个时候没必要每次写入都实时更新,可以用定时任务每隔一段时间批量计算,并更新到文档里。

一定要用后台任务吗?其实也不是的,像订单总金额的计算可以直接使用累加操作 $inc,更复杂的逻辑就需要使用后台任务了。

子集模式

当工作集超过了机器的可用 RAM 时可以使用这种模式。这种情况可能是大文档造成的,这些文档包含大量的应用程序没有使用的信息。此模式建议将经常使用的数据和不经常使用的数据分割为两个单独的集合。一个典型的例子可能是电子商务应用程序中将一个产品的 10 条最近的评论保存在“主”(经常访问的)集合中,并将所有旧的评论移动到第二个集合中,只有在应用程序需要多于 10 条评论时才进行查询。

什么情况下适合使用子集模式?

  • 单个文档里存在大量低命中率字段/子数组(如历史评论、日志、状态变更历史)。
  • 应用绝大多数读取只需要最近 N 条(如最近 10 条评论、最近 20 次状态变更)。
  • 工作集(活跃数据总和)超过了可用内存,频繁触发页换入换出。

以「商品 & 评论」为例:

主集合:products

{
  "_id": "prod_123",
  "name": "键盘",
  "price": 399,
  "ratingCount": 10234,          // 汇总:总评论数
  "ratingAvg": 4.7,              // 汇总:平均评分
  "recentReviews": [             // 只保留最近 N 条,例:10
    {
      "reviewId": "r_998877",
      "userId": "u_1",
      "stars": 5,
      "excerpt": "手感很好…",
      "createdAt": "2025-08-21T10:01:22Z"
    },
    ...
  ],
  "updatedAt": "2025-08-21T10:01:22Z"
}

从集合:product_reviews

{
  "_id": "r_998877",
  "productId": "prod_123",
  "userId": "u_1",
  "stars": 5,
  "content": "手感很好,做工出色……",
  "createdAt": "2025-08-21T10:01:22Z"
}

写入新评论的时候同时进行:

  1. 写全量到 product_reviews
  2. 更新主文档的 recentReviews 字段,维护汇总字段(计数、平均分)

MongoDB 可以使用 $push, $sort, $slice 操作来维护有限长度的数组:

db.products.updateOne(
  { _id: "prod_123" },
  {
    $inc: { ratingCount: 1},
    $set: { updatedAt: new Date() },
    $push: {
      recentReviews: {
        $each: [{
          reviewId: "r_998877",
          userId: "u_1",
          stars: 5,
          excerpt: "手感很好…",
          createdAt: ISODate("2025-08-21T10:01:22Z")
        }],
        $sort: { createdAt: -1 },
        $slice: 10
      }
    }
  }
)

扩展引用模式

这种模式用于有许多不同的逻辑实体或“事物”,并且每个逻辑实体或“事物”都有各自的集合,但是你希望将这些实体组织在一起以实现特定的功能。在一个典型的电子商务模式中,订单、客户和库存可能会有单独的集合。当需要从这些单独的集合中收集单个订单的所有信息时,可能会对性能产生负面影响。解决方案是识别出经常访问的字段,并在订单文档中复制这些字段。对于电子商务订单,这样的字段可能是接收商品的客户姓名和地址。这种模式以数据冗余为代价,以减少将信息整合在一起所需的查询数量。

这种设计模式,个人理解就是「反范式化」+「增加冗余字段」来避免联表或者聚合查询。比如说可以把客户姓名、收货地址等信息直接放到订单文档里,这样只需要一次读操作就可以拿到全部信息

适用场景:

  1. 读多写少,在意读取性能
  2. 跨集合字段相对稳定,比如客户姓名 、收货地址、商品标题这些字段修改很少,甚至干脆不允许修改
  3. 有「快照」的业务需求,保留当时的数据

其中第二点我认为是比较需要考虑的,如果字段变化频繁,而且业务上需要强一致性,那这种设计模式反而会带来麻烦。

原始集合:

// customers
{
  _id: ObjectId("..."),
  name: "张三",
  defaultAddress: { city: "上海", street: "世纪大道 1 号", ... },
  // ...
}

// products
{
  _id: ObjectId("..."),
  sku: "SKU-123",
  title: "无线蓝牙耳机",
  price: 29900, // 分为单位,避免浮点
  // ...
}

订单集合:

// orders
{
  _id: ObjectId("..."),
  customerId: ObjectId("..."),      // 仍保留“引用”
  customerSnapshot: {               // 扩展引用/快照(常用字段)
    name: "张三",
    address: { city: "上海", street: "世纪大道 1 号" }
  },
  items: [
    {
      productId: ObjectId("..."),
      productSnapshot: {            // 快照,保留下单时信息
        sku: "SKU-123",
        title: "无线蓝牙耳机",
        priceAtOrder: 25900
      },
      qty: 2
    }
  ],
  status: "PAID",
  createdAt: ISODate("2025-08-01T10:00:00Z")
}

上面的字段设计中,一定要保留对原始数据 ID(customerIdproductId)的引用,方便必要的时候回溯查询。

如果有一致性需求该如何解决呢?拿上面的例子,如果客户的名字改变了,那可以考虑使用 Spring 事件机制来改变订单中的客户名字,或者是 MongoDB Change Streams 来监听客户集合变化,哪一种都很麻烦,所以如果有强一致性要求还是少用扩展引用模式吧。

近似值模式

这种模式在需要昂贵资源(时间、内存、CPU 周期)的计算却不需要绝对精确的情况下非常有用。这方面的一个例子是一张图片或一则帖子的“点赞”计数器或一个页面浏览的计数器,其中知道确切的计数(例如,是 999 535 还是 10 000 000)是没必要的。在这种情况下,应用此模式可以极大地减少写入次数,例如,仅在每浏览 100次或更多次时更新计数器,而不是在每次浏览之后都进行更新。

简单介绍三种取近似值的方法:

  1. 在客户端中先做内存计数,使用 AtomicInteger,达到一定阈值后再批量 $inc 到数据库
  2. 采用随机抽样 + 放大的方法,当点赞量到成千上万之后,转换策略,不是每次都写,而是按概率 p 写一次,例如 p 设置为 0.01,那么一旦命中,点赞量直接一次 + 100,这种方法在点赞量比较大的时候可以认为能够得到相对准确的结果。
  3. 分片计数:将一个计数拆成多个子计数文档,更新时随机选择一个桶 $inc,读取时再求和,这样可以显著降低单点写热点

树形模式

当你有很多查询并且数据主要是层次结构时,可以使用这种模式。它遵循前面提到过的,将经常一起查询的数据存储在一起的概念。在 MongoDB 中,可以很容易地将层次结构存储在同一个文档的数组中。在电子商务网站的例子,特别是其产品目录中,通常会有属于多个类别的产品或者这个产品的类别从属于其他某个类别。一个例子就是“硬盘驱动器”,它本身是一个类别,但还属于“存储”类别,“存储”本身又属于“计算机部件”类别,而这还是“电子”类别的一部分。在这种情况下,我们会有一个字段跟踪整个层次结构,另一个字段保存直接类别(“硬盘驱动器”)。保存在数组中的整个层次结构字段提供了对这些值使用多键索引的能力。这可以确保很容易地找到层次结构中与类别相关的所有项目。直接类别字段允许找到与此类别直接相关的所有项目。

比较容易理解,定义一个这样的集文档:

{
  "name": "三星 1TB SSD",
  "directCategory": "固态硬盘",
  "categoryPath": ["电子", "计算机部件", "存储", "固态硬盘"]
}

可以查询「计算机部件」下的所有产品:

db.products.find({ categoryPath: "计算机部件" })

也可以只查找「固态硬盘」这个直接类别:

db.products.find({ directCategory: "固态硬盘" })

唯一需要注意的就是使用索引。

预分配模式

这主要用于 MMAP 存储引擎,但仍然有一些可以使用此模式的场景。该模式建议创建一个初始的空结构,稍后对该结构进行填充。例如,一个按天管理资源的预订系统可以跟踪该资源是空闲的还是已预订/不可用的。资源(x)和天数(y)的二维结构使得检查是否可用以及执行计算变得非常简单。

先来看预分配模式解决了什么问题。

无预分配模式下,需要实时去查询房间是否有预定,逻辑是查找是否存在冲突记录,例如要预订一个房间,必须 find 出该房间在目标时间段是否有重叠,然后再决定是否需要插入。如果有并发用户同时查到空闲,可能会造成重复预订。

有预分配模式,查询变得简单,例如将房间预分配,每个房间每天一条文档,需要查询的时候直接定位 (resourceId, date) 文档,看状态字段即可。修改预定状态时,可以使用原子性的 findOneAndUpdate 来更新。如果需要更细粒度的预定(例如会议室预定),可以考虑使用位图。

预分配文档的新增可以考虑使用定时任务。

文档版本控制模式

这种模式提供了一种机制来保留文档的旧版本。它需要在每个文档中添加一个额外的字段来跟踪“主”集合中的文档版本,还需要一个额外的集合来保存文档的所有修订版本。此模式具备以下假设:具体来说,每个文档都有有限数量的修订版本,不存在大量需要版本控制的文档,并且查询主要是对每个文档的当前版本进行的。假如这些假设不成立,那么可能需要修改模式或考虑使用不同的设计模式。

给出以下设计:

// collection: articles,主集合
{
  "_id": ObjectId("..."),
  "title": "MongoDB 版本控制模式",
  "content": "当前内容...",
  "authorId": "u_123",
  "version": 7,               // 当前版本号(单调递增)
  "updatedAt": ISODate("2025-08-20T10:00:00Z"),
  "updatedBy": "u_456"        // 最近修改者
}
// collection: article_revisions,历史集合(所有旧版本)
{
  "_id": ObjectId("..."),
  "docId": ObjectId("..."),   // 指向主集合 _id
  "version": 6,               // 该历史文档的版本号
  "snapshot": {               // 或直接把旧文档平铺到根部
    "title": "MongoDB 版本控制模式",
    "content": "旧内容...",
    "authorId": "u_123",
    "updatedAt": ISODate("2025-08-19T09:00:00Z"),
    "updatedBy": "u_321"
  },
  "createdAt": ISODate("2025-08-19T09:00:00Z")
}

主集合只保留最新文档,历史集合保存每次更新前的完整快照。

更新主集合时建议使用乐观锁,附带条件:where _id = ? and version = v(使用 findOneAndUpdate),失败时客户端需要重试。

End

总体而言还是需要考虑各种业务场景来选择设计模式,例如上面的扩展引用模式需要仔细考虑这种反范式化设计带来的数据不一致能不能接受。选择合适的数据模式是最重要的。

参考:《MongoDB 权威指南 第三版》