老司機使用 Redis 快取複雜查詢

發表於2016-04-19

最近上線了一個複雜的報表, 這個報表後面是一個幾百行的 sql 查詢,很不幸但又是預料之中, 這個 sql 查詢效能非常低下,並且需要在網站的一個訪問量非常大的頁面顯示這個 sql 的查詢結果。幸運的是這個查詢結果不需要
實時更新,只要每天更新一次即可, 於是為這個 sql 查詢加上快取就成為了一個很好的優化方法。開始我們使用 Rails.cache 來快取這個查詢結果,Rails.cache 的 backend 配置如下:

從上面的程式碼可以看出我們使用了 couchdb 作為 Rails.cache 的 backend, 我開始不太清楚為什麼會使用 couchdb, 因為我們的系統中已經使用了 Redis, 並且 Redis 無論是使用舒適度還是效能都不輸 couchdb, 後來我開啟 Gemfile 發現:

我們看到 redis-rails 和 redis-store 都被加在了 Gemfile 裡,然後又被註釋掉了,由此我估計前面的同事也想使用 Redis, 但是由於我們的 Rails 版本過老(現在 Rails 5 都發布了,我們還在使用 Rails 3), 導致 redis-rails 和 redis-store 無法使用,而我們既不想冒升級 Rails 的風險(這個升級的跨度有點大了), 也不願意花時間去改造 redis-store 使其相容 Rails 3(每天改 ticket 已經讓人心力交瘁了,這個藉口讓自己無法反駁)。 報表上線之初,沒有什麼問題,後來隨著資料量變大,發現報表展示的速度變慢但由於還可以容忍,也就沒有去花時間去研究速度變慢的原因,直到不久執行 couchdb 的機器莫名當機,造成整個網站 502(前端請求拿不到快取就會去讀資料庫,這個查詢很耗時就一直掛著,請求量一大資料庫就受不住了,導致整個網站不可訪問), 這時候我們決定重新設計下這個報表的快取。我們的設計如下:

  1. 使用 Redis 替換 couchdb, 主要原因是對 Redis 熟悉,並且系統中的很多非同步佇列服務用的是 Redis,非常穩定。
  2. 前端請求只能從 Redis 中讀取已經被快取的查詢結果,而不能直接讀資料庫,如果快取為空則返回空陣列,這樣做是為了防止資料庫被大量的耗時請求拖垮,保證整個網站的可訪問性。
  3. 快取的過期時間設定為 999 天,其實就是快取不過期的意思,並且寫一個 rake task 每天執行一次用於更新 Redis 快取。

這個設計的 2 和 3 步其實就是一個典型的生產&消費模式, rake task 作為生產者每天定時生成一次查詢結果存入 Redis, 前端請求作為消費者通過讀取 Redis 獲得查詢結果供頁面展示。

有了設計我們並不急於編寫程式碼,而是畫一個設計圖,一方面是為了梳理下思路看看設計是否會有缺陷,另一方面是為了更好地編寫程式碼。

設計圖如下:Snip20160326_36

通過設計圖我們可以看到資料是單向流動的, 這樣生產者和消費者是互不干擾的隔離狀態, 前端請求不生產資料,只從 Redis 中拿資料,這樣情況下前端請求對資料庫的訪問壓力幾乎為0。從設計圖中我們也可以看出我們的程式碼大概會
分成三部分:

  1. 生產者的程式碼
  2. Redis的程式碼(主要是讀寫 Redis)
  3. 消費者的程式碼

其中生產者和消費者都依賴 Redis 的程式碼, 因為兩者都需要和 Redis 產生互動。

Redis 相關程式碼

前面說過由於我們使用的 Rails 版本過低, 將 Redis 整合到 Rails.cache 會是一件比較費力費時的事情,所以我們將直接使用 Reids。

首先配置 Redis,

從上面的程式碼中可以看到我們定義了一個全域性變數 $redis 用來訪問 Redis。

接下來是將 $redis 封裝到一個 service 中,這樣做的目的是方便進行測試,也便於使用及以後的擴充套件。

這樣我們就完成了 Redis 這部分的程式碼,接下來是生產者的程式碼。

生產者程式碼

我們將和報表相關的業務和邏輯封裝到了一個叫 StmReport 的模型中, 我們為 StmReport 定義了一個 class 方法: warm_cache 用於生產報表資料。

接著我們編寫一個 rake task, 並且使用 crontab 每天定時執行此 rake task 用於生產資料。

生產者的程式碼也完成,接下來是消費者的程式碼。

消費者的程式碼

在本文中,消費的過程即建立報表的過程, 建立報表的過程很自然地也封裝到了 StmReport 模型中,

這樣整個實現就完成了,重新上線後的報表執行地非常穩定迅速,證明這個實現是成功的。

相關文章