3.2.1. 视图简介¶
视图在很多方面都很有用
过滤数据库中的文档,找到与特定过程相关的文档。
从文档中提取数据,并以特定顺序呈现。
构建高效索引,以便通过文档中存在的任何值或结构来查找文档。
使用这些索引来表示文档之间的关系。
最后,使用视图,您可以在文档中的数据上进行各种计算。例如,如果文档代表您公司的财务交易,则视图可以回答过去一周、一个月或一年的支出是多少。
3.2.1.1. 什么是视图?¶
让我们看看不同的用例。首先是提取您可能需要用于特定目的的数据,并以特定顺序排列。对于首页,我们想要一个按日期排序的博客文章标题列表。在介绍视图的工作原理时,我们将使用一组示例文档。
{
"_id":"biking",
"_rev":"AE19EBC7654",
"title":"Biking",
"body":"My biggest hobby is mountainbiking. The other day...",
"date":"2009/01/30 18:04:11"
}
{
"_id":"bought-a-cat",
"_rev":"4A3BBEE711",
"title":"Bought a Cat",
"body":"I went to the the pet store earlier and brought home a little kitty...",
"date":"2009/02/17 21:13:39"
}
{
"_id":"hello-world",
"_rev":"43FBA4E7AB",
"title":"Hello World",
"body":"Well hello and welcome to my new blog...",
"date":"2009/01/15 15:52:20"
}
示例中将使用三个文档。请注意,文档按“_id”排序,这是它们在数据库中的存储方式。现在我们定义一个视图。在解释之前,请先看一些代码。
function(doc) {
if(doc.date && doc.title) {
emit(doc.date, doc.title);
}
}
这是一个 映射函数,它用 JavaScript 编写。如果您不熟悉 JavaScript,但使用过 C 或其他类似 C 的语言,如 Java、PHP 或 C#,那么这应该看起来很熟悉。它是一个简单的函数定义。
您将视图函数作为字符串提供给 CouchDB,这些字符串存储在设计文档的 views
字段中。要创建此视图,您可以使用以下命令
curl -X PUT http://admin:[email protected]:5984/db/_design/my_ddoc
-d '{"views":{"my_filter":{"map":
"function(doc) { if(doc.date && doc.title) { emit(doc.date, doc.title); }}"}}}'
您不会自己运行 JavaScript 函数。相反,当您 查询视图 时,CouchDB 会获取源代码并在您定义视图的数据库中的每个文档上运行它。您可以使用以下命令 查询视图 来检索 视图结果
curl -X GET http://admin:[email protected]:5984/db/_design/my_ddoc/_view/my_filter
所有映射函数都只有一个参数 doc。这是数据库中的单个文档。我们的映射函数检查我们的文档是否具有 date
和 title
属性——幸运的是,我们所有的文档都有这些属性——然后使用这两个属性作为参数调用内置的 emit()
函数。
The emit()
函数始终接受两个参数:第一个是 key
,第二个是 value
。The emit(key, value)
函数在我们的 视图结果 中创建一个条目。还有一点:The emit()
函数可以在映射函数中多次调用,以从单个文档在视图结果中创建多个条目,但我们现在还没有这样做。
CouchDB 会将您传递给 emit() 函数的内容放入一个列表中(参见下表 1,“视图结果”)。该列表中的每一行都包含 key 和 value。更重要的是,该列表按 key 排序(在本例中按 doc.date
排序)。视图结果最重要的特性是它按 key 排序。我们将反复回到这一点,以完成一些巧妙的事情。敬请期待。
表 1. 视图结果
Key |
Value |
---|---|
“2009/01/15 15:52:20” |
“Hello World” |
“2009/01/30 18:04:11” |
“Biking” |
“2009/02/17 21:13:39” |
“Bought a Cat” |
当您查询视图时,CouchDB 会获取源代码并在数据库中的每个文档上运行它。如果您有很多文档,这需要相当长的时间,您可能会想知道这是否非常低效。是的,确实如此,但 CouchDB 的设计是为了避免任何额外的成本:它只在您第一次查询视图时遍历所有文档一次。如果文档发生更改,映射函数只运行一次,以重新计算该单个文档的键和值。新文档的处理方式相同。B 树是一个非常高效的数据结构,适合我们的需求,CouchDB 数据库的崩溃设计也扩展到了视图索引。
视图结果存储在 B 树中,就像用于保存文档的结构一样。视图 B 树存储在它们自己的文件中,因此对于高性能 CouchDB 使用,您可以将视图保留在它们自己的磁盘上。B 树提供按键快速查找行,以及在键范围内高效地流式传输行。在我们的示例中,单个视图可以回答所有涉及时间的问题:“给我上周的所有博客文章”或“上个月”或“今年”。非常棒。
当我们查询视图时,我们会得到一个按日期排序的所有文档列表。每一行还包含帖子标题,以便我们可以构建指向帖子的链接。表 1 只是视图结果的图形表示。实际结果是 JSON 编码的,包含更多元数据。
{
"total_rows": 3,
"offset": 0,
"rows": [
{
"key": "2009/01/15 15:52:20",
"id": "hello-world",
"value": "Hello World"
},
{
"key": "2009/01/30 18:04:11",
"id": "biking",
"value": "Biking"
},
{
"key": "2009/02/17 21:13:39",
"id": "bought-a-cat",
"value": "Bought a Cat"
}
]
}
现在,实际结果没有那么漂亮,也不包含任何多余的空格或换行符,但这对于您(和我们!)来说更容易阅读和理解。结果行中的“id”成员来自哪里?它之前并不存在。这是因为我们之前省略了它,以避免混淆。CouchDB 会自动包含创建视图结果中条目的文档的文档 ID。在构建指向博客文章页面的链接时,我们也会使用它。
警告
除非您确定要这样做,否则不要将整个文档作为 emit(key, value)
语句的值发出。这会在视图的辅助索引中存储文档的完整副本。具有 emit(key, doc)
的视图更新时间更长,写入磁盘时间更长,并且会消耗更多磁盘空间。唯一的优势是,它们比在查询视图时使用 ?include_docs=true
参数更快。
在发出整个文档之前,请考虑权衡利弊。通常,在视图中只发出文档的一部分,或者只发出单个键/值对就足够了。
3.2.1.2. 高效查找¶
让我们继续讨论视图的第二个用例:“构建高效索引,以便通过文档中存在的任何值或结构来查找文档。”我们已经解释了高效索引,但我们跳过了一些细节。现在是完成此讨论的好时机,因为我们正在查看更复杂的映射函数。
首先,回到 B 树!我们解释过,支持键排序视图结果的 B 树只构建一次,即在您第一次查询视图时,所有后续查询都将读取 B 树,而不是再次对所有文档执行映射函数。但是,当您更改文档、添加新文档或删除文档时会发生什么?很简单:CouchDB 足够聪明,可以找到视图结果中由特定文档创建的行。它会将它们标记为无效,这样它们就不会再出现在视图结果中。如果文档被删除,那就没问题——生成的 B 树反映了数据库的状态。如果文档被更新,则会将新文档通过映射函数运行,并将生成的新的行插入到 B 树的正确位置。新文档的处理方式相同。B 树是一个非常高效的数据结构,适合我们的需求,CouchDB 数据库的崩溃设计也扩展到了视图索引。
为了进一步说明效率问题:通常,在视图查询之间会更新多个文档。在上一段中解释的机制会应用于自上次查询视图以来数据库中的所有更改,这些更改会以批处理操作的形式进行,这使得速度更快,并且通常是更好地利用资源的方式。
3.2.1.2.1. 查找一个¶
接下来,让我们看看更复杂的映射函数。我们说过“通过文档中存在的任何值或结构来查找文档”。我们已经解释了如何提取一个值来对视图列表进行排序(我们的日期字段)。相同的机制用于快速查找。查询视图结果的 URI 是 /database/_design/designdocname/_view/viewname
。这将返回视图中所有行的列表。我们只有三个文档,所以数据量很小,但是如果拥有数千个文档,结果列表就会很长。你可以向 URI 添加视图参数来限制结果集。假设我们知道一篇博客文章的日期。要查找单个文档,我们可以使用 /blog/_design/docs/_view/by_date?key="2009/01/30 18:04:11"
来获取“骑自行车”博客文章。请记住,你可以在 emit()
函数的 key
参数中放置任何你想要的内容。无论你放入什么,我们现在都可以快速准确地使用它来查找。
请注意,如果多行具有相同的键(也许我们设计了一个视图,其中键是文章作者的姓名),键查询可能会返回多行。
3.2.1.2.2. 查找多个¶
我们谈到了“获取上个月的所有文章”。如果现在是二月,这很简单,就像
/blog/_design/docs/_view/by_date?startkey="2010/01/01 00:00:00"&endkey="2010/02/00 00:00:00"
startkey
和 endkey
参数指定一个包含范围,我们可以在其中搜索。
为了使事情更清晰,并为将来的示例做准备,我们将更改日期字段的格式。我们将不再使用字符串,而是使用数组,其中各个成员是时间戳的一部分,按重要性递减排列。这听起来很复杂,但实际上很简单。我们不再使用
{
"date": "2009/01/31 00:00:00"
}
而是使用
{
"date": [2009, 1, 31, 0, 0, 0]
}
我们的映射函数不需要为此更改,但我们的视图结果看起来略有不同
表 2. 新的视图结果
Key |
Value |
---|---|
[2009, 1, 15, 15, 52, 20] |
“Hello World” |
[2009, 2, 17, 21, 13, 39] |
“Biking” |
[2009, 1, 30, 18, 4, 11] |
“Bought a Cat” |
我们的查询也更改为
/blog/_design/docs/_view/by_date?startkey=[2010, 1, 1, 0, 0, 0]&endkey=[2010, 2, 1, 0, 0, 0]
对你来说,这仅仅是语法上的变化,而不是含义上的变化。但这展示了视图的强大功能。你不仅可以使用字符串和整数等标量值来构建索引,还可以使用 JSON 结构作为视图的键。假设我们用标签列表标记我们的文档,并希望查看所有标签,但我们不关心没有被标记的文档。
{
...
tags: ["cool", "freak", "plankton"],
...
}
{
...
tags: [],
...
}
function(doc) {
if(doc.tags.length > 0) {
for(var idx in doc.tags) {
emit(doc.tags[idx], null);
}
}
}
这展示了一些新内容。你可以在结构上设置条件 (if(doc.tags.length > 0)
),而不仅仅是值。这也是映射函数如何多次调用 emit()
的示例。最后,你可以将 null
而不是值传递给 value
参数。key
参数也是如此。我们将在稍后看到这如何有用。
3.2.1.2.3. 反转结果¶
要以相反顺序检索视图结果,请使用 descending=true
查询参数。如果你正在使用 startkey
参数,你会发现 CouchDB 返回不同的行或根本没有返回行。这是怎么回事呢?
当你看到视图查询选项在幕后是如何工作的时,就很容易理解了。视图存储在树形结构中,以便快速查找。每当你查询视图时,CouchDB 的操作方式如下
从顶部开始读取,或者如果存在,从
startkey
指定的位置开始读取。一次返回一行,直到到达末尾或如果存在,直到到达
endkey
。
如果你指定 descending=true
,则读取方向相反,而不是视图中行的排序顺序。此外,还遵循相同的两步程序。
假设你有一个看起来像这样的视图结果
Key |
Value |
---|---|
0 |
“foo” |
1 |
“bar” |
2 |
“baz” |
以下是可能的查询选项:?startkey=1&descending=true
。CouchDB 会怎么做?请参见上面的第 1 点:它跳到 startkey
,即键为 1
的行,并开始向后读取,直到到达视图的末尾。因此,特定结果将是
Key |
Value |
---|---|
1 |
“bar” |
0 |
“foo” |
这很可能不是你想要的。要以相反顺序获取索引为 1
和 2
的行,你需要将 startkey
切换到 endkey
:endkey=1&descending=true
Key |
Value |
---|---|
2 |
“baz” |
1 |
“bar” |
现在看起来好多了。CouchDB 从视图底部开始读取,并向后读取,直到到达 endkey
。
3.2.1.3. 获取文章评论的视图¶
我们在这里使用数组键来支持 group_level
归约查询参数。CouchDB 的视图存储在 B 树文件结构中。由于 B 树的结构方式,我们可以将中间归约结果缓存到树的非叶子节点中,因此归约查询可以在对数时间内沿着任意键范围计算。请参见图 1,“评论映射函数”。
在博客应用程序中,我们使用 group_level
归约查询来计算每个文章的评论数量以及总评论数量,这可以通过使用不同的方法查询相同的视图索引来实现。对于一些数组键,假设每个键的值为 1
["a","b","c"]
["a","b","e"]
["a","c","m"]
["b","a","c"]
["b","a","g"]
归约视图
function(keys, values, rereduce) {
return sum(values)
}
或者
_sum
这是一个内置的 CouchDB 归约函数(其他函数是 _count
和 _stats
)。_sum
在这里返回开始键和结束键之间的总行数。因此,对于 startkey=["a","b"]&endkey=["b"]
(包括上面列出的前三个键),结果将等于 3
。效果是计算行数。如果你想计算行数而不依赖于行值,你可以打开 rereduce
参数
function(keys, values, rereduce) {
if (rereduce) {
return sum(values);
} else {
return values.length;
}
}
注意
上面的 JavaScript 函数可以有效地用内置的 _count
替换。
这是示例应用程序用来计算评论的归约视图,同时利用映射输出评论,这比一遍又一遍地输出 1
更有用。花一些时间玩玩映射和归约函数是值得的。Fauxton 可以做到这一点,但它无法完全访问所有查询参数。用你选择的语言编写自己的视图测试代码是探索 CouchDB 增量 MapReduce 系统的细微差别和功能的好方法。
无论如何,使用 group_level
查询,你基本上是在运行一系列归约范围查询:每个组都会在你的查询级别出现,并为每个组运行一个查询。让我们重新打印前面列出的键列表,在级别 1
上进行分组
["a"] 3
["b"] 2
在 group_level=2
上
["a","b"] 2
["a","c"] 1
["b","a"] 2
使用参数 group=true
使其表现得好像它是 group_level=999
,因此在当前示例中,它将为每个键提供数字 1
,因为没有完全重复的键。
3.2.1.4. 归约/重新归约¶
我们简要地谈到了归约函数的 rereduce
参数。我们将在本节中解释它是什么。到目前为止,你应该已经了解到你的视图结果存储在 B 树索引结构中,以提高效率。rereduce
参数的存在和使用与 B 树索引的工作方式密切相关。
假设映射结果是
"afrikaans", 1
"afrikaans", 1
"chinese", 1
"chinese", 1
"chinese", 1
"chinese", 1
"french", 1
"italian", 1
"italian", 1
"spanish", 1
"vietnamese", 1
"vietnamese", 1
示例 1. 示例视图结果(嗯,食物)
当我们想要找出每个来源有多少道菜时,我们可以重复使用前面显示的简单归约函数
function(keys, values, rereduce) {
return sum(values);
}
图 2,“B 树索引”显示了 B 树索引的简化版本。我们缩写了键字符串。
视图结果是计算机科学专业毕业生所说的“前序”遍历树。我们从左到右查看每个节点中的每个元素。每当我们看到要下降到子节点时,我们就下降并开始读取该子节点中的元素。当我们遍历完整棵树时,我们就完成了。
你可以看到 CouchDB 在每个叶子节点中存储键和值。在我们的例子中,它始终是 1
,但你可能有一个值,你可以在其中计算其他结果,然后所有行都有不同的值。重要的是,CouchDB 将每个节点中的所有元素都运行到归约函数中(将 rereduce
参数设置为 false),并将结果存储在父节点中,以及指向子节点的边。在我们的例子中,每条边都有一个 3,代表它指向的节点的归约值。
注意
实际上,节点包含超过 1,600 个元素。CouchDB 对单个节点中的元素进行多次迭代,而不是一次性计算所有元素的结果(这将对内存消耗造成灾难性的影响)。
现在让我们看看运行查询时会发生什么。我们想知道我们有多少个“中国”条目。查询选项很简单:?key="chinese"
。请参见图 3,“B 树索引归约结果”。
CouchDB 检测到子节点中的所有值都包含“中国”键。它得出结论,它只需要使用与该节点关联的 3 个值来计算最终结果。然后它找到它左边的节点,并看到它是一个键在请求范围之外的节点 (key=
请求一个范围,其中开始和结束是相同的值)。它得出结论,它必须使用“中国”元素的值和另一个节点的值,并将它们运行到归约函数中,并将 rereduce
参数设置为 true。
归约函数在查询时有效地计算 3 + 1 并返回所需的结果。下一个示例显示了一些伪代码,这些伪代码显示了归约函数的最后一次调用,其中包含实际值
function(null, [3, 1], true) {
return sum([3, 1]);
}
现在,我们说你的归约函数必须真正归约你的值。如果你看到 B 树,你应该清楚地知道当你没有归约你的值时会发生什么。考虑以下映射结果和归约函数。这次我们想要获取视图中所有唯一标签的列表
"abc", "afrikaans"
"cef", "afrikaans"
"fhi", "chinese"
"hkl", "chinese"
"ino", "chinese"
"lqr", "chinese"
"mtu", "french"
"owx", "italian"
"qza", "italian"
"tdx", "spanish"
"xfg", "vietnamese"
"zul", "vietnamese"
我们不关心键,只列出我们拥有的所有标签。我们的归约函数删除重复项
function(keys, values, rereduce) {
var unique_labels = {};
values.forEach(function(label) {
if(!unique_labels[label]) {
unique_labels[label] = true;
}
});
return unique_labels;
}
这转化为图 4,“溢出的归约索引”。
我们希望你能明白。B 树存储的工作方式意味着,如果你没有在 reduce 函数中实际减少数据,最终会导致 CouchDB 复制大量数据,这些数据会随着视图中行数的增加而线性增长,甚至更快。
CouchDB 将能够计算最终结果,但仅适用于包含少量行的视图。任何更大的视图都会遇到极慢的视图构建时间。为了解决这个问题,从 0.10.0 版本开始,CouchDB 会在你的 reduce 函数没有减少其输入值的情况下抛出错误。
3.2.1.5. 单个设计文档与多个设计文档¶
一个常见的问题是:我应该何时将多个视图拆分为多个设计文档,或者将它们放在一起?
你创建的每个视图对应一个 B 树。单个设计文档中的所有视图都将驻留在磁盘上的同一组索引文件中(每个数据库分片一个文件;在 2.0+ 版本中默认情况下,每个节点 8 个文件)。
将视图分离到单独文档中最实用的考虑因素是更改这些视图的频率。经常更改的视图,如果与其他视图位于同一个设计文档中,则在写入设计文档时会使其他视图的索引失效,迫使它们全部从头开始重建。显然,你希望在生产环境中避免这种情况!
但是,当你在一个设计文档中有多个具有相同 map 函数的视图时,CouchDB 会进行优化,只计算一次该 map 函数。这使你可以拥有两个具有不同 _reduce 函数的视图(例如,一个使用 _sum
,另一个使用 _stats
),但只构建一个映射索引的副本。它还节省了磁盘空间和写入多个副本到磁盘的时间。
在同一个设计文档中拥有多个视图的另一个好处是,索引文件可以保留一个从 docid 到行的反向引用索引。CouchDB 需要这些“反向引用”来在删除文档时使视图中的行失效(否则,删除操作将强制进行完全重建!)。
另一个需要考虑的是,每个单独的设计文档都会生成另一个(组)couchjs
进程来生成视图,每个分片一个。根据你的服务器上的核心数量,这可能是有效的(使用你所有的空闲核心)或无效的(使你的服务器上的 CPU 过载)。具体情况取决于你的部署架构。
那么,你应该使用一个还是多个设计文档?选择权在你。
3.2.1.6. 经验教训¶
如果你没有在 map 函数中使用 key 字段,你可能做错了。
如果你试图在 reduce 函数中使值列表唯一,你可能做错了。
如果你没有将你的值减少到单个标量值或一个小的固定大小的对象或数组,其中包含固定数量的小尺寸标量值,你可能做错了。
3.2.1.7. 总结¶
Map 函数是无副作用的函数,它们接受一个文档作为参数并 emit 键值对。CouchDB 通过构建一个排序的 B 树索引来存储发射的行,因此可以通过键进行行查找,以及跨行范围进行流操作,可以在较小的内存和处理占用空间内完成,而写入操作可以避免查找。生成视图需要 O(N)
,其中 N
是视图中的总行数。但是,查询视图非常快,因为即使 B 树包含许多键,它仍然很浅。
Reduce 函数对 map 视图函数发射的排序行进行操作。CouchDB 的 reduce 功能利用了 B 树索引的一个基本属性:对于每个叶节点(一个排序的行),都有一条通往根节点的内部节点链。B 树中的每个叶节点都包含一些行(数量级为几十,具体取决于行的大小),每个内部节点可能链接到一些叶节点或其他内部节点。
为了计算最终的 reduce 值,reduce 函数会按顺序对树中的每个节点执行。最终结果是一个 reduce 函数,它可以在 map 函数发生变化时进行增量更新,同时仅重新计算最少数量的节点的 reduce 值。初始 reduce 操作会对树中的每个节点(内部节点和叶节点)执行一次。
当在叶节点(包含实际的 map 行)上运行时,reduce 函数的第三个参数 rereduce
为 false。在这种情况下,参数是 map 函数输出的键和值。该函数有一个返回的 reduce 值,该值存储在工作集叶节点共有的内部节点上,并在将来的 reduce 计算中用作缓存。
当 reduce 函数在内部节点上运行时,rereduce
标志为 true
。这使函数能够考虑到它将接收自己的先前输出。当 rereduce
为 true 时,传递给函数的值是来自先前计算的缓存的中间 reduce 值。当树的深度超过两层时,rereduce 阶段会重复进行,消耗先前级别输出的块,直到在根节点计算出最终的 reduce 值。
CouchDB 新用户常犯的一个错误是试图使用 reduce 函数构建复杂的聚合值。完整的 reduce 操作应该产生一个标量值,例如 5,而不是一个包含一组唯一键及其计数的 JSON 哈希。这种方法的问题在于,最终会得到一个非常大的最终值。即使对于大型数据集,唯一键的数量也可能接近总键的数量。将一些标量计算组合到一个 reduce 函数中是可以的;例如,在一个函数中查找一组数字的总和、平均值和标准差。
如果你有兴趣推动 CouchDB 的增量 reduce 功能的极限,可以看看 Google 关于 Sawzall 的论文,其中给出了在具有类似约束的系统中可以实现的一些更奇特的 reduce 操作的示例。