優化Laravel Pagination的效能(Macro+Redis)

Laravel 提供簡單好用的 API,
比如它的 ORM 讓你能用 paginate() 拿到 LengthAwarePaginator
它的 links() 能直接 render 出分頁功能的 HTML

專案上線 N 年後,資料量大了,可能會遇到分頁越來越慢的狀況,
這篇文章會描述一下我考慮解決方法的思路

介紹Pagination

Laravel 有一套完整的 ORM,通常在查詢的最後我們會用 get() 取出符合條件的資料
也可以換成 paginate() ,就會得到 LengthAwarePaginator 這個裝著分頁資料的物件
如果想在 HTML 呈現所在頁面、上一頁、下一頁等效果,可以對物件呼叫 links()
如果想在 JSON 呈現目前分頁的資訊,可以對物件呼叫 toJson()

寫法
輸出HTML的效果類似這樣

可以直接快取Pagination的結果嗎?

我覺得不太好,因為:
若我們按照資料的建立時間 descending 排序,
當有使用者新增資料,則新的資料會列在第一筆,而每一頁的筆數是固定的,所以第一頁原本的最後一筆資料會變成第二頁的第一筆,以此類推,每一頁的快取都要重來。

Pagination隨著時間變慢的原因在哪?

實際上,我工作上維護的一個論壇最近頻繁的發生網站回應緩慢的情況,
仔細研究之後發現瓶頸在:我們在抓取文章清單時的 query 有點多,而且資料量大
因為它是一個讀多寫少的網站,我們後續的優化會根據這個前提進行

另外因為我有使用 laravel-debugbar
這個工具會在每個頁面加入一條工具列,裡面塞滿本次 Request/Response 的資訊,包括所有 SQL query。
這讓我發現每次的 paginate() 都會包含以下步驟:

  1. 879: 根據 where 條件做 count(),這個 count 是用來計算並顯示總共有幾頁
  2. 887: 如果前面的數字大於 0,則用 sql limit、offset 抓出該頁的資料

第一步驟的優化

通常 SQL count() 會執行的很慢,而我在 StackOverflow 有找到合理的解釋,看起來在這加上 cache 就可以緩解。況且即使使用者無法拿到最新的 count,代價頂多是使用者會晚幾分鐘看到最新的「最後一頁」。

第二步驟的優化

第二步才是真正做到抓取目前分頁資料的步驟,但也不能直接對這個步驟加上快取。原因如同在上一個章節提到的,當使用者新增資料時,我們希望讓使用者幾乎即時看到新增的資料。但系統仍能做快取,並且成本不要太瘋狂。

為了能比較好的控制快取行為,我把第二步再細分為兩個步驟:

  • 列出符合條件的 primary key(Listing)
  • primary key 一筆一筆讀出這些資料(Reading)

如果寫成 SQL 應該會像下面這樣:

SELECT id FROM articles WHERE status = 'published';
(Application 收到 id 清單,並且將它當作參數放到下面的 query)
SELECT * FROM articles WHERE id IN (1,2,3,4,5);

這麼做的好處有兩個:

  • 若我們不快取「列出符合條件的 primary key」,則能保證使用者永遠看到最新的分頁結果(而且這個可以另外找方式優化)
  • 清單中的每一筆資料(以及要一併帶出來的資料)都可以單獨快取。比如說作者更新文章標題,我們可以單獨移除這篇文章的快取。在下一位訪客瀏覽該分頁的時候,系統只需要重新快取這篇文章的資料。

此時 pseudo code 應該如下:

cachedPaginate(cacheKey, ...args)
  var counts = cache(cacheKey+"_counts", this->count(), 10); // 10 mins
  var idList = this->pluck("id");
  var results = for idList as id: cache(cacheKey+"_items_"+id, this->item(id))
  return new Paginator(counts, results);

實作的方式

藉由 Builder::macro() 我們可以為 Builder 加入新的 function:cachedPaginate()
然後這個 cachedPaginate() 的第一個參數是新加入的 cacheKey,顯然是用來快取
然後我們只要把所有 query->paginate() 都換成 query->cachedPaginate() 即可

成效與下一步

之後有時間再補實際的數字吧,總之是有效的

不過還記得 Listing 這步驟沒有被優化嗎?

之前讀到 Dcard團隊的分享時有一些啟發:Listing 可以不只是單純的 SQL limit + offset,而是一個服務
以我的場景來說,我可以用 cronjob 定期把 Listing 結果塞進 Redis,再用 LRANGE 直接抓該目前分頁的 id 清單。在下一次更新之前,如果有使用者發文則用 LPUSH,如果有使用者刪文則用 LREM


日期

作者

留言

發佈留言

發佈留言必須填寫的電子郵件地址不會公開。 必填欄位標示為 *