RavenDB 的索引存储
《RavenDB 实战》一章节摘录
![]() |
RavenDB 的索引存储
作者:Itamar Syn-Hershko,《RavenDB 实战》作者 在 RavenDB 中,索引在回答查询方面起着至关重要的作用。没有索引,除了文档 ID 之外,不可能基于任何其他信息查找数据,因此 RavenDB 就变成了一个臃肿的键/值存储。索引是解决数据丰富查询并能高效满足查询需求的关键。在这篇文章中,作者 Itamar Syn-Hershko 基于《RavenDB 实战》第三章,解释了 RavenDB 中的索引是如何工作的。 |
RavenDB 有一个专门的文档存储,称为“文档存储”(Document Store)。文档存储有一个重要特性——它非常高效地通过 ID 检索文档。然而,这也是它唯一的特性,也是它查找文档的唯一方式。它只能有一个文档的键,而这个键始终是文档 ID;无法根据任何其他条件检索文档。
当您需要根据除 ID 之外的某些搜索条件从文档存储中检索文档时,文档存储本身就变得毫无用处。为了能够使用文档的其他属性来检索文档,您需要有索引。这些索引与文档分开存储,我们称之为“索引存储”(Index Store)。
在本文中,您将了解 RavenDB 中的索引和索引过程。
RavenDB 索引过程
我们不妨假设,此刻我们的数据库中只有文档存储,里面有数百万个文档,现在我们要处理一个用户查询。文档存储本身帮不了我们太多忙,因为查询中不包含文档 ID。我们该怎么办?
一种选择是遍历系统中的所有文档,逐一检查它们是否与查询匹配。这样做当然可行,前提是发出查询的用户足够耐心,能够在大系统中等待几个小时。但没有用户会这样做。为了高效地满足用户查询,我们需要对数据进行索引。通过使用索引,软件可以更高效地执行搜索,更快地完成查询。
在查看这些索引之前,我们先考虑一下何时会构建或更新索引,以包含新进来的文档。如果我们选择在用户发出查询时计算索引,我们又会延迟返回结果。这比遍历所有文档的开销要小得多,但它仍然是用户每次查询都要承担的性能损耗。
另一种,可能更明智的选择是在用户插入新文档时更新索引。乍一看,这确实更合理,但当您开始考虑每次“put”操作更新多个复杂索引所需的工作量时,它就会变得不那么有吸引力了。在实际系统中,这意味着写入操作会花费大量时间,因为不仅要写入文档,还要更新所有索引。此外,还有一个关于事务的问题:当索引更新过程中发生故障时会发生什么?
RavenDB 做出了一个有意识的设计决定,即不让任何等待因索引而产生。不应该有任何等待,无论是在您请求数据时,还是在其他操作期间,例如向存储添加新文档时。
那么,索引 *何时* 更新呢?
RavenDB 有一个后台进程,它会接收新文档和文档更新(在它们被存储在文档存储中之后),并将它们分批处理到系统中的所有索引。对于写入操作,用户在索引进程开始处理这些更新之前,就能立即收到事务确认——无需等待索引,但可以确定更改已记录在数据库中。查询也不会等待索引;它们只使用查询发出时存在的索引。这确保了所有方面的平稳运行,并且没有任何文档被遗漏。您可以在图 1 中看到这一点。
听起来好得令人怀疑,不是吗?显然,有弊端。由于索引是在后台完成的,当有足够的数据进来时,该过程可能需要一段时间才能完成。这意味着新文档可能需要一段时间才能出现在查询结果中。虽然 RavenDB 经过高度优化以尽量减少这种情况,但它仍然可能发生。当这种情况发生时,我们说索引结果是“过时的”(stale)。这是设计使然,我们将在本文末尾讨论其含义。
什么是索引?
考虑以下书籍列表。
如果我问你 J.K. Rowling 所著书籍的价格,或者列出所有页数超过 600 页的书籍,你会如何找到答案?显然,当列表只有 10 本书时,遍历整个列表并不算太麻烦,但随着列表的增长,它会很快成为一个问题。
索引只是一种帮助我们更快地回答这类问题的方法。它就是创建一个列表,列出所有可能的值,按其上下文分组,并按字母顺序排序。因此,书籍列表将变成以下值列表,每个值都附有它来自的书籍编号。
由于值是按上下文(标题、作者姓名等)分组并按字母顺序排序的,因此即使有数百万本书,现在也很容易通过任何这些值来查找一本书。您只需转到相应的列表(例如,作者姓名),然后查找该值。一旦在列表中找到该值,就会返回与它关联的书籍编号,您可以使用该编号获取实际书籍,如果您需要更多关于它的信息。
RavenDB 使用 Lucene.NET 作为其索引机制。Lucene.NET 是流行的开源搜索引擎库 Lucene 的 .NET 移植版。Lucene 最初用 Java 编写,于 2000 年首次发布,是领先的开源搜索引擎库。Twitter、LinkedIn 等大公司都使用它来使其内容可搜索,并且它在不断改进。
Lucene 索引
由于 RavenDB 索引实际上是 Lucene 索引,在我们更深入地研究 RavenDB 索引之前,我们需要熟悉一些 Lucene 的概念。这将有助于我们了解底层工作原理,并使我们能够更好地处理 RavenDB 索引。
在 Lucene 中,被索引的基本实体称为“文档”(document)。每次搜索都会产生一个匹配文档的列表。在我们的例子中,我们搜索书籍,所以每本书就是一个 Lucene 文档。就像书籍有标题、作者姓名、页数等一样,我们通常需要能够搜索具有多个信息片段的文档。为此,Lucene 中的每个文档都有“字段”(fields)的概念,字段只是我们索引的文档不同部分之间的逻辑分离。Lucene 文档中的每个字段都可以包含关于文档的不同信息,这些信息以后可用于搜索。
将这些概念应用到我们的例子中,在 Lucene 中,每本书就是一个文档,而每个书籍文档将包含标题、作者姓名、价格和页数等字段。Lucene 创建一个带有多个值列表的索引,每个字段一个,就像图 3 所示一样。为了完善画面,放入索引的每个值(在一个值列表中,例如,作者姓名字段中的“Dan Brown”来自 2 本书)都称为一个“术语”(term)。
搜索是通过术语和字段名称进行的,以查找匹配的文档,如果文档在其搜索字段中包含指定的术语,则该文档被视为匹配,如下面的伪查询所示:所有作者字段值为“Dan Brown”的书籍文档。Lucene 允许对同一字段甚至不同字段进行多子句查询,因此“作者是 Dan Brown 或 J.K. Rowling,且价格低于 50 美元的书籍”之类的查询完全受支持。
RavenDB 中的索引只是一个标准的 Lucene 索引。文档存储中的每个 RavenDB 文档都可以通过创建一个 Lucene 文档来索引。该 Lucene 文档中的一个字段将成为我们正在索引的 RavenDB 文档的可搜索部分。例如,博客文章的标题、实际内容和发布日期,每个都将成为一个字段。
查询是针对一个索引、一个或多个字段、每个字段一个或多个术语进行的。接下来,您将看到这到底是如何实现的。
从 RavenDB 存储的文档创建 Lucene 文档的过程——从原始结构化 JSON 到平面结构的文档和字段——称为 map/reduce。这是一个两阶段过程,首先从文档中提取实际数据(称为“映射”,Mapped),然后可选地对其进行处理或转换为其他内容(称为“归约”,Reduced)。从下一节开始,我们将逐步了解 RavenDB 的 map/reduce 过程,并深入理解它。
最终一致性
在我们开始查看实际索引之前,请允许我暂停一两分钟,讨论异步索引的含义。正如我们在本章开头所解释的,RavenDB 中的索引在后台进行,因此在繁忙的系统中,新数据可能需要一段时间才能出现在查询结果中。行为方式如上所述的数据库被称为“最终一致”(eventually consistent),这意味着在某个时间点,新数据保证会出现在查询中,但不能保证立即出现。
乍一看,查询结果过时似乎并不那么吸引人。我为什么要使用一个不总是正确回答查询的数据库?
这主要是因为我们习惯了以 ACID(原子性、一致性、隔离性、持久性)属性为核心实现的数据库。特别是关系数据库是 ACID 的,并且始终保证结果一致。在我们的语境下,这意味着您发送的每个查询都将始终返回最新的结果,换句话说,它们是“立即一致”(immediately consistent)的。如果查询发出时文档存在于数据库中,则保证它会出现在所有匹配的查询中。
但这是真正必需的吗?文档存储是立即一致的,查询是最终一致的:在 RavenDB 中,最终一致性仅在查询时出现。通过 ID 加载文档始终是立即一致的,并且完全兼容 ACID。
即使结果已知 100% 准确且永远不会过时,就像在任何 SQL 数据库中一样,在数据从服务器传输到用户屏幕所需的时间,加上用户阅读、处理数据并采取行动所需的时间内,服务器上的数据也可能已经更改而用户不知道。当存在高网络延迟或缓存时,这种情况更加可能。而且,如果用户在请求那页数据后去喝咖啡怎么办?
在现实世界中,在商业层面,大多数查询结果都是过时的,或者应该被视为过时的。
尽管第一反应是抵触,但当这种情况真正发生在我们身上时,我们通常不会抗拒,甚至会忽略它。以亚马逊为例:将商品添加到购物车并不能确保您能够购买它。当您结账时,它仍然可能缺货。即使您结账付款后,它也可能缺货,在这种情况下,亚马逊的客户关系部门会很乐意退款,甚至为您提供礼品卡作为补偿。这是否意味着亚马逊不负责任?不。这是否意味着您被欺骗了?绝对不是。这只是他们能够高效地跟踪如此庞大系统库存的唯一方式,而我们作为用户几乎从未感觉到这种情况发生。
现在,考虑一下您自己的系统,以及您的数据在显示时需要多么及时。您是否可以接受查询结果显示的数据在几毫秒前是 100% 准确的?可能可以。半秒呢?一秒?五秒?一分钟?
如果一致性确实很重要,您不会接受任何微小的差距,而如果您可以接受一些过时,您可能也能接受更多。您越深入思考,越会意识到拥抱它而不是与之对抗是明智的。
说到底,没有哪家企业会拒绝一笔交易,即使它可能是通过电子邮件或传真进行的,而库存数据已经发生变化。每一个客户都很重要,最糟糕的情况可能只是一个道歉。而这正是过时的查询结果所代表的。
事实上,我们付出了很多努力来确保索引过时的时段尽可能短。由于大量的优化,您将看到的许多过时索引是新创建的索引,在数据量很大的实时系统中,或者当有许多索引并且大量新数据持续涌入时。在大多数安装中,索引在大多数时间是非过时的,当它们确实过时时,您可以安全地忽略它们仍在追赶的事实。对于那些绝对需要考虑过时结果的情况,RavenDB 提供了一种方法来知道结果何时过时,并且还可以等待查询返回非过时结果。
摘要
拥有一个可扩展的键/值存储数据库固然很好,但索引才是真正让 RavenDB 与众不同的地方。索引使查询成为可能且高效,索引越灵活,您拥有的查询可能性就越多。
在本文中,我们为理解 RavenDB 中的索引奠定了基础,并熟悉了 RavenDB 新颖的索引方法。我们讨论了异步索引过程以及可能出现过时结果的可能性,尽管在大多数实际情况中您几乎不会注意到这一点。
![]() |
创建 iPhone 应用 |
![]() |
Windows Phone 7 实战 |
![]() |
C# 深入解析(第三版) |