结果分页

在大多数搜索应用程序中,将按得分或其他标准排序的“顶部”匹配结果显示给某个人类用户。

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

基本分页

在 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 name

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 已被跳过,因为它是所有匹配结果的排序集中现在的第五个文档——它将在“第 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 无法计算哪个匹配文档是按排序顺序排列的第 999001 个结果,而无需首先确定前 999000 个匹配排序结果。

如果索引是分布式的(在 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 时所示,则可能已跳过一些匹配的文档。此外,如果 cursorMark 匹配 nextCursorMark,则无法确定是否没有更多结果。

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

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

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

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

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

游标标记值是根据结果中每份文档的排序值计算的,这意味着具有相同排序值的多个文档将生成相同的游标标记值(如果其中一个文档是结果页面上的最后一份文档)。在这种情况下,使用该 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 name

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 处理程序 可能是一种选择。