还在发愁怎么设计mongodb吗?那就直接点进来吧

MongdDB设计参考(Part1)

Part 1

原文:6 Rules of Thumb for MongoDB Schema Design: Part 1

By William Zola, Lead Technical Support Engineer at MongoDB

“我有丰富的sql使用经验,但是我是个MongoDB的初学者。我应该如何在MongoDB中针对一对多关系进行建模?”这是我被问及最多的问题之一。

我没法简单的给出答案,因为这有很多方案去实现。接下来我会教导你如何针对一对多进行建模。

这个话题有很多内容需要讨论,我会用三个部分进行说明。在第一部分,我会讨论针对一对多关系建模的三种基础方案。在第二部分我将会覆盖更多高级内容,包括反范式化和双向引用。在最后一部分,我将会回顾各种选择,并给出做决定时需要考虑的因素。

很多初学者认为在MongoDB中针对一对多建模唯一的方案就是在父文档中内嵌一个数组子文档,但是这是不准确的。因为你可以在MongoDB内嵌一个文档不代表你就必须这么做。

当你设计一个MongoDB数据库结构,你需要先问自己一个在使用关系型数据库时不会考虑的问题:这个关系中集合的大小是什么样的规模?你需要意识到一对很少,一对许多,一对非常多,这些细微的区别。不同的情况下你的建模也将不同。

Basics: Modeling One-to-Few

一对很少

针对个人需要保存多个地址进行建模的场景下使用内嵌文档是很合适,可以在person文档中嵌入addresses数组文档:

collection:person
{
    name: "tom",
    ssn: "11224-154",
    addresses : [
        {street: "majiagou", city: "cd", "cc": "china"},
        {street: "jianglingjiangdonglu", city: "qd", "cc": "china"}
    ]
}

这种设计具有内嵌文档设计中所有的优缺点。最主要的优点就是不需要单独执行一条语句去获取内嵌的内容。最主要的缺点是你无法把这些内嵌文档当做单独的实体去访问。

例如,如果你是在对一个任务跟踪系统进行建模,每个用户将会被分配若干个任务。内嵌这些任务到用户文档在遇到“查询昨天所有的任务”这样的问题时将会非常困难。我会在下一篇文章针对这个用例提供一些适当的设计。

Basics: One-to-Many

一对许多

以产品零件订货系统为例。每个商品有数百个可替换的零件,但是不会超过数千个。这个用例很适合使用间接引用—将零件的objectid作为数组存放在商品文档中(在这个例子中的ObjectID我使用更加易读的2字节,现实世界中他们可能是由12个字节组成的)。

每个零件都将有他们自己的文档对象

collection:parts
{
    _id: ObjectID('asdfasdf'),
    partno: "121-asd-45d",
    name: "#43 groment",
    qty: 94,
    cost: 0.94,
    price: 3.99
}

每个产品的文档对象中parts数组中将会存放多个零件的ObjectID :

collection:products
{
    name: "product1",
    describe: "this is a product",
    number: 100,
    parts: [
        ObjectID('asdfasdf'),
        ObjectID('778745'),
        ObjectID('124s1d5f')
    ]
}

在获取特定产品中所有零件,需要一个应用层级别的join

为了能快速的执行查询,必须确保products.catalog_number有索引。当然由于零件中parts._id一定是有索引的,所以这也会很高效。

这种引用的方式是对内嵌优缺点的补充。每个零件是个单独的文档,可以很容易的独立去搜索和更新他们。需要一条单独的语句去获取零件的具体内容是使用这种建模方式需要考虑的一个问题(请仔细思考这个问题,在第二章反反范式化中,我们还会讨论这个问题)

这种建模方式中的零件部分可以被多个产品使用,所以在多对多时不需要一张单独的连接表。

Basics: One-to-Squillions

一对非常多

我们用一个收集各种机器日志的例子来讨论一对非常多的问题。由于每个mongodb的文档有16M的大小限制,所以即使你是存储ObjectID也是不够的。我们可以使用很经典的处理方法“父级引用”—用一个文档存储主机,在每个日志文档中保存这个主机的ObjectID。

collection:host
{
    _id: ObjectID('12312'),
    name: 'xxx.com',
    ipaddr: 'xxx.xxx.xxx'
}

collection:logmsg
{
    time: "2021-09-01 15:00:00",
    message: 'The application is run.',
    host_id:  ObjectID('12312')
}

以下是个和第二中方案稍微不同的应用级别的join用来查找一台主机最近5000条的日志信息

host = db.host.findOne({ipaddr:'xxx.xxx.xxx'});
last_5k_logmsg = db.logmsg.find({host_id:host._id}).sort({time: -1}).limit(5000).toArray();

所以,即使这种简单的讨论也有能察觉出mongobd的建模和关系模型建模的不同之处。你必须要注意一下两个因素:

Will the entities on the “N” side of the One-to-N ever need to stand alone?

一对多中的多是否需要一个单独的实体。

What is the cardinality of the relationship: is it one-to-few; one-to-many; or one-to-squillions?

这个关系中集合的规模是一对很少,很多,还是非常多。

Based on these factors, you can pick one of the three basic One-to-N schema designs:

基于以上因素来决定采取一下三种建模的方式

一对很少且不需要单独访问内嵌内容的情况下可以使用内嵌多的一方。

一对多且多的一端内容因为各种理由需要单独存在的情况下可以通过数组的方式引用多的一方的。

一对非常多的情况下,请将一的那端引用嵌入进多的一端对象中。

Part 2

原文:6 Rules of Thumb for MongoDB Schema Design: Part 2

By William Zola, Lead Technical Support Engineer at MongoDB

在上一篇文章中我介绍了三种基本的设计方案:内嵌,子引用,父引用,同时说明了在选择方案时需要考虑的两个关键因素。

一对多中的多是否需要一个单独的实体。

这个关系中集合的规模是一对很少,很多,还是非常多。

在掌握了以上基础技术后,我将会介绍更为高级的主题:双向关联和反范式化。

双向关联

如果你想让你的设计更酷,你可以让引用的“one”端和“many”端同时保存对方的引用。

以上一篇文章讨论过的任务跟踪系统为例。有person和task两个集合,one-to-n的关系是从person端到task端。在需要获取person所有的task这个场景下需要在person这个对象中保存有task的id数组,如下面代码所示。

db.person.findOne()
{
    _id: ObjectID('12132'),
    name: "tom",
    tasks: [
        ObjectID('123131'),
        ObjectID('12132'),
        ObjectID('12132')
    ]
}

在某些场景中这个应用需要显示任务的列表(例如显示一个多人协作项目中所有的任务),为了能够快速的获取某个用户负责的项目可以在task对象中嵌入附加的person引用关系。

db.task.findOne()
{
    _id: ObjectID('112233'),
    description: "Complete tody word plan.",
    due_date: ISODate("2017-02-01"),
    owner: ObjectID('12132')
}

这个方案具有所有的一对多方案的优缺点,但是通过添加附加的引用关系。在task文档对象中添加额外的“owner”引用可以很快的找到某个task的所有者,但是如果想将一个task分配给其他person就需要更新引用中的person和task这两个对象(熟悉关系数据库的童鞋会发现这样就没法保证操作的原子性。当然,这对任务跟踪系统来说并没有什么问题,但是你必须考虑你的用例是否能够容忍)

在一对多关系中应用反范式

在你的设计中加入反范式,可以使你避免应用层级别的join读取,当然,代价是这也会让你在更新是需要操作更多数据。下面我会举个例子来进行说明

反范式Many -< One

以产品和零件为例,你可以在parts数组中冗余存储零件的名字。以下是没有加入反范式设计的结构。

db.product.findOne()
{
    name: "product1",
    describe: "this is a product",
    number: 100,
    parts: [
        ObjectID('asdfasdf'),
        ObjectID('778745'),
        ObjectID('124s1d5f')
    ]
}

反范式化意味着你不需要执行一个应用层级别的join去显示一个产品所有的零件名字,当然如果你同时还需要其他零件信息那这个应用层的join是避免不了的。

db.product.findOne()
{
    name: "product1",
    describe: "this is a product",
    number: 100,
    parts: [
        {ObjectID('asdfasdf'), name: 'part1', ...},
        {ObjectID('778745'), name: 'part2', ...},
        {ObjectID('124s1d5f'), name: 'part3', ...}
    ]
}

在使得获取零件名字简单的同时,执行一个应用层级别的join会和之前的代码有些区别,具体如下:

// 获取指定的商品
product = db.product.findOne({cata_log: 123});
// 获取商品对应零件id
part_ids = product.parts.map(function(doc){return doc.id});
// 获取零件详细信息
product_part_details = db.part.find({_id: {$in: part_ids}}).toArray();

反范式化在节省你读的代价的同时会带来更新的代价:如果你将零件的名字冗余到产品的文档对象中,那么你想更改某个零件的名字你就必须同时更新所有包含这个零件的产品对象。

在一个读比写频率高的多的系统里,反范式是有使用的意义的。如果你很经常的需要高效的读取冗余的数据,但是几乎不去变更他的话,那么付出更新上的代价还是值得的。更新的频率越高,这种设计方案的带来的好处越少。

例如:假设零件的名字变化的频率很低,但是零件的库存变化很频繁,那么你可以冗余零件的名字到产品对象中,但是别冗余零件的库存。

需要注意的是,一旦你冗余了一个字段,那么对于这个字段的更新将不在是原子的。和上面双向引用的例子一样,如果你在零件对象中更新了零件的名字,那么更新产品对象中保存的名字字段前将会存在短时间的不一致。

反范式One -< Many

你也可以冗余one端的数据到many端:

db.parts.findOne();
{
    _id: ObjectID('asdfasdf'),
    partno: "121-asd-45d",
    name: "#43 groment",
    qty: 94,
    cost: 0.94,
    price: 3.99.
    // product
     describe: "this is a product",
    number: 100
}

如果你冗余产品的名字到零件表中,那么一旦更新产品的名字就必须更新所有和这个产品有关的零件,这比起只更新一个产品对象来说代价明显更大。这种情况下,更应该慎重的考虑读写频率。

在一对很多的关系中应用反范式

在日志系统这个一对许多的例子中也可以应用反范式化的技术。你可以将one端(主机对象)冗余到日志对象中,或者反之。

下面的例子将主机中的IP地址冗余到日志对象中。

db.logmsg.findOne();
{
    time: "2021-09-01 15:00:00",
    message: 'The application is run.',
    host_id:  ObjectID('12312'),
    // host
   	name: 'xxx.com',
    ipaddr: 'xxx.xxx.xxx'
}

如果想获取最近某个ip地址的日志信息就变的很简单,只需要一条语句而不是之前的两条就能完成。

db.logmsg.find({ipaddr:'xxx.xxx.xxx'});

事实上,如果one端只有少量的信息存储,你甚至可以全部冗余存储到多端上,合并两个对象。

另一方面,也可以冗余数据到one端。比如说你想在主机文档中保存最近的1000条日志,可以使用mongodb 2.4中新加入的 e a c h e / eache/ eache/slice功能来保证list有序而且只保存1000条。

日志对象保存在logmsg集合中,同时冗余到hosts对象中。这样即使hosts对象中超过1000条的数据也不会导致日志对象丢失。

通过在查询中使用投影参数 (类似{_id:1})的方式在不需要使用logmsgs数组的情况下避免获取整个mongodb对象,1000个日志信息带来的网络开销是很大的。

在一对多的情况下,需要慎重的考虑读和更新的频率。冗余日志信息到主机文档对象中只有在日志对象几乎不会发生更新的情况下才是个好的决定。

总结

在这篇文章里,我介绍了对三种基础方案:内嵌文档,子引用,父引用的补充选择。

使用双向引用来优化你的数据库架构,前提是你能接受无法原子更新的代价。

可以在引用关系中冗余数据到one端或者N端。

在决定是否采用反范式化时需要考虑下面的因素:

你将无法对冗余的数据进行原子更新。

只有读写比较高的情况下才应该采取反范式化的设计。

Part 3

原文:6 Rules of Thumb for MongoDB Schema Design: Part 3

By William Zola, Lead Technical Support Engineer at MongoDB

这篇文章是系列的最后一篇。在第一篇文章里,我介绍了三种针对“一对多 ”关系建模的基础方案。在第二篇文章中,我介绍了对基础方案的扩展:双向关联和反范式化。

反范式可以让你避免一些应用层级别的join,但是这也会让更新变的更复杂,开销更大。不过冗余那些读取频率远远大于更新频率的字段还是值得的。

如果你还没有读过前两篇文章,欢迎一览。

让我们回顾下这些方案

你可以采取内嵌,或者建立one端或者N端的引用,也可以三者兼而有之。

你可以在one端或者N端冗余多个字段

下面这些是你需要谨记的:

1、优先考虑内嵌,除非有什么迫不得已的原因。

2、需要单独访问一个对象,那这个对象就不适合被内嵌到其他对象中。

3、数组不应该无限制增长。如果many端有数百个文档对象就不要去内嵌他们可以采用引用ObjectID的方案;如果有数千个文档对象,那么就不要内嵌ObjectID的数组。该采取哪些方案取决于数组的大小。

4、不要害怕应用层级别的join:如果索引建的正确并且通过投影条件(第二章提及)限制返回的结果,那么应用层级别的join并不会比关系数据库中join开销大多少。

5、在进行反范式设计时请先确认读写比。一个几乎不更改只是读取的字段才适合冗余到其他对象中。

6、在mongodb中如何对你的数据建模,取决于你的应用程序如何去访问它们。数据的结构要去适应你的程序的读写场景。

设计指南

当你在MongoDB中对“一对多”关系进行建模,你有很多的方案可供选择,所以你必须很谨慎的去考虑数据的结构。下面这些问题是你必须认真思考的:

关系中集合的规模有多大:是一对很少,很多,还是非常多?

对于一对多中”多“的那一端,是否需要单独的访问它们,还是说它们只会在父对象的上下文中被访问。

被冗余的字段的读写的比例是多少?

数据建模设计指南

在一对很少的情况下,你可以在父文档中内嵌数组。

在一对很多或者需要单独访问“N”端的数据时,你可以采用数组引用ObjectID的方式。如果可以加速你的访问也可以在“N”端使用父引用。

在一对非常多的情况下,可以在“N”端使用父引用。

如果你打算在你的设计中引入冗余等反范式设计,那么你必须确保那些冗余的数据读取的频率远远大于更新的频率。而且你也不需要很强的一致性。因为反范式化的设计会让你在更新冗余字段时付出一定的代价(更慢,非原子化)

总结

one-one

{   

   "主机id":"1",   
   "CPU核数":"2核",   
   "内存大小":"16GB",   
   "显卡大小":"2GB",   
   "键盘":
   {     
      "键盘类型":"机械",     
      "颜色":"Black",     
      "牌子":"双飞燕"   
   } 
}

查询、更新、增加时,一张表即可。

总结:在mongoDB在one-one的场景下,建议使用内嵌,不应该使用两张表关联

one-many(内嵌不是很多)

{
    "id": "asdg184981651568956",
    "订单号": "201809270012598323334",
    "运费": 0,
    "总价格": 41,
    "订单状态": "已经支付",
    "订单项": [
        {
            "名称": "益达口香糖",
            "单价": 8,
            "数量": 2
        },
        {
            "名称": "大大口香糖",
            "单价": 5,
            "数量": 1
        },
        {
            "名称": "绿箭口香糖",
            "单价": 10,
            "数量": 2
        }
    ]
}

查询时:

查询订单一条语句即可,就能查询出订单以及订单中所有订单项。(不会需要查询出单个订单项)

修改时:

只修改根内容,不会修改订单项(内嵌)内容。

总结:

  • 内嵌数组不宜过大(建议不超过20个或者30个)
  • 如果内嵌实体数组多(一般多于5个),查询时,内嵌实体内容没有作为单独的实体查询。例如不会有单个查询订单项的需求
  • 如果内嵌实体数组多(一般多于5个),修改/删除/插入时,内嵌实体内容没有作为单独的实体修改/删除/插入

one-many(内嵌很多)

公司表

{
    "id":"45465516654",
    "名称":"千度科技有限公司",
    "注册地址":"北京市朝阳区",
    "所有人":"张大大",
    "注册日期":"2001-9-1",
    "员工":[
        {            "id":"员工id1"        },        {            "id":"员工id2"        }
    ]
}

员工表

{
    "id":"员工id",
    "姓名":"alun",
    "入职时间":"2017-8-8",
    "身份证号":"441955876632155502",
    "职位":"架构师",
    "工资":8000,
    "入职年限":1,
    "头像":"https://www.dr.cn/head/sdfjooc2143.jpg",
    "公资金百分比":5,
    "得奖数":1,
    "体重":"50KG",
    "住址":"广东省广州市天河区龙洞",
    "下属人数":20,
    "年假剩余天数":0,
    "评价级别":10
}

查询时:

  • 查询公司下面所有员工信息,两张表关联查询,先去查公司表,然后关联员工表去查询员工信息
  • 查询单个员工信息时,只需查询员工表,就能取得员工实体信息

修改时:

  • 修改单个员工信息时,修改员工实体即可

总结

优点:

  • 一对多的多那方数量要多,最好是几十个到几千个不等
  • 如果需要单独把内嵌的实体取出。即单独取出多那方的实体

缺点:

  • 查询员工属于哪些公司时,需要跨表查询
  • 内嵌方的数量不能过多

one-many(内嵌很多)

继续运用上面表。

如果有个功能,查询公司下面的员工名字。这个功能是占用查询率70%以上的话,可以考虑使用内嵌id+查询字段型。

即在公司内嵌员工id的同时,加上员工的姓名。如下:

{
    "id":"45465516654",
    "名称":"千度科技有限公司",
    "注册地址":"北京市朝阳区",
    "所有人":"张大大",
    "注册日期":"2001-9-1",
    "员工":[
        {            "id":"员工id1",            "姓名":"alun"        },
        {            "id":"员工id2",            "姓名":"vivien"      }
    ]
}

在查询时,不需要管理表,直接查询公司表即可。查询效率大大提升

但是相应地,修改名字的时候需要修改公司表、员工表。需要一次都修改2张表成功。需要原子性的操作

总结

优点:

  • 一对多的多那方数量要多,最好是几十个到几千个不等
  • 内嵌方的属性(字段)不宜过多
  • 查询、修改比高。即查询需求大大大于修改需求

缺点:

  • 修改时需要原子性操作
  • 文档内容加大了,即产生了多余字段。如上面的【姓名】
  • 需要特殊场景需求

one-many(父级引用)

品牌表

{
    "id":"hijgio19089popik",
    "名称":"大宝",
    "注册地址":"杭州市",
    "所有人":"某某某",
    "注册日期":"2005-1-1"
}

商铺表

{
    "id":"84948654",
    "名称":"成都商铺",
    "创建时间":"2018-2-1",
    "过期时间":"2019-2-1",
    "是否合法":true,
    "商品品质级别":"高",
    "是否个人商铺":true,
    "营业执照":"https://yyzz.tb.cn/kg/145/84948654.jpg",
    "持有人身份证号":"441922365587444468",
    "持有人身份证图片":"https://yyzz.tb.cn/kg/966/441922365587444468.jpg",
    "品牌id":"hijgio19089popik"
}

查询时:

  • 查询属于 品牌的商铺时,在商铺表通过 品牌id查出所有的商铺。几万商铺以上

查询时:

  • 修改商铺、修改 品牌表的属性都是单个表修改

总结

优点:

  • 多的那方数据很多,上万以上
  • 对性能要求不高

缺点:

  • 性能不高
  • 与关系型sql差不多

one-many(内嵌id+父级引用)

商铺表

{
    "id":"3455-2dp4x-xderd0",
    "名称":"alun的商铺",
    "类型":"药材专卖店",
    "所有人":"alun",
    "创建日期":"2018-9-1",
    "商品":[
        {            "id":"商品id1"        },
        {            "id":"商品id2"        }
    ]
}

商品表

{
    "id":"商品id1",
    "名称":"枸杞",
    "类型":"中药补血",
    "是否上架":"是",
    "创建日期":"2018-9-15",
    "商品详情":"农民枸杞直销,价格原厂,不经过加工。",
    "商品编码":"878866554234",
    "规格":"1kg",
    "运费":0,
    "重量":11,
    "标签":"中药材枸杞",
    "销售量":0,
    "销售价":20,
    "原价":40,
    "商铺id":"3455-2dp4x-xderd0"
}

查询时:

  • 查询到商铺按类别查询、按销量查询的需求。查询商品表即可。
  • 查询商铺的所有商品。查询商铺表的商品,再通过商品id查询所有的商品表。所以要关联查询

修改时:

  • 如果要修改商品与商铺的关系,需要原子性删除,两张表做操作

总结

优点:

  • 一对多的多方数量中等,最好是几百到上万即可
  • 一对多的多方数量存在严重的不确定性

缺点:

  • 混合模式,折中方案,没有太大的优势
  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值