结果分页

在大多数搜索应用程序中,“顶部”匹配结果(按分数或其他标准排序)会显示给最终用户。

在许多应用程序中,这些排序结果的用户界面会以“页面”的形式显示给用户,每个页面包含固定数量的匹配结果,并且用户通常不会查看前几页以外的结果。

基本分页

在 Solr 中,这种基本分页搜索使用 startrows 参数支持,并且可以通过利用 queryResultCache 并根据您期望的页面大小调整 queryResultWindowSize 配置选项来调整这种常见行为的性能。

基本分页示例

考虑简单分页的最简单方法是,只需将您想要的页码(将“第一”页码视为“0”)乘以每页的行数;例如,在以下伪代码中

function fetch_solr_page($page_number, $rows_per_page) {
  $start = $page_number * $rows_per_page
  $params = [ q = $some_query, rows = $rows_per_page, start = $start ]
  return fetch_solr($params)
}

基本分页如何受索引更新影响

在发送到 Solr 的请求中指定的 start 参数指示客户端希望 Solr 用作当前“页”起点的完整排序匹配列表中的绝对“偏移量”。

如果索引修改(例如添加或删除文档)影响了匹配查询的排序文档序列,发生在客户端对后续结果页面的两次请求之间,那么这些修改可能会导致同一文档在多个页面上返回,或者文档由于结果集缩小或增大而被“跳过”。

例如,考虑一个包含 26 个文档的索引,如下所示

id 名称

1

A

2

B

…​

26

Z

然后,以下请求和索引修改交错进行

  • 客户端请求 q=*:&rows=5&start=0&sort=name asc

    • id 为 1-5 的文档将返回给客户端

  • 删除文档 id 3

  • 客户端使用 q=*:&rows=5&start=5&sort=name asc 请求“第 2 页”

    • 将返回文档 7-11

    • 文档 6 已被跳过,因为它现在是所有匹配结果的排序集中第 5 个文档——它将在对“第 1 页”的新请求中返回

  • 现在添加了 3 个新文档,id 为 909192;所有三个文档的名称都是 A

  • 客户端使用 q=*:&rows=5&start=10&sort=name asc 请求“第 3 页”

    • 将返回文档 9-13

    • 文档 91011 现在在第 2 页和第 3 页上都返回了,因为它们在排序结果列表中向后移动了

在典型情况下,索引更改对分页搜索的这些影响不会显着影响用户体验——要么是因为它们在相当静态的集合中极少发生,要么是因为用户认识到数据集合在不断演变,并期望看到文档在结果集中上下移动。

“深度分页”的性能问题

在某些情况下,Solr 搜索的结果并非注定用于简单的分页用户界面。

当您希望从 Solr 中获取大量排序结果以馈送到外部系统时,使用非常大的 startrows 参数值可能会非常低效。使用 startrows 进行分页不仅需要 Solr 在内存中计算(和排序)所有应为当前页面获取的匹配文档,还需要计算所有会出现在之前页面上的文档。

虽然请求 start=0&rows=1000000 显然效率低下,因为它需要 Solr 在内存中维护和排序 100 万个文档,但同样,请求 start=999000&rows=1000 出于相同的原因也效率低下。Solr 无法在不先确定前 999000 个匹配的排序结果的情况下,计算出哪个匹配文档是排序后的第 999001 个结果。

如果索引是分布式的(在 SolrCloud 模式下运行时很常见),则会从**每个分片**检索 100 万个文档。对于一个包含十个分片的索引,必须检索和排序一千万个条目才能找出与这些查询参数匹配的 1000 个文档。

获取大量排序结果:游标

作为增加 “start” 参数以请求后续排序结果页面的替代方法,Solr 支持使用 “游标” 来扫描结果。

Solr 中的游标是一个逻辑概念,不涉及在服务器上缓存任何状态信息。相反,客户端返回的最后一个文档的排序值用于计算表示排序值有序空间中逻辑点的 “标记”。可以在后续请求的参数中指定该 “标记”,以告知 Solr 从哪里继续。

使用游标

要在 Solr 中使用游标,请使用值 * 指定 cursorMark 参数。您可以将其视为类似于 start=0 的方式,告诉 Solr “从我的排序结果的开头开始”,只不过它还通知 Solr 您要使用游标。

除了返回前 N 个排序结果(您可以使用 rows 参数控制 N)之外,Solr 响应还将包含一个名为 nextCursorMark 的编码字符串。然后,您将响应中的 nextCursorMark 字符串值作为下一个请求的 cursorMark 参数传递回 Solr。您可以重复此过程,直到获取了所需的文档数量,或者直到返回的 nextCursorMark 与您已指定的 cursorMark 匹配,表明没有更多结果为止。

使用游标时的约束

在 Solr 请求中使用 cursorMark 参数时,需要注意以下几个重要约束。

  1. cursorMarkstart 是互斥的参数。

    • 您的请求要么不包含 start 参数,要么必须指定值为 “0”。

  2. 使用 timeAllowed 请求参数时,可能会返回部分结果。如果在搜索完成之前时间过期(如 responseHeader 中包含 "partialResults": true 所指示的那样),则可能会跳过一些匹配的文档。此外,如果 cursorMarknextCursorMark 匹配,则不能确定没有更多结果。

    在这种情况下,请考虑增加 timeAllowed 并重新发出查询。当 responseHeader 不再包含 "partialResults": true,并且 cursorMarknextCursorMark 匹配时,则没有更多结果。

  3. sort 子句必须包含 uniqueKey 字段(ascdesc)。

    如果 id 是您的 uniqueKey 字段,则像 id ascname asc, id desc 这样的排序参数都可以正常工作,但 name asc 本身则不行

  4. 包含 基于日期数学 且涉及相对于 NOW 的计算的排序,将会导致令人困惑的结果,因为每个文档在每个后续请求中都将获得新的排序值。这很容易导致游标永远不会结束,并不断重复返回相同的文档,即使这些文档从未更新过。

    在这种情况下,请在所有游标请求中为 NOW 请求参数 选择并重用一个固定值。

  5. 在多副本 SolrCloud 部署中,包含 score 伪字段的 sort 子句也应谨慎使用。

    分数依赖于词项统计信息,这些信息可能因副本而异,导致同一文档在每个副本中的得分不同。因此,如果请求由不同的副本提供服务,则一系列游标标记请求可能会无意中跳过或重复文档

    希望在游标标记请求中使用 score 作为排序标准的 SolrCloud 用户可以通过使用 PULL 类型副本或确保一系列游标标记请求都由同一副本处理来避免这些问题。

游标标记值是根据结果中每个文档的排序值计算的,这意味着如果具有相同排序值的多个文档中的一个是结果页面的最后一个文档,则会产生相同的游标标记值。在这种情况下,后续使用该 cursorMark 的请求将不知道应该跳过哪个具有相同标记值的文档。要求 uniqueKey 字段用作排序标准中的一个子句,可保证返回确定性排序,并且每个 cursorMark 值都将标识文档序列中的唯一点。

游标示例

获取所有文档

此处显示的伪代码显示了使用游标获取所有与查询匹配的文档所涉及的基本逻辑

// when fetching all docs, you might as well use a simple id sort
// unless you really need the docs to come back in a specific order
$params = [ q => $some_query, sort => 'id asc', rows => $r, cursorMark => '*' ]
$done = false
while (not $done) {
  $results = fetch_solr($params)
  // do something with $results
  if ($params[cursorMark] == $results[nextCursorMark]) {
    $done = true
  }
  $params[cursorMark] = $results[nextCursorMark]
}

使用 SolrJ,此伪代码将是

SolrQuery q = (new SolrQuery(some_query)).setRows(r).setSort(SortClause.asc("id"));
String cursorMark = CursorMarkParams.CURSOR_MARK_START;
boolean done = false;
while (! done) {
  q.set(CursorMarkParams.CURSOR_MARK_PARAM, cursorMark);
  QueryResponse rsp = solrServer.query(q);
  String nextCursorMark = rsp.getNextCursorMark();
  doCustomProcessingOfResults(rsp);
  if (cursorMark.equals(nextCursorMark)) {
    done = true;
  }
  cursorMark = nextCursorMark;
}

如果您想使用 curl 手动执行此操作,则请求序列如下所示

$ curl '...&rows=10&sort=id+asc&cursorMark=*'
{
  "response":{"numFound":32,"start":0,"docs":[
    // ... 10 docs here ...
  ]},
  "nextCursorMark":"AoEjR0JQ"}
$ curl '...&rows=10&sort=id+asc&cursorMark=AoEjR0JQ'
{
  "response":{"numFound":32,"start":0,"docs":[
    // ... 10 more docs here ...
  ]},
  "nextCursorMark":"AoEpVkRCREIxQTE2"}
$ curl '...&rows=10&sort=id+asc&cursorMark=AoEpVkRCREIxQTE2'
{
  "response":{"numFound":32,"start":0,"docs":[
    // ... 10 more docs here ...
  ]},
  "nextCursorMark":"AoEmbWF4dG9y"}
$ curl '...&rows=10&sort=id+asc&cursorMark=AoEmbWF4dG9y'
{
  "response":{"numFound":32,"start":0,"docs":[
    // ... 2 docs here because we've reached the end.
  ]},
  "nextCursorMark":"AoEpdmlld3Nvbmlj"}
$ curl '...&rows=10&sort=id+asc&cursorMark=AoEpdmlld3Nvbmlj'
{
  "response":{"numFound":32,"start":0,"docs":[
    // no more docs here, and note that the nextCursorMark
    // matches the cursorMark param we used
  ]},
  "nextCursorMark":"AoEpdmlld3Nvbmlj"}

根据后处理获取前 *N* 个文档

由于从 Solr 的角度来看游标是无状态的,因此一旦您确定拥有足够的信息,您的客户端代码就可以停止获取其他结果

while (! done) {
  q.set(CursorMarkParams.CURSOR_MARK_PARAM, cursorMark);
  QueryResponse rsp = solrServer.query(q);
  String nextCursorMark = rsp.getNextCursorMark();
  boolean hadEnough = doCustomProcessingOfResults(rsp);
  if (hadEnough || cursorMark.equals(nextCursorMark)) {
    done = true;
  }
  cursorMark = nextCursorMark;
}

索引更新如何影响游标

与基本分页不同,游标分页不依赖于使用到匹配文档的已完成排序列表中的绝对 “偏移量”。相反,请求中指定的 cursorMark 封装了关于返回的最后一个文档的**相对**位置的信息,基于该文档的**绝对**排序值。这意味着与基本分页相比,使用游标时索引修改的影响要小得多。考虑在讨论基本分页时描述的相同示例索引

id 名称

1

A

2

B

…​

26

Z

  • 客户端请求 q=:&rows=5&start=0&sort=name asc, id asc&cursorMark=*

    • ID 为 1-5 的文档将按顺序返回给客户端

  • 删除文档 id 3

  • 客户端使用上一个响应中的 nextCursorMark 请求另外 5 个文档

    • 将返回文档 6-10 - 删除已返回的文档不会影响游标的相对位置

  • 现在添加了 3 个新文档,id 为 909192;所有三个文档的名称都是 A

  • 客户端使用上一个响应中的 nextCursorMark 请求另外 5 个文档

    • 将返回文档 11-15 - 添加排序值已过去的的新文档不会影响游标的相对位置

  • 更新文档 ID 1 以将其 “名称” 更改为 Q

  • 更新文档 ID 17 以将其 “名称” 更改为 A

  • 客户端使用上一个响应中的 nextCursorMark 请求另外 5 个文档

    • 结果文档按顺序为 16,1,18,19,20

    • 由于文档 1 的排序值发生了变化,使其位于游标位置之后,因此该文档会两次返回给客户端

    • 由于文档 17 的排序值发生了变化,使其位于游标位置之前,因此该文档已被 “跳过”,并且当游标继续前进时不会返回给客户端

简而言之:当使用 cursorMark 获取与查询匹配的所有结果时,索引修改导致文档被跳过或返回两次的唯一方法是文档的排序值发生更改。

确保文档永远不会被返回多次的一种方法是将 uniqueKey 字段用作主要(因此也是唯一的)排序标准。

在这种情况下,您可以保证每个文档只返回一次,无论在使用游标期间如何修改它。

“追踪” 游标

由于游标请求是无状态的,并且 cursorMark 值封装了从搜索返回的最后一个文档的绝对排序值,因此可以从已经到达终点的游标 “继续” 获取其他结果。如果将新文档添加到结果末尾(或更新现有文档)。

您可以将其视为类似于在 Unix 中使用 “tail -f”。当您有一个 “时间戳” 字段记录文档何时在您的索引中添加/更新时,这是最常见的有用示例。客户端应用程序可以持续轮询使用 sort=timestamp asc, id asc 的游标,以获取与查询匹配的文档,并在添加或更新与请求条件匹配的文档时始终收到通知。

另一个常见的示例是,当您的 uniqueKey 值始终随着新文档的创建而增加时,您可以持续轮询使用 sort=id asc 的游标,以接收关于新文档的通知。

用于追踪游标的伪代码与我们早期用于处理与查询匹配的所有文档的示例相比,只有略微修改

while (true) {
  $doneForNow = false
  while (not $doneForNow) {
    $results = fetch_solr($params)
    // do something with $results
    if ($params[cursorMark] == $results[nextCursorMark]) {
      $doneForNow = true
    }
    $params[cursorMark] = $results[nextCursorMark]
  }
  sleep($some_configured_delay)
}
对于某些特殊情况,/export 处理程序可能是一个选项。