Solr結(jié)果分頁

2018-11-29 14:36 更新

在大多數(shù)搜索應(yīng)用程序中,“top” 匹配結(jié)果(按分?jǐn)?shù)或其他標(biāo)準(zhǔn)排序)將顯示給某些用戶。

在許多應(yīng)用程序中,這些排序結(jié)果的用戶界面以“頁面”顯示給用戶,其中包含固定數(shù)量的匹配結(jié)果,而用戶通常不會查看經(jīng)過前幾頁結(jié)果的結(jié)果。

基本分頁

在 Solr 中,使用 start 和 rows 參數(shù)支持這種基本的分頁搜索,通過使用 queryResultCache 和根據(jù)預(yù)期的頁面大小調(diào)整 queryResultWindowSize 配置選項,可以調(diào)整這種常見行為的性能。

基本分頁示例

談到基礎(chǔ)的分頁,最簡單的方法就是將所需的頁碼乘以每頁的行數(shù) (將第一頁的頁碼視為 "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 參數(shù)指示客戶端希望 Solr 用作當(dāng)前“頁面”開頭的完整排序匹配列表中的絕對 “偏移量”。

如果索引修改 (如添加或刪除文檔) 影響與查詢匹配的有序文檔的順序,則會在客戶端的兩個請求之間發(fā)生,從而導(dǎo)致后續(xù)頁的結(jié)果,那么這些修改可能會產(chǎn)生在多個頁上返回的同一文檔,或者當(dāng)結(jié)果集收縮或增大時,文檔被 "跳過"。

例如,考慮一個包含 26 個文檔的索引,如下所示:

ID 名稱

1

A

2

B

...

...

26

Z


后跟以下請求和索引修改交錯:

  • 客戶請求
     q=:&rows=5&start=0&sort=name asc 
    帶有 1-5 的 ID 的文檔將返回給客戶端
  • ID 為 3 的文檔被刪除
  • 客戶端請求“page#2” 使用
    q=:&rows=5&start=5&sort=name asc

    文件 7-11 將被退回;
    已跳過文檔 6,因?yàn)樗F(xiàn)在是所有匹配結(jié)果的排序集合中的第5個文檔 - 它將在 “page#1” 的新請求上返回。

  • 現(xiàn)在添加了ID為90,91以及92的3頁新的文件;這三個文件都有一個名稱
  • 客戶端請求“第3頁”使用:
    q=:&rows=5&start=10&sort=name asc
    文檔9、10和11已在 page #2 和 page #3 中返回,因?yàn)樗鼈円频搅伺判蚪Y(jié)果列表中的更遠(yuǎn)的后面。

在典型的情況下,從索引更改對分頁搜索的影響不會顯著影響用戶體驗(yàn) - 因?yàn)樗鼈冊谙喈?dāng)靜態(tài)的集合中極少發(fā)生,或者是因?yàn)橛脩粽J(rèn)識到數(shù)據(jù)集合不斷發(fā)展并期望看到文檔在結(jié)果集中上下移動。

“深度分頁”的性能問題

在某些情況下,Solr 搜索的結(jié)果不適用于簡單的分頁用戶界面。

當(dāng)您希望從 Solr 中獲取大量的排序結(jié)果,并將其輸入到外部系統(tǒng)中時,為 startor rows 參數(shù)使用非常大的值可能是非常低效的。分頁使用 start 和 rows 不僅要求 Solr 計算(和排序)在內(nèi)存中應(yīng)為當(dāng)前頁面提取的所有匹配文檔,而且還需要在以前的頁面上出現(xiàn)的所有文檔。

雖然請求 start=0&rows=1000000 可能顯然是低效率的,因?yàn)樗?Solr 維護(hù)和排序一百萬份文檔,同樣 start=999000&rows=1000,由于同樣的原因,請求同樣是低效的。Solr 無法計算出排序順序中的哪個匹配文檔是 999001 個結(jié)果,而無需先確定前 999000 個匹配排序結(jié)果是什么。

如果索引是分布式的(在 SolrCloud 模式下運(yùn)行時常見),則從每個分片中檢索一百萬個文檔。對于十個分片索引,必須檢索和排序一千萬個條目以找出與這些查詢參數(shù)匹配的 1000 個文檔。

獲取大量排序結(jié)果:Cursor

作為增加 “start” 參數(shù)以請求后續(xù)頁的排序結(jié)果的替代方法,Solr 支持使用 “Cursor” 掃描結(jié)果。

Solr 中的 Cursor 是一個邏輯概念,不涉及在服務(wù)器上緩存任何狀態(tài)信息。而是使用返回給客戶端的最后一個文檔的排序值來計算表示排序值的有序空間中的邏輯點(diǎn)的“mark”。這個“mark”可以在隨后的請求參數(shù)中指定,告訴 Solr 在哪里繼續(xù)。

使用 Cursor

要在 Solr 中使用 Cursor ,請指定具有 \* 值的 cursorMark 參數(shù)。您可以把這 start=0 看作是告訴 Solr “在我的排序結(jié)果開始處開始”的一種方法,它也告訴 Solr 您想使用一個 Cursor。

除了返回前 N 個排序結(jié)果(可以使用 rows 參數(shù)控制 N )之外,Solr 響應(yīng)還將包括一個名為 nextCursorMark 的編碼字符串。然后從響應(yīng)中取 nextCursorMark 字符串值,并將其作為cursorMark 參數(shù)傳遞回 Solr 作為下一個請求。您可以重復(fù)這個過程,直到您已經(jīng)獲取盡可能多的文檔,或者直到返回的 nextCursorMark 與已指定的 cursorMark 匹配為止,這表示沒有更多的結(jié)果。

使用 Cursor 時的約束

在 Solr 請求中使用 cursorMark 參數(shù)時需要注意一些重要的約束條件:

  1. cursorMark 和 start 是互斥的參數(shù)。
    您的請求必須不包含 start 參數(shù),或者必須使用值 “0” 指定。
  2. sort 子句必須包含 uniqueKey 字段(asc 或者 desc)。
    如果 id 是您的 uniqueKey 字段,那么類似 id asc、name asc、id desc 的參數(shù)將工作正常,但 name asc 本身不會
  3. 排序包括基于日期數(shù)學(xué)的函數(shù),涉及與 NOW 相關(guān)的計算將導(dǎo)致混淆的結(jié)果,因?yàn)槊總€文檔將在每個后續(xù)請求中獲得新的排序值。這很容易導(dǎo)致永遠(yuǎn)不會結(jié)束的 Cursor,并且不斷地返回相同的文檔 - 即使文檔從不更新。在這種情況下,為所有 Cursor 請求中的 "NOW" 請求參數(shù)選擇和重用一個固定值。

游標(biāo)標(biāo)記值是根據(jù)結(jié)果中每個文檔的排序值計算出來的,這意味著如果多個具有相同排序值的文檔中的一個是結(jié)果頁面上的最后一個文檔,則會產(chǎn)生相同的 Cursor 標(biāo)記值。在這種情況下,使用 cursorMark 的后續(xù)請求將不知道具有相同標(biāo)記值的哪個文檔應(yīng)該被跳過。要求將 uniqueKey 字段作為排序標(biāo)準(zhǔn)中的一個子句使用,可以確保返回一個確定性排序,并且每個 cursorMark值都將標(biāo)識文檔序列中的一個唯一點(diǎn)。

Cursor 示例

獲取所有文檔

此處顯示的偽代碼顯示了使用 Cursor 獲取與查詢匹配的所有文檔時涉及的基本邏輯:

// 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 個文檔,基于 Post 處理

由于從 Solr 的角度來看,游標(biāo)是無狀態(tài)的,所以一旦您確定有足夠的信息,您的客戶端代碼就可以停止獲取額外的結(jié)果:

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;
}

索引更新如何影響 Cursor

與基本分頁不同,Cursor 分頁不依賴于在完成的匹配文檔的排序列表中使用絕對“偏移量”。相反,請求中指定的 cursorMark 將根據(jù)該文檔的絕對排序值封裝返回的上一個文檔的相對位置信息。這意味著,與基本分頁相比,使用 Cursor 時,索引修改的影響要小得多??紤]在討論基本分頁時所描述的相同示例索引:

ID 名稱

1

A

2

B

...

...

26

Z

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

    帶有 1-5 的 ID 的文檔將返回給客戶端

  • ID 為 3 的文檔被刪除
  • 客戶端使用前一個響應(yīng)中的 nextCursorMark 請求5個以上的文檔
    文檔6-10將被返回 - 刪除已經(jīng)返回的文檔不會影響 Cursor 的相對位置
  • 現(xiàn)在添加了ID為90,91以及92的3頁新的文檔;這三個文檔都有一個名稱。
  • 客戶端使用前一個響應(yīng)中的 nextCursorMark 請求5個以上的文檔
    文檔 11-15 將被返回 - 添加已通過排序值的新文檔不會影響 Cursor 的相對位置
  • ID 為1的文檔更新為將其 “name” 更改為 Q 
  • ID 為17的文檔更新為將其 “name” 更改為 A
  • 客戶端使用前一個響應(yīng)中的 nextCursorMark 請求5個以上的文檔
    生成的文檔以 16、1、18、19、20的順序排列;
    由于文檔1的排序值已更改, 使其位于 Cursor 位置之后, 因此文檔將兩次返回給客戶端;
    由于文檔17的排序值已經(jīng)改變,所以在 Cursor 位置之前,文檔已被“跳過”,并且不會因?yàn)?nbsp;Cursor 繼續(xù)進(jìn)行而返回給客戶端

簡而言之:當(dāng)獲取與使用 cursorMark 匹配的查詢的所有結(jié)果時,索引修改的唯一方式可能導(dǎo)致被跳過的文檔或返回兩次,如果文檔的排序值發(fā)生更改。

確保文檔永遠(yuǎn)不會被返回的一種方法是將 uniqueKey 字段用作主要(因此是唯一有效的)排序標(biāo)準(zhǔn)。

在這種情況下,您將保證每個文檔只返回一次,無論它如何在使用 Cursor 時被修改。

“拖放” Cursor

由于 Cursor 請求是無狀態(tài)的,并且 cursorMark 值封裝了從搜索返回的上一個文檔的絕對排序值,所以可以“繼續(xù)”從已經(jīng)達(dá)到其結(jié)尾的 Cursor 獲取附加結(jié)果。如果添加新文檔(或更新現(xiàn)有文檔)到結(jié)果的末尾。

您可以把它看作類似于在 Unix 中使用 “tail -f” 的東西。如何在索引中添加/更新文檔時,如果有 "時間戳" 字段記錄,則最常見的示例是如何使用此方法??蛻舳藨?yīng)用程序可以使用匹配查詢的文檔的 sort=timestamp asc, id asc 連續(xù)輪詢 Cursor,并且在添加或更新符合請求條件的文檔時總是會收到通知。

另一個常見的例子是,當(dāng)您創(chuàng)建新文檔時 uniqueKey 值始終增加,并且您可以使用 sort=id asc 連續(xù)輪詢游標(biāo)以獲得有關(guān)新文檔的通知。

拖放 Cursor 的偽代碼只是我們早期處理與查詢匹配的所有文檔的一個小修改:

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 處理程序可以是一個選擇。

以上內(nèi)容是否對您有幫助:
在線筆記
App下載
App下載

掃描二維碼

下載編程獅App

公眾號
微信公眾號

編程獅公眾號