API 分頁探討:offset 來分頁真的有效率?

對於設計和實現 API 來說,當結果集包含成千上萬條記錄時,返回一個查詢的所有結果可能是一個挑戰,它給服務器、客戶端和網絡帶來了不必要的壓力,於是就有了分頁的功能。

通常我們通過一個 offset 偏移量或者頁碼來進行分頁,然後通過 API 實現類似請求:

GET /api/products?page=10
{"items": [...100 products]}

如果要繼續訪問後續數據,則修改分頁參數即可。

GET /api/products?page=11
{"items": [...another 100 products]}

在使用 offset 的情況下,通常使用 ?offset=1000 和 ?offset=1100 這種大家都熟悉的方法。它要麼直接調用 OFFSET 1000 LIMIT 100 的 SQL 查詢數據庫,要麼使用 LIMIT 乘以 page 作爲查詢參數。

無論如何,「這是一個次優的解決方案」,因爲無論哪種數據庫都要跳過前面 offset 指定的 1000 行。而跳過額外的offset,不管是 PostgreSQL,ElasticSearch還是 MongoDB 都存在額外開銷,數據庫需要對它們進行排序,計數,然後將前面不用的數據扔掉。

這是一種低效的方法,但由於它使用簡單,所以大家重複地用這個方法,也就是直接把 API 參數映射到數據庫查詢上。

那合適的方法是什麼?介紹之前我們可以先看看數據庫的實現。在數據庫中有一個遊標(cursor)的概念,它是一個指向行的指針,然後可以告訴數據庫:"在這個遊標之後返回 100 行"。這個指令對數據庫來說很容易,因爲你很有可能通過一個索引字段來識別這一行。然後就不需要去取和跳過前面那些沒用到的記錄了。

舉個例子。

GET /api/products
{"items": [...100 products],
 "cursor""qWe"}

API 返回一個無業務意義的字符串(遊標),你可以用它來檢索下一個頁面。

GET /api/products?cursor=qWe
{"items": [...100 products],
 "cursor""qWr"}

實現遊標有很多方法。一般來說,可以通過一些排序字段比如產品 id 來實現。在這種情況下,你可以用一些可逆算法對產品 id 進行編碼。而在接收到一個帶有遊標的請求時,你會對它進行解碼,並生成一個類似 WHERE id > :cursor LIMIT 100 的查詢。

下面是一個小小的性能對比,先看看 offset 是如何工作:

=# explain analyze select id from product offset 10000 limit 100;
                                                           QUERY PLAN                                                            
---------------------------------------------------------------------------------------------------------------------------------
 Limit  (cost=1114.26..1125.40 rows=100 width=4) (actual time=39.431..39.561 rows=100 loops=1)
   ->  Seq Scan on product  (cost=0.00..1274406.22 rows=11437243 width=4) (actual time=0.015..39.123 rows=10100 loops=1)
 Planning Time: 0.117 ms
 Execution Time: 39.589 ms

再看看 where (cursor) 語句如何工作:

=# explain analyze select id from product where id > 10000 limit 100;
                                                          QUERY PLAN                                                          
------------------------------------------------------------------------------------------------------------------------------
 Limit  (cost=0.00..11.40 rows=100 width=4) (actual time=0.016..0.067 rows=100 loops=1)
   ->  Seq Scan on product  (cost=0.00..1302999.32 rows=11429082 width=4) (actual time=0.015..0.052 rows=100 loops=1)
         Filter: (id > 10000)
 Planning Time: 0.164 ms
 Execution Time: 0.094 ms

這是幾個數量級的差異! 當然,實際的差異取決於表的大小以及過濾器和存儲的實現。有一篇不錯的文章 (1) 提供了更多的技術信息,裏面有 ppt,性能比較見第 42 張幻燈片。

(1)   https://use-the-index-luke.co...

當然,用戶不會按 id 來檢索商品,而是會按一些相關性來查詢(然後按 id 作爲關聯字段)。在現實世界中,需要根據你的業務來決定該怎麼做。訂單可以按 id 排序(因爲它是單調增加的)。購買清單可以按 wishlist 時間排序。在我們的案例中,產品來自 ElasticSearch,自然支持遊標的特性。

我們可以看到的一個不足是,使用無狀態的 API, 無法支持翻到“上一頁”這樣的功能。所以在面向用戶界面中,如果有 prev/next 或者 “直接進入第10頁” 這樣的按鈕,就沒有辦法繞過前面提到的 offset/limit 這種實現。但是在其他情況下,使用基於遊標的分頁可以極大地提高性能,特別是在真正的大表和真正的深度分頁上。

英文原文:

https://solovyov.net/blog/202...

HackerNews 評論:

https://news.ycombinator.com/...

HN網友 et1337:

使用遊標的另一個原因是避免由於併發編輯而導致元素重複或跳過的問題,比如你使用 offset 正在第 10 頁上,而有人在第 1 頁上刪除了一個項目,則整個列表會移動,你可能會意外跳過第 11 頁上的一行數據。同樣,如果有人在第 1 頁上添加了一條記錄而你正在第 10 頁上,第 10 頁中的一項也會重複顯示在第 11 頁上。

遊標優雅地迴避了這些問題。

HN 網友 chrismorgan:

有時候,你需要一個遊標,這樣你就可以從你剛纔的地方繼續前進,而不用擔心新的記錄進來擾亂你的分頁。

有時你想要基於位置的查詢,因爲你明確地希望所有的東西都是位置的。

有時你想把這兩種技術結合起來,例如,如果你跳到一個大的、不斷變化的列表中間,然後想在剛纔的位置之後檢索下一批結果。

我喜歡 JMAP 最後的設計(https://tools.ietf.org/html/r...:你可以指定一個位置整數,或者一個錨 ID 和可選的 anchorOffset 整數。錨是遊標的一種實現,它使用結果集中一個實體 ID,而不是一個可以嵌入其他信息(比如 coroutine 地址)的不透明類型,,它有一個明顯的優點,就是可以由客戶端控制。

HN 網友 vincnetas

我認爲作者在使用 OFFSET 時忽略了一些關鍵點。至少 postgres 文檔對此有明確的的說法(https://www.postgresql.org/do...

When using LIMIT, it is important to use an ORDER BY clause that constrains the result rows into a unique order. Otherwise you will get an unpredictable subset of the query's rows. You might be asking for the tenth through twentieth rows, but tenth through twentieth in what ordering?

看起來作者提供的分頁查詢沒有考慮到排序,這意味着第 100 頁上的項目的 ID 大於 10000,但順序未定義。

explain analyze select id from product where id > 10000 limit 100

HN 網友 boulos

鑑於對“遊標”一詞的重用感到困惑,我更喜歡 Google 爲分頁所使用的術語:頁面令牌和頁面大小,詳細可以參閱:

https://google.aip.dev/158

image

 

image

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章