MongoDB开发规范与数据建模

MongoDB开发规范

  1. 命名原则

    数据库名使用小写字符,集合名称使用统一命名风格。可以统一大小写或者驼峰命名。数据库名和集合名均不能超过64个字符

  2. 集合设计

    对于少量数据的包含关系使用嵌套模式有利于读写性能和保证原子性的写入。对于复杂的关系,以及后期可能发生演进变化的情况,建议使用引用模式

  3. 文档设计

    避免使用大文档,MongoDB的文档最大不能超过64MB。

    如果使用了内嵌子文档或数组,应该保证内嵌数据不能无限增长。在文档结构上,尽可能减少字段名的长度,MongoDB会保存文档中的字段名,因此整个字段名的长度会影响整个集合的大小和内存的需求。一般建议将字段名控制住32字符以内

  4. 索引设计

    在必要时使用索引加速查询。避免建立过多的索引,单个集合不建议超过10个索引。mongodb对数据的写入很可能会触发索引的写入,从而触发更多的I/O操作。无效的索引会操作内存空间的浪费,需及时清理不需要的索引

    遵循索引优化原则,如覆盖索引、优先前缀匹配等,使用explain()命令分析索引性能

  5. 分片设计

    对可能出现快速增长或读写压力较大的业务表考虑分片。分片建的设计满足均衡分布的目标,业务上尽量避免广播查询。应尽早决定分片策略,建议在集合达到256GB之前进行分片,如果集合中存在唯一索引,则应该确保该索引覆盖分片建,避免冲突。为了降低风险,单个分片集数据量不建议超过2TB

  6. 升级设计

    应用上需支持对旧版本数据的兼容性,在添加唯一性索引约束之前,对数据表进行检查并及时清理冗余的数据。新增/修改数据库对象需经过评审,并保持对数据字典进行更新

  7. 考虑数据老化问题

    要及时清理无效、过期的数据。优先考虑为系统日志、历史数据表添加合理的数据老化策略

  8. 数据一致性方面

    非关键业务使用writeConcern: 1 。 对于关键业务类使用writeConcern: majority 。如果业务上严格不允许脏读,则使用ReadConcern:majority

  9. 使用update、findAndUpdate对数据进行更新时,如果使用过了upset: true,则必须使用唯一性索引避免产生重复数据

  10. 业务上尽量避免短连接,使用官方最新驱动的连接池实现,控制客户端连接的数量,最大不建议超过200

  11. 对大量数据写入使用Bulk Write批量化API,建议使用无序批次更新

  12. 优先使用单文档事务保证原子性,如果需要使用多文档事务,则必须保证事务尽可能小,一个事务的执行时间最长不超过60s

  13. 在条件允许的情况下,使用读写分离降低primary节点的压力。对于一些统计分析类的查询可优先从节点上读取

  14. 考虑业务数据的隔离,例如将配置项数据、历史数据存放在不同的数据库中,微服务之间使用单独的数据库,尽量避免垮库访问

  15. 维护数据字典文档,并保持更新,提前按不同的业务进行数据容量规划



MongoDB数据建模

嵌入式文档 一对一关系模型

嵌入式文档模型

以下映射客户和地址关系的示例。对于这种数据量较小的文档使用嵌入式文档更好

// patron document
{
   _id: "joe",
   name: "Joe Bookreader"
}

// address document
{
   patron_id: "joe", // reference to patron document
   street: "123 Fake Street",
   city: "Faketon",
   state: "MA",
   zip: "12345"
}

如果经常将address数据与name信息一起检索,更好的Realm 数据模型是将address数据嵌入到patron数据中,如以下文档所示:

{
   _id: "joe",
   name: "Joe Bookreader",
   address: {
              street: "123 Fake Street",
              city: "Faketon",
              state: "MA",
              zip: "12345"
            }
}



子集模式

嵌入式文档模型的一个潜在问题是,它可能会导致大型文档包含应用程序不需要的字段。 这些不必要的数据可能会给服务器造成额外负载,并减慢读取操作的速度。相反,可以使用子集模式来检索在单个数据库调用中访问最频繁的数据子集。

考虑一个显示电影信息的应用程序。 movie数据库包含具有以下模式的collection集合:

{
  "_id": 1,
  "title": "The Arrival of a Train",
  "year": 1896,
  "runtime": 1,
  "released": ISODate("01-25-1896"),
  "poster": "http://ia.media-imdb.com/images/M/MV5BMjEyNDk5MDYzOV5BMl5BanBnXkFtZTgwNjIxMTEwMzE@._V1_SX300.jpg",
  "plot": "A group of people are standing in a straight line along the platform of a railway station, waiting for a train, which is seen coming at some distance. When the train stops at the platform, ...",
  "fullplot": "A group of people are standing in a straight line along the platform of a railway station, waiting for a train, which is seen coming at some distance. When the train stops at the platform, the line dissolves. The doors of the railway-cars open, and people on the platform help passengers to get off.",
  "lastupdated": ISODate("2015-08-15T10:06:53"),
  "type": "movie",
  "directors": [ "Auguste Lumière", "Louis Lumière" ],
  "imdb": {
    "rating": 7.3,
    "votes": 5043,
    "id": 12
  },
  "countries": [ "France" ],
  "genres": [ "Documentary", "Short" ],
  "tomatoes": {
    "viewer": {
      "rating": 3.7,
      "numReviews": 59
    },
    "lastUpdated": ISODate("2020-01-09T00:02:53")
  }
}



如果应用程序显示电影简单概述时不需要的多个字段,我们就可以将该collection分割为两个collection,而不是将所有电影数据存储在单个collection中:

  • 电影的基本信息。应用程序默认加载的数据如下:

    // movie collection
    
    {
      "_id": 1,
      "title": "The Arrival of a Train",
      "year": 1896,
      "runtime": 1,
      "released": ISODate("1896-01-25"),
      "type": "movie",
      "directors": [ "Auguste Lumière", "Louis Lumière" ],
      "countries": [ "France" ],
      "genres": [ "Documentary", "Short" ],
    }
    
  • 每部电影的其他不常访问的数据:

    // movie_details collection
    
    {
      "_id": 156,
      "movie_id": 1, // 通过这个字段进行关联
      "poster": "http://ia.media-imdb.com/images/M/MV5BMjEyNDk5MDYzOV5BMl5BanBnXkFtZTgwNjIxMTEwMzE@._V1_SX300.jpg",
      "plot": "A group of people are standing in a straight line along the platform of a railway station, waiting for a train, which is seen coming at some distance. When the train stops at the platform, ...",
      "fullplot": "A group of people are standing in a straight line along the platform of a railway station, waiting for a train, which is seen coming at some distance. When the train stops at the platform, the line dissolves. The doors of the railway-cars open, and people on the platform help passengers to get off.",
      "lastupdated": ISODate("2015-08-15T10:06:53"),
      "imdb": {
        "rating": 7.3,
        "votes": 5043,
        "id": 12
      },
      "tomatoes": {
        "viewer": {
          "rating": 3.7,
          "numReviews": 59
        },
        "lastUpdated": ISODate("2020-01-29T00:02:53")
      }
    }
    



嵌入式文档 一对多关系模型

嵌入式文档模型

客户和多个地址关系的示例如下

// patron document
{
   _id: "joe",
   name: "Joe Bookreader"
}

// address documents
{
   patron_id: "joe", // reference to patron document
   street: "123 Fake Street",
   city: "Faketon",
   state: "MA",
   zip: "12345"
}

{
   patron_id: "joe",
   street: "1 Some Other Street",
   city: "Boston",
   state: "MA",
   zip: "12345"
}



如果经常检索带有name信息的address数据,那么就需要发出多个查询来解析引用。 更优化的模式是将address数据实体嵌入到patron数据中,如以下文档所示:

{
   "_id": "joe",
   "name": "Joe Bookreader",
   "addresses": [
                {
                  "street": "123 Fake Street",
                  "city": "Faketon",
                  "state": "MA",
                  "zip": "12345"
                },
                {
                  "street": "1 Some Other Street",
                  "city": "Boston",
                  "state": "MA",
                  "zip": "12345"
                }
              ]
 }

借助嵌入式数据模型,应用程序可以通过一次查询检索完整的客户信息。



子集模式

嵌入式文档模式的一个潜在问题是,它可能导致文档过大,尤其是在嵌入式字段没有限制的情况下。在这种情况下,您可以使用子集模式仅访问应用程序所需的数据,而不是访问整个嵌入数据集

例如,产品评论列表的电商站点,reviews字段中保存着所有的评论数据:

{
  "_id": 1,
  "name": "Super Widget",
  "description": "This is the most useful item in your toolbox.",
  "price": { "value": NumberDecimal("119.99"), "currency": "USD" },
  "reviews": [
    {
      "review_id": 786,
      "review_author": "Kristina",
      "review_text": "This is indeed an amazing widget.",
      "published_date": ISODate("2019-02-18")
    },
    {
      "review_id": 785,
      "review_author": "Trina",
      "review_text": "Nice product. Slow shipping.",
      "published_date": ISODate("2019-02-17")
    },
    ...
    {
      "review_id": 1,
      "review_author": "Hans",
      "review_text": "Meh, it's okay.",
      "published_date": ISODate("2017-12-06")
    }
  ]
}



评论按时间倒序排列。用户访问产品页面时,应用程序会加载最近十条评论。

您可以将该集合拆分为两个集合,而不存储该产品的所有评论:

  • product collection 存储每个产品的信息,包括该产品的 10 条最新评论:

    {
      "_id": 1,
      "name": "Super Widget",
      "description": "This is the most useful item in your toolbox.",
      "price": { "value": NumberDecimal("119.99"), "currency": "USD" },
      "reviews": [
        {
          "review_id": 786,
          "review_author": "Kristina",
          "review_text": "This is indeed an amazing widget.",
          "published_date": ISODate("2019-02-18")
        }
        ...
        {
          "review_id": 777,
          "review_author": "Pablo",
          "review_text": "Amazing!",
          "published_date": ISODate("2019-02-16")
        }
      ]
    }
    
  • review collection 存储所有评论。每条评论都包含对相应产品的引用。

    {
      "review_id": 786,
      "product_id": 1, // 通过该字段进行关联
      "review_author": "Kristina",
      "review_text": "This is indeed an amazing widget.",
      "published_date": ISODate("2019-02-18")
    }
    {
      "review_id": 785,
      "product_id": 1,
      "review_author": "Trina",
      "review_text": "Nice product. Slow shipping.",
      "published_date": ISODate("2019-02-17")
    }
    ...
    {
      "review_id": 1,
      "product_id": 1,
      "review_author": "Hans",
      "review_text": "Meh, it's okay.",
      "published_date": ISODate("2017-12-06")
    }
    



文档引用 一对多关系模型

以下示例展示如何映射出版商和图书关系。该示例说明在避免出版商信息冗余方面,引用比嵌入更有优势。

将出版商文档嵌入图书文档会导致出版商数据重复,如以下文档所示:

// 各个文档中都保存publisher 出版商信息,造成了出版商信息冗余
{
   title: "MongoDB: The Definitive Guide",
   author: [ "Kristina Chodorow", "Mike Dirolf" ],
   published_date: ISODate("2010-09-24"),
   pages: 216,
   language: "English",
   publisher: {
              name: "O'Reilly Media",
              founded: 1980,
              location: "CA"
            }
}

{
   title: "50 Tips and Tricks for MongoDB Developer",
   author: "Kristina Chodorow",
   published_date: ISODate("2011-05-06"),
   pages: 68,
   language: "English",
   publisher: {
              name: "O'Reilly Media",
              founded: 1980,
              location: "CA"
            }
}



使用引用并将出版商信息保存在图书集合之外的单独集合中。

使用引用时,关系的增长将决定引用的存储方式。

如果每个出版商的图书数量较少且增长有限,则将图书引用存储在出版商文档中有时可能十分有用。相反,当每个出版商的图书数量没有限制时,此数据模型将导致可变且不断增长的数组,如以下示例所示:

// 出版商信息
// 如果出版商的图书数量没有限制时,那么下面的books数组将会非常大
{
   name: "O'Reilly Media",
   founded: 1980,
   location: "CA",
   books: [123456789, 234567890, ...]
}

// 图书信息
{
    _id: 123456789,
    title: "MongoDB: The Definitive Guide",
    author: [ "Kristina Chodorow", "Mike Dirolf" ],
    published_date: ISODate("2010-09-24"),
    pages: 216,
    language: "English"
}

{
   _id: 234567890,
   title: "50 Tips and Tricks for MongoDB Developer",
   author: "Kristina Chodorow",
   published_date: ISODate("2011-05-06"),
   pages: 68,
   language: "English"
}



为避免出现可变且不断增长的数组,请将出版商的引用存储在图书文档中:

// 出版商信息
{
   _id: "oreilly",
   name: "O'Reilly Media",
   founded: 1980,
   location: "CA"
}


// 图书信息
{
   _id: 123456789,
   title: "MongoDB: The Definitive Guide",
   author: [ "Kristina Chodorow", "Mike Dirolf" ],
   published_date: ISODate("2010-09-24"),
   pages: 216,
   language: "English",
   publisher_id: "oreilly"  // 将出版商的引用存储在图书文档中
}

{
   _id: 234567890,
   title: "50 Tips and Tricks for MongoDB Developer",
   author: "Kristina Chodorow",
   published_date: ISODate("2011-05-06"),
   pages: 68,
   language: "English",
   publisher_id: "oreilly"    // 将出版商的引用存储在图书文档中
}



物联网时序数据建模

需求

美国州际公路的流量统计。数据库需要提供的能力:

  • 存储事件数据

  • 提供分析查询能力

  • 理想的平衡点:

    • 内存使用
    • 写入性能
    • 读取分析性能
  • 可以部署在常见的硬件平台上



每个事件用一个独立的文档存储

{
    segId: "I80_mile23",
    speed: 63,
    ts: ISODate("2013-10-16T22:07:38.000-0500")
}
  • 非常“传统”的设计思路,每个事件都会写入一条同样的信息。多少的信息,就有多少条数据,数据量增长非常快。

  • 数据采集操作全部是Insert语句;



每分钟的信息用一个独立的文档存储(存储平均值)

{
    segId: "I80_mile23",
    speed_num: 18,
    speed_sum: 1134,
    ts: ISODate("2013-10-16T22:07:00.000-0500")
}
  • 对每分钟的平均速度计算非常友好(speed_sum/speed_num);
  • 数据采集操作基本是Update语句;
  • 数据精度降为一分钟;



每分钟的信息用一个独立的文档存储(秒级记录)

{
    segId: "I80_mile23",
    speed: {0:63, 1:58, ... , 58:66, 59:64},
    ts: ISODate("2013-10-16T22:07:00.000-0500")
}
  • 每秒的数据都存储在一个文档中;
  • 数据采集操作基本是Update语句;



每小时的信息用一个独立的文档存储(秒级记录)

{
    segId: "I80_mile23",
    speed: {0:63, 1:58, ... , 3598:54, 3599:55},
    ts: ISODate("2013-10-16T22:00:00.000-0500")
}

相比上面的方案更进一步,从分钟到小时:

  • 每小时的数据都存储在一个文档中;
  • 数据采集操作基本是Update语句;
  • 更新最后一个时间点(第3599秒),需要3599次迭代(虽然是在同一个文档中)



进一步优化

{
    segId: "I80_mile23",
    speed: {
        0:  {0:47, ..., 59:45},
        ...,
        59: {0:65, ... , 59:56}
    }
    ts: ISODate("2013-10-16T22:00:00.000-0500")
}
  • 用了嵌套的手法把秒级别的数据存储在小时数据里;
  • 数据采集操作基本是Update语句;
  • 更新最后一个时间点(第3599秒),需要59+59次迭代;

嵌套结构正是MongoDB的魅力所在,稍动脑筋把一维拆成二维,大幅度减少了迭代次数;



每个事件用一个独立的文档存储VS每分钟的信息用一个独立的文档存储

从写入上看:后者每次修改的数据量要小很多,并且在WiredTiger引擎下,同一个文档的修改一定时间窗口下是可以在内存中合并的;

从读取上看:查询一个小时的数据,前者需要返回3600个文档,而后者只需要返回60个文档,效率上的差异显而易见;

从索引上看:同样,因为稳定数量的大幅度减少,索引尺寸也是同比例降低的,并且segId,ts这样的冗余数据也会减少冗余。容量的降低意味着内存命中率的上升,也就是性能的提高;



每小时的信息用一个独立的文档存储VS每分钟的信息用一个独立的文档存储

从写入上看:因为WiredTiger是每分钟进行一次刷盘,所以每小时一个文档的方案,在这一个小时内要被反复的load到PageCache中,再刷盘;所以,综合来看后者相对更合理;

从读取上看:前者的数据信息量较大,正常的业务请求未必需要这么多的数据,有很大一部分是浪费的;

从索引上看:前者的索引更小,内存利用率更高;

总结

那么到底选择哪个方案更合理呢?从理论分析上可以看出,不管是小时存储,还是分钟存储,都是利用了MongoDB的信息聚合的能力。

  • 每小时的信息用一个独立的文档存储:设计上较极端,优势劣势都很明显;
  • 每分钟的信息用一个独立的文档存储:设计上较平衡,不会与业务期望偏差较大;

落实到现实的业务上,哪种是最优的?最好的解决方案就是根据自己的业务情况进行性能测试,以上的分析只是“理论”基础,给出“实践”的方向,但千万不可以此论断。

  • 14
    点赞
  • 26
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值