在CouchDB上如何对文档之间的关系建模?

13

我正在尝试在CouchDB中建立一个相当简单的关系模型,但我很难确定最佳实现方式。我希望用户能够创建视频游戏对象列表。我已经在数据库中存储了视频游戏文档,并且使用"type":"game"进行标记。我想通过查询列表对象的ID(通过视图)来获取列表的元数据(标题、创建日期等)和游戏文档的部分内容(例如标题和发布日期)。此外,我希望能够向列表中添加/删除游戏,而无需下载整个列表文档并将其上传回去(这意味着我不能简单地将游戏信息存储在列表文档中),因为我最终希望支持多个用户共同贡献到同一列表,而我不想引入冲突。

阅读了CouchDB维基上关于 实体关系的文章后,我确定建立关系文档可能是最佳解决方案。

游戏:

{
    "_id": "2600emu",
    "type": "game"
}

列表:

{
    "_id": 123,
    "title": "Emulators",
    "user_id": "dstaley",
    "type": "list"
}

游戏-列表关系:

{
    "_id": "98765456789876543",
    "type": "relationship",
    "list_id": 123,
    "game_id": "2600emu"
}

但是,据我所知,这样做不能让我在一个请求中获取列表元数据和游戏元数据。有什么建议吗?

1个回答

14

很棒的问题。您指出了使用“规范化”数据模型(带有链接的不同文档类型)是最佳模型的几个非常重要的原因:

  1. 用户 <=> 游戏列表 <=> 游戏之间存在多对多关系。
  2. 在单个文档中表示一对多关系很容易,其中使用容器表示“多”的部分,但它们会变得很大,并且可能存在并发冲突。
  3. 将单个文档模型扩展到存储多对多关系是不可行的。
  4. 通常,文档不可变性非常适合并发系统。在CouchDB中,您确切地按照您注意到的方式存储“只写一次”的文档,表示图中的一个边缘,然后使用二级索引来重建您想要的链接部分,并获取您想要的信息在单个API查询调用中。

您还正确地指出此处解决方案是“映射端连接”(借鉴hadoop社区)。基本上,您想要使用映射输出中的不同行来表示不同的信息片段。然后,您可以使用范围查询(startkey/endkey)仅查询您需要的映射结果部分,然后,voila,您的“连接”表的材料化视图。但是,您在文档中没有找到的一个谜题是:

  

3.2.3. 使用视图进行连接

  

3.2.3.1. 链接文档

  

如果您的映射函数发出了一个具有{'_id': XXX}的对象值,并使用include_docs=true参数查询视图,则CouchDB将获取具有IDXXX的文档,而不是处理以发出键/值对的文档。

这就是全部内容。这就是您如何取消引用存储有外键的链接文档的指针。然后将其与使用复合键(JS数组)和视图排序规则结合使用。

使您的视图行按以下方式排序:

["list_1"], null
["list_1", "game"], {"_id":"game_1234"}
["list_1", "game"], {"_id":"game_5678"}
["list_2"], null
["list_2","game"], {"_id":"game1234"}
["list_3"], null
...

结合您现有的数据模型,以下是一些(未经测试的)伪代码,可以完成此操作:

function(doc) {
    if (doc.type=="list") {
        //this is the one in the one-to-many
        emit( [doc._id]),);
    }
    else if (doc.type=="relationship") {
        //this is the many in the one-to-many
        //doc.list_id is our foreign key to the list.  We use that as the key
        //doc.game_id is the foreign key to the game.  We use that as the value
        emit( [doc.list_id,'game'],  {'_id': doc.game_id});
    }
}   

最后,您需要使用startkey/endkey查询以获取所有以您感兴趣的list_id开头的行。查询语句可能类似于:

curl -g 'https://usr:pwd@usr.cloudant.com/db/_design/design_doc_name/_view/view_name?startkey=["123"]&endkey=["123",{}]&include_docs=true'

-g选项告诉curl不要进行glob(即不要扩展方括号等),include_docs=true选项将跟随指向relationship文档中你指定的外键game_id的指针。

分析:

  1. 你正在使用基本不变的文档来存储状态更改,并让数据库为你计算聚合状态。这是一个在规模上非常优秀且成功的模式。
  2. 对于列表的添加或删除非常有效率。
  3. 在高并发下拥有极佳的扩展性。
  4. 在Cloudant(以及CouchDB v2.0)中,我们尚未实现二级索引的“读取写入一致性”。这已经是高优先级任务之一,但在故障场景或高负载下,主索引和二级索引之间可能存在不一致性的潜在边角情况。简而言之,主索引使用仲裁,但对于二级索引,仲裁不是可行的模型,因此正在开发另一种一致性策略。

if (doc.type=="list") { 中的 emit 应该是 emit(doc._id,doc) 吗? - dstaley
甚至是 emit([doc._id, 0], null) 吗?我有点困惑你的目标是什么,因为有一个未配对的括号和一个没有第二个值的逗号。 - dstaley
从视图输出["list_1"], null来看,我会选择emit([doc._id], null) - Mike Rhodes
2
是的,我在这里有点粗心。 有几件事情需要注意。 (1)键的结构(emit()方法中的第一个参数)不需要从一行到另一行完全相同。 这很有用。 例如,["foo"]将在["foo","foo"]之前排序。 (2)如果您有一个空条目(例如emit(“foo”,); JS运行时会自动插入null。 抱歉我有些粗心。 - Mike Miller

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接