結果分頁

在大多數搜尋應用程式中,會向人類使用者顯示「最符合」的結果(依分數或其他準則排序)。

在許多應用程式中,這些排序結果的 UI 會以「頁面」顯示給使用者,每個頁面包含固定數量的符合結果,使用者通常不會查看前幾頁結果以外的結果。

基本分頁

在 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,因為它現在是所有符合結果的排序集中的第 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 萬個文件。對於一個有 10 個分片的索引,必須檢索和排序 1000 萬個條目,才能找出符合這些查詢參數的 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. 包含 score 虛擬欄位的 sort 子句也應在多副本 SolrCloud 部署中謹慎使用。

    分數取決於詞彙統計資訊,而這些資訊可能因副本而異,導致同一個文件在每個副本中的分數不同。因此,如果一系列游標標記請求由不同的副本提供服務,則可能會無意中跳過或重複文件。

    希望在游標標記請求上使用 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 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 處理器可能會是一個選項。