【译】MongoDB Shema 设计的6条经验法则 2

723

6 Rules of Thumb for MongoDB Schema Design: Part 2

作者 William Zola, Lead Technical Support Engineer at MongoDB


(请结合上一篇 MongoDB Shema 设计的6条经验法则1一起阅读。)


关于 在 MongoDB 中为 1对多 关系建模的旅程中的第二站,上一次我谈过了三点基本的 Schema 设计:内嵌,子引用和父引用。还谈到了在选择这些设计时要考虑两个因素:

  1. 1对多多端的实体需要独立存在吗?

  2. 这个关系的基数是什么样的:1对少1对很多,还是1对非常多?

有了这些基础技术知识的保障,我可以继续讨论更多复杂的 Schema 设计, 会涉及 双向引用和反范式化。

中级:双向引用

如果你想你的 Scheme 设计变得更高级一点,你可以结合两个技术并且在你的Schema同时使用着两种引用,从1端多端的引用和从多端1端的引用。

比如,让我们回到任务追踪系统。系统中 People 集合保存 Person 文档, Task 集合保存 Task 文档,以及一个 一对多 的从Person ->Task 的关系。这个应用需要追踪所有的一个Person所有的 Task

Task 文档中有一个引用数组, Person 文档可能看起来像下面这样:

db.person.findOne()
{
    _id: ObjectID("AAF1"),
    name: "Kate Monster",
    tasks [     // array of references to Task documents
        ObjectID("ADF9"), 
        ObjectID("AE02"),
        ObjectID("AE73") 
        // etc
    ]
}

另一方面,这个应用的其他部分要显示一个 Task 列表 (例如, 显示在多人项目中的所有任务)并且这个列表还需要快速的查找到一个 Person 应当负责的所有任务。你可以通过在Task 文档中添加一个附加的引用来对其进行优化。

db.tasks.findOne()
{
    _id: ObjectID("ADF9"), 
    description: "Write lesson plan",
    due_date:  ISODate("2014-04-01"),
    owner: ObjectID("AAF1")     // Reference to Person document
}

这个设计具有 一对很多 Schema 所有的有点和缺点,但还有一些附加优点。在 Task 文档中加入一个额外的 owner 引用意味着它可以很容易地做到快速地找到任务的所有者。但同时也意味着,如果你重新分配任务给另外一个人,你需要执行两条更新。 特别是,你必须同时更新从 Person 到 Task 的应用,以及从 Task 到 Person 的引用。(对于正在阅读这篇文章的专家来说——没错, 使用这个Schema设计 意味着 不再可能重新将任务分配给一个新的 Person。 这对我们的任务追踪系统是可以的: 你需要考虑这是否对你的特定方案有效。)

中级: “一对多”关系的规范化

对于零件的例子, 你可以反范式化零件的名字到 parts[] 数组。作为参考,这是 Product 文档反范式化版本。

> db.products.findOne()
{
    name : 'left-handed smoke shifter',
    manufacturer : 'Acme Corp',
    catalog_number: 1234,
    parts : [     // array of references to Part documents
        ObjectID('AAAA'),    // reference to the #4 grommet above
        ObjectID('F17C'),    // reference to a different Part
        ObjectID('D2AA'),
        // etc
    ]
}

反范式化意味着在显示产品所有的零件名称时将不必执行应用级别的联接,但如果你需要关于零件的其他信息,则必须得执行该联接。

> db.products.findOne()
{
    name : 'left-handed smoke shifter',
    manufacturer : 'Acme Corp',
    catalog_number: 1234,
    parts : [
        { id : ObjectID('AAAA'), name : '#4 grommet' },         // Part name is denormalized
        { id: ObjectID('F17C'), name : 'fan blade assembly' },
        { id: ObjectID('D2AA'), name : 'power switch' },
        // etc
    ]
}

虽然零件名称的获取变得容易了,但这也在应用级的联接中增加了一点客户端的工作。

// Fetch the product document
> product = db.products.findOne({catalog_number: 1234});  
  // Create an array of ObjectID()s containing *just* the part numbers
> part_ids = product.parts.map( function(doc) { return doc.id } );
  // Fetch all the Parts that are linked to this Product
> product_parts = db.parts.find({_id: { $in : part_ids } } ).toArray() ;

反范式化节约了对反范式化数据查找的成本,却是以一个更高成本的更新为代价。 如果你在已经将 PartName 反范式化到了Product 文档中, 那么在更新 Part 的名称时,你必须同时更新 Products 集合中每一条出现这个零件的数据。

反范式化只当在读取与更新比例很高时才有意义。如果你需要频繁的读取这些反范式化的数据,却很少对它作更新操作,那么为了得到更有效的查询,通常需要以更新变得缓慢而复杂作为代价。当更新相对于查询更为频繁时,反范式化所节省下的成本则变少了。

例如,假设零件的名称经常不经常改动,但现有的零件的数据经常变动。这意味着虽然将 PartName 范式化到 Product 文档中有意义,现有零件的数据的反范式化则没有意义。

同时注意,当你范式化一个字段时,你将无法对这个字段进行独立原子更新。就像上面的双向引用的示例,如果你先在 Part 文档中更新零件的名字,然后在 Product 文档中更新零件的名字,将会出现一个不到一秒的时间间隔,这会造成Product文档中将不会映射到 Part 文档中心得更新过的值。

1对很多 的反范式化

你也是对自动实现从 1端 到 多端的反范式化:

> db.parts.findOne()
{
    _id : ObjectID('AAAA'),
    partno : '123-aff-456',
    name : '#4 grommet',
    product_name : 'left-handed smoke shifter',   // Denormalized from the ‘Product’ document
    product_catalog_number: 1234,                     // Ditto
    qty: 94,
    cost: 0.94,
    price: 3.99
}

然而,如果你已经完成了从 Product 名称 到 Part 文档的范式化,那么当你更新 Product 名称时,你也必须同时更新 Part 集合中每一个 Product出现的地方。这可能是一个成本更高的更新,因为你同时更新了的多个 Parts。 因此,在进行这样的反范式化时,读与写比例的考虑就显是尤为重要了。

中级:范式化 1对非常多 的关系

1对非常多 的示例也可以进行反范式化,通过以下两种方式:你可以将 1端 的信息(来自 hots 文档)放入 非常多的那一端, 或者你也可以放入一些非常多端的总结性的信息到1端

下面是反范式化到非常多端的示例。我将把 host的IP地址 放入到单个的日志消息中:

> db.logmsg.findOne()
{
    time : ISODate("2014-03-28T09:42:41.382Z"),
    message : 'cpu is on fire!',
    ipaddr : '127.66.66.66',
    host: ObjectID('AAAB')
}

对于查询特定IP的最新消息会变得更加容易。现在只需要一次查询:

> last_5k_msg = db.logmsg.find({ipaddr : '127.66.66.66'}).sort({time : -1}).limit(5000).toArray()

实际上,如果你只想在 1端存放一定数量的信息,你可以将它全部都反范式化到 非常多 那一段,完全不必用到1端

> db.logmsg.findOne()
{
    time : ISODate("2014-03-28T09:42:41.382Z"),
    message : 'cpu is on fire!',
    ipaddr : '127.66.66.66',
    hostname : 'goofy.example.com',
}

另一方面,你有可以反范式化到 1端。假如你想在 host 文档中保存一个 host 最近1000条信息。你可以使用MongoDB 2.4中 介绍的 $each$slice 方法来保存那只包含了最近1000 条且已排好序的信息。

日志信息被保存到了 logmsg 集合中,同时也保存到了 host 文档中的反范式化列表里。 这样,即使信息超出了 hostslogmsgs 数组,也不会丢失了

 //  Get log message from monitoring system
logmsg = get_log_msg();
log_message_here = logmsg.msg;
log_ip = logmsg.ipaddr;
  // Get current timestamp
now = new Date()
  // Find the _id for the host I’m updating
host_doc = db.hosts.findOne({ipaddr : log_ip },{_id:1});  // Don’t return the whole document
host_id = host_doc._id;
  // Insert the log message, the parent reference, and the denormalized data into the ‘many’ side
db.logmsg.save({time : now, message : log_message_here, ipaddr : log_ip, host : host_id ) });
  // Push the denormalized log message onto the ‘one’ side
db.hosts.update( {_id: host_id }, 
        {$push : {logmsgs : { $each:  [ { time : now, message : log_message_here } ],
                           $sort:  { time : 1 },  // Only keep the latest ones 
                           $slice: -1000 }        // Only keep the latest 1000
         }} );

需要注意,投影规划可防止MongoDB在网络中传输整个 hosts 文档。通过告知 MongoDB只需要返回 _id 字段,网络开销可以减少到仅用于村塾该字段的那个几个字节(还有一些传输协议的开销)。

就像 一对多 案例中一样,你需要考虑读取与更新的比例。只有当日志消息相对应用程序需要在所有消息查找单个主机的次数很少时,日志消息到Host文档的反范式化才又意义。如果你要查找数据的频率低于更新的频率,那这种特殊的反范式化则不是一个好的办法。

回顾

在这篇文章中,我谈到了在基本的内嵌,子引用和父引用的其他选择。

  • 如果双向引用可以优化你的 Schema,并且你可以接受不能进行原子更新这样的代价,那么就可以使用双向引用。

  • 引用时,从 1端 到 N 端,或者 N端 到 1端 的反范式化都是可以的,

在觉得是否需要反范式化是,要考虑下面的因素:

  • 你将无法对反范式化的数据进行原子更新。

  • 在读较写的比例高时, 反范式化才有意义。

下一次,我给你们一些关于这些选项的选择上的指导。