Uncategorized

使用MongoDB存储评论

翻译自MongoDB官方文档中的用例

使用MongoDB存储评论

这个文档概述了如何架构一个在内容管理系统(CMS)中存储用户评论的基本组件。

概览

MongoDB提供了一系列不同的方法用来存储像是对某一内容的用户评论这种数据。这种需求没有一种公认的实现方式,但是每一种方式都有一些共同的部分和考虑。这个用例中就展示了一些实现细节和每一种实现方式之间的权衡。存储用户评论数据有三个基本的模式,为:

  1. 每个文档仅储存一个评论

    这种方式在损失一部分额外的应用程序可处理内容的情况下,灵活性是最好的。它的实现能够让评论按照时间顺序或者串顺序(threaded order)显示,并且也不会有对某个特定对象的评论数量的限制。

  2. 将所有评论嵌入在“父”文档中

    这种方式损失了一部分灵活性,但在显示评论的表现上是最好的:评论的显示格式取决于文档中评论的结构。

    注:

    因为文档大小的限制,文档(包括原始内容和它的所有评论)的大小无法超过16MB。

    1. 一种混合式的设计,既将评论与它的“父文档”分离,又将这些评论整合到几个小的文档中,每个文档中都包含复数个评论。

单评论单文档

概述

如果你的每个文档都储存仅一条评论,那么你的 comments 集合中的文档应当类似于以下文档结构:

{
    _id: ObjectId(...),
    discussion_id: ObjectId(...),
    slug: '34db',
    posted: ISODateTime(...),
    author: {
              id: ObjectId(...),
              name: 'Rick'
             },
    text: 'This is so bogus ... '
}

这种形式只适合显示以时间顺序排列的评论。评论文档中存储了:

  • discussion_id 字段,指向讨论帖
  • URL兼容的 slug 标识
  • 发表时间 posted
  • 作者 author,包含了指向用户信息的 id 和名称的 name 字段
  • 评论全文 text

如果要支持串顺序的评论,你应该使用和上面稍许不同的文档结构:

{
    _id: ObjectId(...),
    discussion_id: ObjectId(...),
    parent_id: ObjectId(...),
    slug: '34db/8bda'
    full_slug: '2012.02.08.12.21.08:34db/2012.02.09.22.19.16:8bda',
    posted: ISODateTime(...),
    author: {
              id: ObjectId(...),
              name: 'Rick'
             },
    text: 'This is so bogus ... '
}

这个结构

  • 添加了 parent_id 字段,用于储存父评论的 _id 字段
  • 修改了 slug 字段,新的字段组合了父节点/父节点的slug和这个评论的唯一slug
  • 添加了 full_slug 字段,将slug和时间信息组合起来,可以使得在串中再按照时间排序

警告:

MongoDB 只能够索引 1024字节 的数据。这包括所有的字段数据、字段名、和命名空间(例如数据库名和集合名)。所以当你为 full_slug 字段创建索引时可能会因此而产生问题。

实现

这一节内容包括了对单评论单文档的结构的一些基本操作。

在这篇文档中的所有示例都会使用 Python编程语言 以及 PyMongo 驱动,当然你也可以使用你想要的语言来实现这个系统。在 Python交互式界面 中执行下列操作来加载需要的库:

>>> import bson
>>> import pymongo
创建一条新评论

在一个按照时间顺序排序的系统中(例如没有串),使用如下的 insert() 操作

slug = generate_pseudorandom_slug()
db.comments.insert({
    'discussion_id': discussion_id,
    'slug': slug,
    'posted': datetime.utcnow(),
    'author': author_info,
    'text': comment_text })

在一个存在评论串的系统中,你需要在插入时生成 slug 路径和 full_slug。详见以下操作:

posted = datetime.utcnow()

# 创建 slug 和 full_slug 的唯一的部分
slug_part = generate_pseudorandom_slug()
full_slug_part = posted.strftime('%Y.%m.%d.%H.%M.%S') + ':' + slug_part
# 查找父评论(如果存在)
if parent_slug:
    parent = db.comments.find_one(
        {'discussion_id': discussion_id, 'slug': parent_slug })
    slug = parent['slug'] + '/' + slug_part
    full_slug = parent['full_slug'] + '/' + full_slug_part
else:
    slug = slug_part
    full_slug = full_slug_part

# 插入评论
db.comments.insert({
    'discussion_id': discussion_id,
    'slug': slug,
    'full_slug': full_slug,
    'posted': posted,
    'author': author_info,
    'text': comment_text })
对评论进行分页

查找没有串的评论,只需查询所有参与到讨论中的评论然后将其按 posted 字段排序。举例来说:

cursor = db.comments.find({'discussion_id': discussion_id})
cursor = cursor.sort('posted')
cursor = cursor.skip(page_num * page_size)
cursor = cursor.limit(page_size)

因为 full_slug 字段既包含层级信息(通过路径)和时间信息,你可以简单地通过对 full_slug 排序来获得一个串排序的评论:

cursor = db.comments.find({'discussion_id': discussion_id})
cursor = cursor.sort('full_slug')
cursor = cursor.skip(page_num * page_size)
cursor = cursor.limit(page_size)

参阅:

cursor.limitcursor.skip,以及cursor.sort

索引

为了使上述查询更有效率,可以维护两个复合索引:

  1. (``discussion_id, posted)“ 以及
  2. (``discussion_id, full_slug)“

在 Python交互式界面 中执行下列操作

>>> db.comments.ensure_index([
...    ('discussion_id', 1), ('posted', 1)])
>>> db.comments.ensure_index([
...    ('discussion_id', 1), ('full_slug', 1)])

注:

保证你只使用复合索引中的最后一个元素来进行排序,这样可以最大化查询的性能。

直接获取评论

查询

想要直接获得一条评论而不是遍历所有的评论,你可以通过 slug 字段来查询:

comment = db.comments.find_one({
    'discussion_id': discussion_id,
    'slug': comment_slug})

要获得一条”子讨论串”,或者是递归地获得一条评论以及它的派生,可以在 full_slug 字段上使用前缀正则表达式查询:

import re

subdiscussion = db.comments.find_one({
    'discussion_id': discussion_id,
    'full_slug': re.compile('^' + re.escape(parent_slug)) })
subdiscussion = subdiscussion.sort('full_slug')
索引

你已经创建了 { discussion_id: 1, full_slug: } 索引以用于查询子讨论串,你还可以添加 { discussion_id: 1 ,slug: 1 } 索引来支持以上的查询。在 Python交互式界面 中执行下列操作:

>>> db.comments.ensure_index([
...    ('discussion_id', 1), ('slug', 1)])

整合所有评论

这种设计将整个讨论串嵌入到了主题所在的文档里。在这个例子中,“主题”文档中存储了你想要管理的任何内容。

概述

考虑下种主题文档的原型架构:

{
    _id: ObjectId(...),
    ... 一堆关于主题的数据 ...
    comments: [
        { posted: ISODateTime(...),
          author: { id: ObjectId(...), name: 'Rick' },
          text: 'This is so bogus ... ' },
       ... ]
}

这种结构只适用于所有评论都是按照时间显示的情况。因为评论都是按照时间顺序嵌入进来的。所有在 comments 数组中的文档都包含了评论的时间、作者和评论内容。

注:

因为你已经将评论存储为固定的顺序,所以,已经没有必要再去为每一个评论维护一个 slug。

为了使这种设计支持串排序,你需要将评论再嵌入到评论中,用类似如下的结构:

{
    _id: ObjectId(...),
    ... 一堆关于主题的数据 ...
    replies: [
        { posted: ISODateTime(...),
          author: { id: ObjectId(...), name: 'Rick' },
          text: 'This is so bogus ... ',
          replies: [
              { author: { ... }, ... },
       ... ]
}

每个评论中的 replies 字段都存储了它的子评论,而它的每一个子评论又可以存储它们自己的子评论。

注:

在这种嵌入式的文档设计中,会丧失一些在显示格式上的灵活性,因为它很难再按照本身存储形式以外的格式来显示。

如果,在之后你想要将评论显示形式从时间顺序改成串顺序,那么这种设计会导致迁移的工作十分费力。

警告:

不要忘记 BSON 文档有着 16MB 的大小限制。如果这些讨论(主题和回复)的大小超过了 16MB ,后续的大小增长将会失败。

除此之外,当 MongoDB 的某一文档在创建后产生明显增长时,因为 MongoDB 需要将文档进行迁移,所以将会产生更大的存储碎片,以及降低更新的效率。

实现

这一节内容包括了对将所有评论整合进“父评论”或者主题文档的一些基本操作。

注:

对于以下的所有操作,不需要再添加任何的索引,因为所有的操作都是在文档内的操作。你会使用 _id 字段来获得这些文档,所以你只需要依赖 MongoDB 自行为你创建的索引即可。

创建一条新评论

在一个按照时间顺序排序的系统中(如没有串),使用如下的 update()操作

db.discussion.update(
    { 'discussion_id': discussion_id },
    { '$push': { 'comments': {
        'posted': datetime.utcnow(),
        'author': author_info,
        'text': comment_text } } } )

$push 操作符会将评论按照时间顺序插入到 comments 数组中。对于讨论串来说,update() 操作要更加的复杂。要对一条评论进行回复,以下的代码假设存在一个path,为存储了一连串位置的列表,对于父评论:

if path != []:
    str_path = '.'.join('replies.%d' % part for part in path)
    str_path += '.replies'
else:
    str_path = 'replies'
db.discussion.update(
    { 'discussion_id': discussion_id },
    { '$push': {
        str_path: {
            'posted': datetime.utcnow(),
            'author': author_info,
            'text': comment_text } } } )

这样构造了一个形如 replies.0.replies.2… 的字段名作为 str_path,然后 $push 操作符再使用这个值将新的评论插入到父评论的 replies 数组中。

对评论进行分页

在没有讨论串的设计中,你需要使用 $slice 操作符来进行分页:

discussion = db.discussion.find_one(
    {'discussion_id': discussion_id},
    { ... some fields relevant to your page from the root discussion ...,
      'comments': { '$slice': [ page_num * page_size, page_size ] }
    })

在有讨论串的设计中,你必须要获得所有文档,然后在程序中对评论进行分页:

discussion = db.discussion.find_one({'discussion_id': discussion_id})

def iter_comments(obj):
    for reply in obj['replies']:
        yield reply
        for subreply in iter_comments(reply):
            yield subreply

paginated_comments = itertools.slice(
    iter_comments(discussion),
    page_size * page_num,
    page_size * (page_num + 1))

直接获取评论

和上种通过 slug 获得评论的方式不同,接下来的例子展示了如何使用其在评论树中的位置获得该评论。

在一个按照时间顺序排序的系统中(如没有串),只需使用 $slice 操作符来获得一条评论,如下:

discussion = db.discussion.find_one(
    {'discussion_id': discussion_id},
    {'comments': { '$slice': [ position, position ] } })
comment = discussion['comments'][0]

对于讨论串,你需要在程序中通过评论树来找到正确的路径,如下:

discussion = db.discussion.find_one({'discussion_id': discussion_id})
current = discussion
for part in path:
    current = current.replies[part]
comment = current

注:因为父评论中已经嵌入了它的子评论,所以这个操作实际上已经获得了你想要的整个子讨论串中的评论。

参阅:

find_one()

混合架构设计

概述

在这种“混合设计”中,会使用一个能存储大约100条评论的“桶”。考虑以下的桶文档:

{
    _id: ObjectId(...),
    discussion_id: ObjectId(...),
    bucket: 1,
    count: 42,
    comments: [ {
        slug: '34db',
        posted: ISODateTime(...),
        author: { id: ObjectId(...), name: 'Rick' },
        text: 'This is so bogus ... ' },
    ... ]
}

每个文档维护了 bucketcount 数据,其中保存了关于桶的一些元数据, 比如桶的编号和评论的数量,以及 comments 数组存储了评论本身。

注:

使用这种混合的方式会将评论串的实现变得极其复杂,所以在该文档中不会涉及到具体的配置。

另外,每个桶存储大约100条评论并不是一个硬性规定。这个值是任意的:最好选择一个既不会达到BSON的 16MB 文档大小限制,又要确保足够评论能够存入单个文档中。在某些情况下文档中评论的数量会超过100,但是这并不影响这个模式的正确性。

实现

这一节包括了用于使用存储100条评论的“桶”的混合存储模型的一些基本操作。

在这篇文档中的所有示例都会使用 Python编程语言 以及 PyMongo 驱动,当然你也可以使用你想要的语言来实现这个系统。

创建一条新评论

更新

要创建一条新评论,你需要将评论 $push 到最后一个桶中,然后使用 $inc 增加桶的评论数量。考虑下种基于 discussion_id 字段的查询:

bucket = db.comment_buckets.find_and_modify(
    { 'discussion_id': discussion['_id'],
      'bucket': discussion['num_buckets'] },
    { '$inc': { 'count': 1 },
      '$push': {
          'comments': { 'slug': slug, ... } } },
    fields={'count':1},
    upsert=True,
    new=True )

find_and_modify() 函数实际上是一个 upsert 操作:如果 MongoDB 无法找到对应的 bucket 编号,那么它将会创建并且用对应的 countcomments 初始化这个文档。

要限制每一个桶的评论数量在100左右,要在需要的时候创建新的页。添加以下程序逻辑以实现:

if bucket['count'] > 100:
    db.discussion.update(
        { 'discussion_id: discussion['_id'],
          'num_buckets': discussion['num_buckets'] },
        { '$inc': { 'num_buckets': 1 } } )

update() 操作中的查询条件包括了最后已知的页编号来避免竞态,防止页编号被增加两次,否则可能会产生一个近乎甚至完全不包含评论的文档。如果其它的进程已经增加了该页的编号,那么上述的更新将不会做出任何修改。

索引

要支持 find_and_modify()update() 操作,comment_buckets 集合中需要维护一个在 (discussion_id, bucket) 上的复合索引, 在 Python交互式界面 上执行下列操作:

>>> db.comment_buckets.ensure_index([
...    ('discussion_id', 1), ('bucket', 1)])
对评论进行分页

下列函数是一个将评论分割为固定长度的页面的例子。这个函数的原理是遍历该讨论中的所有评论,同时维护一个计数器,代表已经跳过了多少评论,也代表已经返回了多少评论。

def find_comments(discussion_id, skip, limit):
    result = []

    # 找到这个讨论的所有评论桶
    buckets = db.comment_buckets.find(
        { 'discussion_id': discussion_id },
        { 'bucket': 1 })
    buckets = buckets.sort('bucket')

    # 遍历这些桶,查询它们的评论
    for bucket in buckets:
        page_query = db.comment_buckets.find_one(
            { 'discussion_id': discussion_id, 'bucket': bucket['bucket'] },
            { 'count': 1, 'comments': { '$slice': [ skip, limit ] }})
        result.append((bucket['bucket'], page_query['comments']))
        skip = max(0, skip - page_query['count'])
        limit -= len(page_query['comments'])
        if limit == 0: break

    return result

在这里, $slice 操作符在符合 skip 条件时,将每一个桶中的评论取出。例如:如果现在有四个评论数量分别为 100, 102, 101, 22 的4个桶,需要查询 skip=300 以及 limit=50 的数据。使用以下算法:

Skip Limit 讨论
300 50 {$slice: [300, 50]} 没有在 桶#1 中匹配到评论;将 skip 减去 桶#1 的 count,继续
200 50 {$slice: [200, 50]} 没有在 桶#2 中匹配到评论;将 skip 减去 桶#2 的 count,继续
98 50 {$slice: [98, 50]} 在 桶#3 中匹配到3条评论;将 skip 减去 桶#2 的 count(取到0),从 limit 中减去 3 ,继续
0 48 {$slice: [98, 50]} 在 桶#4 中匹配到所有的22条评论; 从 limit 中减去 3 ,继续
0 26 没有桶了,循环终止

注:

因为你已经在comment_buckets 集合中创建了 (discussion_id, bucket) 上的索引,所以 MongoDB 可以高效地进行这些查询。

直接获取评论

查询

要在不遍历所有评论的情况下直接获得一条评论,使用 slug 来找到正确的桶,然后再使用程序逻辑来找到正确的评论:

bucket = db.comment_buckets.find_one(
    { 'discussion_id': discussion_id,
      'comments.slug': comment_slug},
    { 'comments': 1 })
for comment in bucket['comments']:
    if comment['slug'] = comment_slug:
        break
索引

要高效地执行这种查询,需要在 discussion_idcomments.slug 字段 (例 { discussion_id: 1 comments.slug: 1 })建立新的索引。在 Python交互式界面 上执行下列操作:

>>> db.comment_buckets.ensure_index([
...    ('discussion_id', 1), ('comments.slug', 1)])

分片

如果你有对你的程序做分片的需求,对于以上讨论的所有架构,使用含有 discussion_id 的分片字段。

对于使用“单评论单文档”方法的程序,可以考虑使用包含 slug (或者在存在评论串的情况下使用 full_slug)的分片字段,使得 mongos 能够通过 slug 来对请求进行路由。在 Python交互式界面 中执行下列操作:

>>> db.command('shardCollection', 'comments', {
...     'key' : { 'discussion_id' : 1, 'full_slug': 1 } })

将会得到以下返回:

{ "collectionsharded" : "comments", "ok" : 1 }

对于使用完全嵌入进父内容文档的程序,如何选择分片字段在该文档中暂不讨论。

对于混合架构的程序,使用桶编号和 discussion_id 来让 MongoDB 在将讨论集中在同一分片的同时,分割讨论中的不同页。在 Python交互式界面 中执行下列操作

>>> db.command('shardCollection', 'comment_buckets', {
...     key : { 'discussion_id' : 1, 'bucket': 1 } })
{ "collectionsharded" : "comment_buckets", "ok" : 1 }

Leave a Reply

Your email address will not be published.Required fields are marked *

此站点使用Akismet来减少垃圾评论。了解我们如何处理您的评论数据