Rails 的 cache 介紹二:網頁 caching

紅寶鐵軌客
來關注...
關注/停止關注:紅寶鐵軌客
關注有什麼好處?:當作者有新文章發佈時,「思書」就會自動通知您,讓您更容易與作者互動。
現在就加入《思書》,你就可以關注本作者了!
《思書》是一個每個人的寫作與論壇平台,特有的隱私管理,讓你寫作不再受限,討論更深入真實,而且免費。 趕快來試試!
還未加入《思書》? 現在就登錄! 已經加入《思書》── 登入
寫程式中、折磨中、享受中 ......
3.18k   0  
·
2019/02/03
·
33分鐘


前一篇文章介紹 cache store,如果你還未看,我建議先看,很多設定與選擇要做:

Rails 的 cache 介紹一:cache stores — 在 Rails ,最讓其他平台使用者攻擊的就是網站執行效率,效率這件事,有很多影響因素,像是 Ruby 的慢就是其中一個重要因素,但是...
Scrivinor 思書: 紅寶鐵軌客

知道了 Rails 的 cache stores 是什麼了後,當然就要知道怎麼用了,網頁 caching 最主要就是要讓 Rails 的網頁變快,越快越好,只是我們一般都不會把網頁效率寫就規格內,都是等到被客戶嫌的時候,才開始改,畢竟太多變數了,硬體軟體都會參一腳,硬體難改很貴,苦工就是我們寫程式的了,有沒有一個速度的準蛇呢?我看了一些網路討論,一般都會希望使用者的每一網頁動作能在一秒內完成,要做到這一點,東扣西扣,每一個網頁的 server responses 就會被希望能在 300ms 完成,算是一個參考吧。

要優化速度為什麼要用 cache?很簡單,因為 cache 應該是最簡單的速度優化方法,Ruby 本來就不快,最好的提速方法就是讓它跑越少行越好,所以,這就帶出了什麼是 Cache 的主要觀念了:

程式跑一次,將結果存起來(cache),下次還是一樣時、沒過期,就直接讀 cache 的資料!

所以,cache 有兩大重點:

  1. 存取 fetch
  2. 過期判斷 expiration 

知道這樣大該就可以開始寫 cache 了,反正,一定一大堆問題,Rails guide 的 Cache 篇還是要讀的,不過很難讀,不能只靠這篇啦:Caching with Rails: An Overview — Ruby on Rails Guides

在開始寫之前,還有一個問題,我們怎麼知道,減肥前跟減肥後,到底差在那裡?所以我們需要一個「秤」,最簡單的秤就是你的 Log,Rails 已經提供了一寫簡單的效率數據,你可以再你的 server log 中看到類似以下的資料:

Completed 200 OK in 1971ms (Views: 1490.5ms | ActiveRecord: 208.9ms)

這就是告訴你:你這一網頁總共花了快兩秒,view template(例如:index.html.erb) 花了快 1.5 秒產生,你的資料庫花了 0.2 秒,這數字並不是很準,ActiveRecord 的數字只有 DB 的讀寫時間,ActiveRecord 的轉換、lazy loading 等,應該都沒算入,不過,聊勝於無!要更準一些,也好讀一些嗎?來來來,裝個 profiling 工具!

 

Profiling 效率工具

你當然可以把「全部」的網站都 cache 起來,但是真的不用啦,只要找出那幾個最慢的部分,把它 cache 起來,基本上,就大功告成了,畢竟,打蛇打七寸,抓元兇就好,只是,誰是戰犯呢?這個抓戰犯的行為,就叫 profiling!

Rails 最強大的地方就是社群,有一個叫做 rack-mini-profiler 的 profiling gem 非常好用:

MiniProfiler/rack-mini-profiler — Profiler for your development and production Ruby rack apps. - MiniProfiler/rack-mini-profiler
GitHub

安裝也簡單,只要在 gemfile 裡面加上,記住,要加在 pg/sql gem 的後面,他會改 DB 設定:

gem 'rack-mini-profiler'

然後 bundle install,鐺鐺!你就會在開發環境的網頁左上角,看到一個像下圖的小標簽了:

是的,那個數字就是你這一頁網頁跑了多久,點一下它,你還可以看到分析,多方便啊!如果你還沒裝,我強烈的建議你一定要裝,這個 mini profiler 可一點都不迷你,只有顯示的很迷你,他有很多功能,也可以加很多外掛,功能強大,詳細的介紹與使用,就請自己看這個 gem 的說明吧,關於 mini profiler 中顯示的數字是什麼,以下這篇文章講得很清楚:rack-mini-profiler - the Secret Weapon of Ruby and Rails Speed

 

Fragment Caching

我們先來介紹 Fragment(片段) Caching,事實上好像也只介紹這個,也假設你已經選用了 File Store 當 cache 的店(為什麼?因為這是初學者應該要用的,還是不知道為什麼?請看前篇),我們不介紹 action caching 跟 page caching 了,反正:一、Rails 4+ 以後也不支援這兩種東東;二、用的地方不多,除非你的網頁超級簡單,如果超簡單,那還要 cache 嗎?

絕大多數的動態網頁(就是在說 Rails)都是由一堆 Fragments 組成的,一般一個網頁都會分成什麼版頭、上面下面中間的,這就是 Fragment,當各個 Fragment 產生後的結果先存起來(就是 cache),下次,我們就檢查,「原來生產這些 Fragment 的變數」,也沒有改變,還是一樣時(沒改變),就直接讀 cache 的資料!有改變,就從新做一個 Fragment,就這樣,就可以用 Fragment Caching!

讀得懂嗎?一定有點昏昏的,什麼跟什麼嘛,這也是我寫這篇文章的主因,一堆文章都用講的,到底實作時,怎麼做?有那些要注意的?來,放碼!

這是在 Rails guide 上的說明程式舉例:

<% @products.each do |product| %>
  <% cache product do %>
     <%= render product %>
  <% end %>
<% end %>

很清楚的說明了,你只要把要 cache 的內容,用第二行的:cache do ... end 包起來,你的 cache 就完成了,超級簡單的,但是是這樣嗎?答案不是! 這第二行中間的 product 是一個很重要的關鍵,你必須了解,它叫做「key」,這種 cache 也就是你會常在 cache 行為中聽到的「key base」cache!

這個 key 很重要,你可以有很多的方式來產生,最重要要懂得它的觀念,這個 key 要能夠獨特的代表你所 cache 的內容,也就是說,這個 key 以後,就是代表這段 cache do ... end 的內容,如果 key 的「值」沒改變,我們就不執行 cache do ... end 所包起來的程式碼,直接讀取使用我們以前產生的 cache 內容,如果「值」改變了,我們就會執行 cache do ... end ,重新產生 cache 內容。詳細的說明,可以看一下它的 Helper 文件說明: cache (ActionView::Helpers::CacheHelper) - APIdock: ,我們來看一下一些實務上常用的例子:

用網址:<% cache do %>
  <% cache do %>
     <%= render product %>
  <% end %>

這最簡單,你什麼 key 都沒用,這時,Rails 會自作聰明的以你目前的 URL 內容,當成 key!注意看一下你的 log,你的 log 會有類似的下面這幾行:

Read fragment views/0.0.0.0:3000/?p=1/0a6ad29a15c98c67517b6df008206c92 (0.3ms)
Write fragment views/0.0.0.0:3000/?p=1/0a6ad29a15c98c67517b6df008206c92 (3.2ms)

註:如果你在 log  中沒看到這些,那你可能沒有在 config 中,設定要顯示: config.action_controller.enable_fragment_cache_logging = trueRails 5.1 後,預設就是關閉的。

你可以到目錄 tmp/cache/ 下,找到產生 cache 的內容,他在 file store 中會隨機產生的兩層數字 xxx/xxx 目錄下(我怎麼也找不到這兩層目錄的命名規則,好像就是隨機,但是,隨機要怎麼找?這對我是個謎!,有那位大師知道,請務必告知),檔名就是 views%2F0.0.0.0%3A3030%....,你可以打開這個檔案來看,哈,就是那一個 fragment 的 html 嘛,只是前面跟後面加了一些 cache 用的識別碼,是的,cache 就這麼簡單,它就是把要 cache 的這部分 html 存起來,如果 key 找得到,就用這個,找不到,就在產生一個。

檔名的組成也很有意義,它就是 "view" + URL + template digest 所產生,等等!什麼是 template digest?它是一個「值」,依照你的「整頁」網頁 template 內容,用 md5 計算後,自動產生的,目的是當你改了這個網頁的 template 時,rails 也才會知道要產生新的 cache 內容,要注意的是,他不只是計算 cache 內的 template 「值」,它會計算的是「整頁」的 template 內容相關連的 template,會算一大堆,做這的目的是為確保當你改變網頁內的 template 時,cache 會即時更新,但是這也產生了要怎麼知道網頁相關聯(template dependencies)的問題,我們等一下會介紹

如果你的這個網頁非常簡單,這個網址剛好就是獨特的代表你所要 cache 的內容,那這就是你要用的,超級簡單,只是,我想絕大多數都不會是這樣,因為我們在做的是 fragment cache,這樣做基本像是一個 page caching,rails 4.0 以後就不提供內建的 page caching,但是你如果有需要,還是有個以下的 gem 可以使用:

rails/actionpack-page_caching — Static page caching for Action Pack (removed from core in Rails 4.0) - rails/actionpack-page_caching
GitHub

 
用單一變數:<% cache product do %>
  <% cache product do %>
     <%= render product %>
  <% end %>

如果你的這部分的網頁就只是由單一個變數所產生,如上面的例子,就是只用到一個 active record 的 product 變數,這時,你的 key 就可以很簡單的用它來代表,這時,你的 log 會有類似的下面這幾行:

Read fragment views/product/2-20171205142950564034/5a3a91de1ff58805e8a0147bb255026b (1.7ms)
Write fragment views/product/2-20171205142950564034/5a3a91de1ff58805e8a0147bb255026b (23.8ms)

同樣的,你也可以到目錄 tmp/cache/ 下,找到相對應產生的 cache 內容,注意到,他的檔案名稱,也就是 key,變了,是的,他的檔案名稱變成由 active record 變數所產生了,他是由:"view" + class + 這個 active record 的 id + record 的 updated_at datetime stamp + 先前說的 template digest 所產生。

這時,cache 與否的判斷就只有依靠 product 這個 active record 的 ID 與 updated_at 來判斷了,我們知道,當我們跟改了任一個 active record,它的 update_at 就會記錄下改的時間,所以我們很確定,當我們跟新了這筆 product 的內容時,cache 一定會更新! 只是,如果你的fragment 內容不只是依靠這個 product 的值,還會依據別的變數改變,那不幸的,你的 fragment 內容就被 product 鎖死了,這筆 product 的內容沒改變時,它就會一直給你一樣的內容,為了要讓 cache 的內容能夠依其他變數重新 caching,更多的 caching 選項就來了。

 

用多個變數:<% cache [I18n.locale.to_s, product] do %>
  <% cache [I18n.locale.to_s, product] do %>
     <%= render product %>
  <% end %>

前面這個例子是用 array,你也可以自組 key 如:<% cache "product-#{product.id}" do %>,在很多的網頁實務裡,這才是大部分正常的使用狀況,這代表你的這部分的網頁就是由多個變數所產生,如上面的例子,就是用到一個 active record 的 product 變數,再加上一個多國語言識別的變數,這時,你的 key 就是由這兩個變數所組成,同樣的,這時,你的 log 會有類似的下面這幾行:

Read fragment views/zh-TW/product/2-20171205142950564034/83a7468774f5df1931eb89d098e16a62 (0.5ms)
Write fragment views/zh-TW/product/2-20171205142950564034/83a7468774f5df1931eb89d098e16a62 (17.8ms)

注意到了沒,它的「key」現在多了一個語言的語言別變數值,同樣的,你也可以到目錄 tmp/cache/ 下,找到 cache 存的資料,這種用法應該是最常用的,有人因爲很煩,每次都要加 I18n.locale,乾脆寫了一個 gem(如下),它會自動加上 I18n.localte,要不要用就看你自己了,我是能少用一個 gem 就少用一個的人。

igorkasyanchuk/cache_with_locale — Contribute to igorkasyanchuk/cache_with_locale development by creating an account on GitHub.
GitHub

 
用指定的名稱:<% cache ‘name_of_cache’ do %>
  <% cache ‘name_of_cache’ do %>
     <%= render product %>
  <% end %>

這種用法很有趣,就是一個全手動的觀念,它最大的好處是因為 key 就是指定的那麼一個,所以不用管 expired 後的刪除清理,但是,你卻要自己管理何時要 refresh 這個 cache 的內容,你可以在 model 中用 before_destroy 跟 after_create/update 的 callbacks 來呼叫 expire_fragement,舉例如下:

ActionController::Base.new.expire_fragment(‘name_of_cache’)

這種用法 rails 並不建議,基本上就是一個全手動的 cache 管理方式,但是,就如同我一開始說的, cache 絕不是一件簡單的事,有一個手動的選項,總是很好的,手動的 fragment cache 管理還有幾個好用的功能,像是建立 key、確定 key 的存在、讀寫 fragment cache 等,如果你決定要手動做 fragment cache,一定要看一下他的文件:ActionController::Caching::Fragments,這篇文章也值得一看:Caching in Ruby on Rails 5.2

 
Collection caching

就是指  render partial: xxx, collection: @collection ,這時使用 collection caching 很方便又效果好,但是只限於你是使用 partial collection rendering 的時候,也就是說,你只能在有 render partial: xxx, collection: @collection 的形況下使用,render @collection, cached: true 這種是不能用的。  cache 使用很簡單,就在後面加一個 cached: true,如下:

<%= render partial: 'products/product', collection: @products, cached: true %>

就這樣就好,夠簡單吧! 使用後,你在 log 中第一次 render 會看到:

Rendered collection of products/product.html.erb [0 / 8 cache hits] (519.2ms)

多了一個 x/x cache hits 的顯示,第一次當然都不中,refresh 網頁後,第二次,就發現全中了!

Rendered collection of products/product.html.erb [8 / 8 cache hits] (25.1ms)

很棒吧,這是效率很好的 cache,要多多使用!實務上,如果是 render partial with collection,就一定要用這個方法,Rails 內部有優化,效率很高,如果沒有 collection,就把整個 partial 包在一個 cache 內,那樣效果比較好,畢竟,partial 還是另外一個檔,讀取還是很花時間的,只是,世界常常就不會是完美的,render partial with collection 不支援過期 expires_in。  下面這篇文章有一些深入的效能數字比較,有興趣的可以看看:

Rails Collection Caching — In this article, we'll take a look at how Rails collection caching works and how we can use it to speed up large collection rendering.
AppSignal Blog

 
cache 一個 Query <% cache query.cache_key do %>

可以 cache 一組資料嗎?在 Rails 的官方 guide  上沒說,但是可以,這是在 Rails 5 以後新增的功能,也是 Rails 社群建議後產生的,很有趣的一個功能,但是使用起來要小心,我們就來看以下這個簡單個例子,你一看就懂了:

<% mc = Category.all.where("language = ?", (I18n.locale).to_s) %>
<% cache ["category", I18n.locale.to_s, mc.cache_key] do %>
  <% mc.each do |c| %>
      <%= link_to c.name.html_safe, category_path(id: c.id) %>
  <% end %>
<% end %>

乍看之下,跟以前單一變數長得幾乎一樣,只是這次變數內不是只有一筆資料了,而是一組 records,執行以上的程式後,會在 LOG 中看到以下的一個 Fragment key 產生:

views/category/en/categories/query-b179323bc4d3f0cf677118ed5fb76028-6-20191004093312714577/e651926d451a2da2815d43b775294a1f

views/category/en 我們已經介紹過了,重點在它的後面那串:

  • cateogries/query-b1......76028: 這是 Query 字串(就是 Category.all.where.... 啦)的 MD5
  • 6:是有六筆資料
  • 2019100.....577:是這些資料中,最後被改的 update_at 日期值

後面跟著的就是 Digest 的值了,這我們也介紹過了。

所以,如果 Query 字串沒變,找出來的資料也沒改變,這段 cache 就會直接用已經存好的 fragment 來取代,很方便吧,只是,這使用起來要小心,有一些情況下,這個 Fragment key 可能會遇到當資料已經改變時,產生的 key 卻是一樣的,以下這篇文章介紹的很清楚,我就不重複了,幾本上:

  • query 中有 limit() 時:含 Limit() 的 query 一定要先執行,不然產生的 key 會忽略 Limit(),還有,如果有單筆資料被刪除時,query cache 有使用 Limit() 時,會讓 key 不知不覺,不知道資料改了,這是因為 query 並沒改變,最後被改的 update_at 日期值也沒改變,limit() 讓筆數也沒改變,所以,key 就錯了。
  • query 中有 group():也有以上的問題。

詳細就看這篇 Mohit Natoo 的大作,解釋得很清楚:

Caching result sets and collection in Rails 5 | BigBinary Blog — Rails 5 provides cache_key for ActiveRecord::Relation that can help in caching result of a collection of records. BigBinary Blog

 

cache_if

我們還可以在程式中設定,用 cache_if 或是 cache_unless 來決定 cache 與否,實務上,我覺得用處不大,但是也許在開發時,是個好幫手。  下面的例子就是只會在 production 中 cache:

<% cache_unless Rails.env.production?, product do %>
  <%= render product %>
<% end %>

 

template digest:網頁關聯(template dependencies)問題

好了,終於,我們要來談 template digest 了,你在開發 Cache 時,很有可能會看到一個 Couldn't find template for digesting: xxx 的錯誤訊息,但是好像又對 cache 沒有什麼大影響,這時,你就是遇到網頁相關聯(template dependencies)問題了。 話說,我想大家都知道什麼是 template 吧?這裡指的是 view template,rails 的預設 template 就是那個 xxxx.html.erb 東東~

記得我們在之前談過,cache 後的檔名(或是 key)的組成,是由 "view" + 指定的變數內容 + template digest 所構成,而這 template digest 是用 md5 來算這「整頁」網頁的 template 及 其相關連的 template 後,所得到的值。 好啦,問題來了,Rails 怎麼知道這頁網頁是由那些 template 所組成呢? Rails 想要很聰明,也算很很厲害啦,它會沿著這一頁網頁中的每一個 render 去找到每一個指到的 template,而且會一層一層的找下去,只是,有時候,特別是你如果有用 render helper,Rails 會找不到 render 所用的 template,這時我們就要去告訴 Rails 那一個才是我們用的 template了!以下我借用 rails guide 的例子:

可以找到 template 的 rendering:
render partial: "comments/comment", collection: commentable.comments
render "comments/comments"
render 'comments/comments'
render('comments/comments')

render "header" => render("comments/header")

render(@topic) => render("topics/topic")
render(topics) => render("topics/topic")
render(message.topics) => render("topics/topic")
不能找到 templete 的 rendering:
render group_of_attachments
render @project.documents.where(published: true).order('created_at')
render_sortable_todolists @project.todolists

其中,像第一與第二行找不到時,你就要改寫成內含 template 位置的寫法(有一點不是很美了):

render partial: 'attachments/attachment', collection: group_of_attachments
render partial: 'documents/document', collection: @project.documents.where(published: true).order('created_at')

但是,第三行,就完全沒辦法改寫成內含 template 位置了,怎麼辦?這時,你就只能用很特別很怪的「註解指令」寫法來指定 template 的位置了⋯⋯ (真不美啊!)

<%# Template Dependency: todolists/todolist %>
<%= render_sortable_todolists @project.todolists %>

<%# Template Dependency: todolists/todolist %> 「註解指令」就是告訴 rails 你的 template 在那裡,這「# Template Dependency:」要照著打,多一字少一字都不行,很奇怪的問題解法,很不美。 這 「註解指令」還可以有鬼牌,如:<%# Template Dependency: events/* %>,也還有一個更鬼怪的:<%# Template Collection: notification %>,我真不知道何時會用到,就不介紹了。

 

強迫 template digest 更新

你只要改變了 template 中的內容,包含改變 remark 內容,template digest 就會從新計算,這是一個很方便的 cache 更新方法,但是,有一個情況,就是當你使用 helper 時,你可能改寫了 helper ,讓它產生新的內容,但是這時,Rails 的 cache 是完全不會知道的,因為 template 並沒有改變啊,簡單的說,Rails cache 是不會檢查 helper 有沒有改變的,那怎麼辦?我們再是借用 rails guide 中的例子:

<%# Helper Dependency Updated: May 6, 2012 at 6pm %>
<%= some_helper_method(person) %>

這時你就要強迫 template digest 更新,最簡單的方法就是改 remark 的內容,這也是 rails 的建議方式,rails 是建議加一個日期在 remark 內,當你更新 helper 後,記得改一下日期,這樣就會跟新 template digest 了,只是,這樣做真的太太麻煩了,再說,如果你這個 helper 用得到處都是時,那要改多少次啊! 這在 Rails 的 cache 中真的沒解,我能想到的唯一方法就是讓 cache 過期!expired 它,這樣至少如果忘了,一段時間後,也會被更新!來,讓我們先說完 template digest 相關的事,等一下,我們就來看怎麼讓 cache expired。 

 

註:當 log 中,出現 Couldn't find template for digesting: templates/template

你如果找了很久,都找不到你的那一個 render 裡有用到這個 view template 時:

  1. 那很有可能會是在你的 remark 中,例如:<%#= render @template %> ,這是一個 rails 的 bug,我想原因可能是 rails 在找尋網頁關聯時,要能讀取 remark 的內容,就如同上面所提到的,它會讀 <%# Template Dependency: todolists/todolist %> 用來指定 template 的檔案位置,我就遇到這樣的問題,連加兩個 ## 都沒用,最後只能把這個 remark 刪除,花了我好多時間,希望你不會遇到。
  2. 不要在 remark 中寫 render,改用 “rendering”,會讓你省掉很多麻煩。
  3. 也有可能 Rails 搞不清楚你要的 template 是那個,這時你就要指定 template.html.erb 或是 template.xml.erb 等了。
  4. 小小幫手:別忘了,你可以用 rake 來查看 template 的相關連,用法如下:
    • rake cache_digests:dependencies TEMPLATE=static_pages/home,
    • rake cache_digests:nested_dependencies TEMPLATE=static_pages/home
關掉 template digest 

有時候,如果你所要 cache 的內容與 template 無關,這時,你就可以關掉 template digest,很簡單,如下,加上一個 skip_digest: true 就好:

<% cache "no_template", skip_digest: true do %>

就這樣,這時你如果看你的 log ,就會發現,template digest 不見了!

有 digest 時: views/no_template/83a7468774f5df1931eb89d098e16a62
關掉 digest 時: views/no_template

 

Time Base! 設定過期時間的好處

設定過期時間的 cache 就叫做 time base cache,為什麼要設定過期時間?為什麼要設定這個 cache 的內容多久就過期?很多的 cache 的介紹不是都說不用管 cache 過期,大家都說 Rails cache 的好處就是你不用手動清除 Cache 的過期內容,連 DHH(Rails 創辦人) 在他的這篇介紹 key base cache expiration 的文章 中,也是這麼說的,其實,rails 的 作法是:舊的 cache 內容就讓它留著,交給 cache store 去負責清除它,但是他說的是那些可以設定店容量大小,而且裝滿後,會自動清理的店,像是 Memcached,不是 file store,file store cache 的內容可是會一直加到硬碟全滿,而且 file store 是預設在 production 環境中的 cache 店家,所以,在 file store 中,清除過期的 cache 內容是很重要的!

清理 file store 的 cache 就是一個很有趣的挑戰了,我的建議是:

  • 要讓所有的 file store cache 資料都有一個過期時間:讓每一筆 cache 的資料都有一個過期的時間設定,這樣我們就可以知道那些是舊的,那些是新的,也就可以定期的清除它,也許到時,這筆資料還是正確的,所花的代價就是會有新多產生一筆 cache 的時間,但是,至少,不會有一堆過期的資料而不知道如何清除。
  • 定期清除:可以做一個 crontab 來執行定期的清除,過期時間要設定多久就是一門藝術了,要看你的流量與資料異動來決定了,如果流量大、資料異動很少,那就可以把過期時間設為一天或是幾天,如果異動量很頻繁,就要設短;如果異動量很頻繁,流量又小,那就不要搞 cache 了啦。
  • 定期清除的好處:expired 也是一個保險,確定一段時間後,cache 的內容一定會更新!馬有失蹄,你也很難百分之百確定,你寫的 cache 一定會完全包含所有的異動更新,這時,定期的清除就有很大的好處,舊的 cache 過期後,新的一定要重新產生,這時,就一定會是對的,只是,會有一個時間差,會付出一點 cache 的 overhead (代價),不完美,但是是一個備援方案。
怎麼樣設定過期時間呢?
  1. 直接設在 config 中:config.cache_store = :file_store, "#{root}/tmp/cache/", { expires_in: 1.day }
  2. 每一筆分開設定:<% cache(product, :expires_in => 1.day) do %>

那一種好?我覺得直接設在 config 中好,可以確保不會忘了設定,程式碼也乾淨一些,你可以在 development 環境中設短一些,我是設一個小時,production 環境就可以設的長一些。  你也可以兩個都設定,當兩者都設定時,所以 <2> 的每一筆分開設定就會蓋過 <1> 的設定,文件上沒寫,但是我驗證過了。

puppy_pile / okayceeFlickr
Dog-pile effect 狗堆效應

在高流量的環境中,使用 expires_in 是一項具有挑戰的事,有一個可能就是在高流量的環境中,一個 cache 剛過期,其他多個多功運行中的程式就同時建立了好多個新的 cache,Rails 有一個特有的 :race_condition_ttl 設定,專門使用在設有 expires_in 的情況下,用來防止這個問題,用法如下:

<% cache(product, :expires_in => 1.day, race_condition_ttl: 60.seconds do %>

以可以是在 config 中,這種現象也稱為狗堆效應(dog-pile effect ),你如果覺得這會發生的可能性太低了吧,可以看看以下這位苦主的發文:Memcached: How to avoid dog-pile effect – Askar Fuzaylov – Medium: Problem

expires_at

有一個讓 cache 在指定的時間過期的方法也不錯,也就做一個 expires_at,只是 Rails 不提供這個功能,以下的文章有怎麼開發的介紹,基本上就是計算要過期的時差,如下,別忘了要用 round 來解決小數點誤差問題。

# Expiring posts based on the maximum expires_at time
expires_at = Post.maximum(:expires_at)
Rails.cache.fetch(:posts, expires_in: (expires_at - Time.current).round.seconds ) do
  ...
end

更詳細的介紹請看這篇:

Be aware of time operations when using cache with expires_in — In the last post I exposed some methods to calculate time difference in seconds, minutes and hours using Ruby on Rails. Today, we will… Medium

 
如何清除過期的 cache?

很簡單,就執行 Rails.cache.cleanup,可以把它寫成一個 rails 的 script 給 crontab 呼叫 rails runner 定期執行就好,你也可以在 rails console 中使用,這在開發中很有用,我甚至每個禮拜都回完全刪除所有的 cache 資料,畢竟,萬一搞到硬碟爆炸,整個 server 都完蛋。

不幸的是,你如果是用 Rails 5.2 以前,Rails.cache.cleanup 在 file store 時不能用,也就是說,你沒有辦法 expired 內容,你唯一的選項就是全刪,用:Rails.cache.clear,這很糟糕,但是,這也就是有時候,你一定要更新 rails 版本的原因......吧。  註:cleanup 在 Rails 5.2 以降,也許不能用 cleanup 刪除過期的 cache 內容,但是還是可以用來設定 cache 內容過期時間,所以當內容過期了,還是會令 cache 重新產生。 

ActiveSupport::Cache::FileStore#cleanup does not remove expired entries from cache · Issue #30788 · rails/rails — Steps to reproduce # frozen_string_literal: true begin require... GitHub

 
Russian doll caching (俄羅斯娃娃 caching)

都看過俄羅斯娃娃吧,就是一個娃娃打開後,裡面還有一個,再打開,還有,聽說可以做到幾十個的,在 Rails 的 cache 中,我們也會遇到同樣的問題,我們來看以下的例子:

<% cache(@blog) do %>
  <ul>
    <% @blog.comments.each do |comment| %>
      <% cache(comment) do %>
        <li class="comment"><%= comment.content %></li>
      <% end %>
    <% end %>
  </ul>
<% end %>

我們可以發現,當我們改變了 comment 的內容時,blog 並不知道,所以,最外面的這個 cache(@blog) 沒有變,因為這個 key 沒變,所以 blog 的 cache 並不會改變,這就是俄羅斯娃娃 caching 問題,為了解決這個問題,Rails guide 上的建議的解法是:

改 model:用 'touch' 指令在 model 中:

class blog < ActiveRecord::Base
  has_many :comments
end

class comment < ActiveRecord::Base
  belongs_to :blog, touch: true
end

這樣的解法好不好,見仁見智,好處是簡單,不過你必須了解,touch 是一個不只用在 cache 的功能,他是一個 ActiveRecord 的功能,當你設定這個 model 的 touch 是 true 時,任何的更動這筆資料,會觸發:after_touch,after_commit 跟 after_rollback 的 callbacks。

不改 model:還有一個下面的解法是不用改 model 的,看起來比較直覺,是用 Maximum 及 map,效果一樣,我覺得也很好用,還是用前面這個 blog + comment 的例子來說明:

<% cache([@blog, @blog.comments.maximum(:updated_at),@blog.comments.map(&:id)] do %>
  <ul>
    <% @blog.comments.each do |comment| %>
      <% cache(comment) do %>
        <li class="comment"><%= comment.content %></li>
      <% end %>
    <% end %>
  </ul>
<% end %>

這樣,當任何一筆相關的 comment 被改變時,@blog.comments.maximum(:updated_at) 就會改變,或是,當相關的 comment 有新增刪除時,@blog.comments.map(&:id),也會改變,這兩個誰好,難說,還是要因時因地自己判斷,不過這個範例也很清楚的說明了如何正確的設定 Rails 的 cache key。

 
分享 cache 內容

cache 如果是同樣的 key 是可以在不同的網頁分享的,很棒吧,這很好用,以下我們來列舉一些常碰到的問題:

  • cache 內容不同,但 key 相同:如果你不小心,有兩個不同內容的片段,但是 cache 的 key 被設定成一樣,那他們就會開始互相消滅,所以就會越 cache 越慢,建議:加個 cache 名稱。
  • cache 內,網頁相關聯改變了:如上面我們介紹的,template digest 是用 md5 來算這「整頁」網頁的 template 及 其相關連的 template 的,所以一個常見的問題就是,當你分享這個 cache 時,奇怪,怎麼老是產生新的內容,如果怎麼都找不到理由,你就要注意一下相關連的 template 有沒有改變了。
  • cache 的 template digest 跟所在的 template 有關,很多時候,你必須要用 skip_digest: true來關掉 template digest,才能分享,但是,後遺症就是當你改變 template 後,cache 就不會更新,兩難,怎麼用,你要自己判斷了,我的建議是:關掉 template digest 來分享,但是也設一個短一些的過期時間,這樣,自少時間到後,一定會跟新。 
  • Rails 預設是將所有的 MIME type 都 cache:Rails 在 cache 時,是不分網路媒體型式(MIME type)的,也就是,它會把所有同名的 template 都 cache 起來,如果你只是要分享其中的一個,很簡單,就指定它,如,將:render(partial: 'hotels/hotel', collection: @hotels, cached: true),改成:render(partial: 'hotels/hotel.html.erb', collection: @hotels, cached: true)。

 

Low-Level Caching

有時候,你需要「暫存」一些不是「view」的資料,這時,low-level caching 就是一個很好的選項,Rails cache 可以將任何資料以字串的方式儲存,最常用的方式就是用:Rails.cache.fetch,它是一個 ruby hash 的 fetch 指令 rails 加強版,rails guide 上有一個很棒的例子,它可以設定讀取時間,減少很慢的外部網站讀取,所以我們就用這個例子來介紹:

class Product < ApplicationRecord
  def competing_price
    Rails.cache.fetch("#{cache_key_with_version}/competing_price", expires_in: 12.hours) do
      Competitor::API.find_price(id)
    end
  end
end

在這個例子中,設定了隔 12 小時才會讀取跟新一次外部的網站的價格資料,既然是 cache 就要有一個 key,你可以自己取一個名稱,或是用 cache_key 還是 Rails 5.2 後提供的 cache_key_with_version,cache_key_with_version 會預設產生一個model id + updated_at 的 key,如:products/233-20140225082222765838000/competing_price。

cache_key 還是 cache_key_with_version 這兩個沒有什麼大差別,只是 cache_key_with_version 可以用:cache_version 來設定不用預設的 updated_at timestamp 來當作 version,會有更大的運用彈性,rails 6.0 後使用 cache_key 會出現 deprecated 的警告,也就是說 rails 希望 5.2 以後,都要用 cache_key_with_version 來清楚的指定是用那個 timestamp 來當版本 version 識別,是會清楚一些啦,但是很煩啊!以下是指定 timestamp 的做法:

Person.find(5).cache_key(:updated_at, :last_reviewed_at) 

以下是不指定 timestamp (用預設的 updated_at )

Product.new.cache_key # => "products/new"
Product.find(5).cache_key # => "products/5" #如果沒有 updated_at
Person.find(5).cache_key # => "people/5-20071224150000"  #如果有 updated_at

Rails 5.2 以後,你也可以把 versioning 關掉:ActiveRecord::Base.cache_versioning = false,6.0 後預設也是關掉,這樣, cache_key 就可以繼續使用了。  Cache_key 或是 cache_key_with_version 還有一個好用的地方就是用在 HTTP cache 中,我們來看看:

 
HTTP 條件取得 Conditional GETs

大家都知道瀏覽器與伺服器間,也有 cache 吧,好啦,這裡也不講太深,反正就是當瀏覽器來要求讀取資料時,如果以前有讀過,也還保有資料,就會提供一個以前資料的 ETag(實體標籤)與 Last-Modified(最後修改時間),伺服器端可以用這個來判斷網頁的資料有沒有改變,如果這兩個都沒有改變,就回應說:304 (Not Modified),這樣,伺服器端就不用再送一次資料,這比搞什麼 fragment cache 都快。

在 Rails 中,常用的是:if stale?(etag: @article, last_modified: @article.created_at),在 Rails guide 中,Etag 可以用 cache_key_with_version 來做。 我們還是用 Rails guide 中很棒的例子來說明:

class ProductsController < ApplicationController
 
  def show
    @product = Product.find(params[:id])
 
    # If the request is stale according to the given timestamp and etag value
    # (i.e. it needs to be processed again) then execute this block
    if stale?(last_modified: @product.updated_at.utc, etag: @product.cache_key_with_version)
      respond_to do |wants|
        # ... normal response processing
      end
    end
 
    # 如果沒改變,就會送出 304 (Not Modified) 
  end
end

還有一個更簡單的用法是:if stale?(@product),Rails 會自動以 updated_at 跟 cache_key_with_version 來產生 last_modified 跟 etag,就跟上面的一模一樣,省了很多字,如果你的 controller 沒有指定的 respond_to,也就是說是用預設的,這時,你更簡單,就用:fresh_when last_modified: @product.published_at.utc, etag: @product,更省字了。  rails 還可以讓你個 HTTP cache 永遠:http_cache_forever(public: true),其中 public 設成 true 會讓 proxy 設成 2011-1-1 起,一百年不過期,不過這樣真的超級危險,你以後的網頁就完全不會跟新了,使用要很小心、非常的小心。

強 Strong ETags vs. 弱弱 Weak ETags

ETags 是什麼?如前面所介紹的,Etag 是一個「值」,我們可以用像是 cache_key_with_version 來產生,它是使用在 http header 內,主要是用在當瀏覽器向 web server 要網頁時,判斷要不要重新讀取,如果 ETag 沒變,就用瀏覽器的 cache 網頁,如果變了,就重新讀取。  Rails 5+ 以後,預設從 strong ETags 改成 weak ETags,弱與強主要的差別是就是弱弱 Etags 由一個 W 開頭,以下的例子說明兩者比較的差別: 

ETag 1 ETag 2 強比較 弱比較
W/"1" W/"1" 不同 相同
W/"1" W/"2" 不同 不同
W/"1" "1" 不同 相同
"1" "1" 相同 相同

說實在的,這改變好像跟 rails 的開發沒什麼影響,也跟我們現在談的 cache 無關,大家以為可以跳過,只要知道基本上 Rails 5.0 後是用 weak ETags 就好,可是不然啊,如果你有用到 CDN,很多就只支援 Strong ETags,像是 Akamai,所以你一定要改用,用法如下:

fresh_when last_modified: @product.published_at.utc, strong_etag: @product

還有更多的求知慾,可以看以下這篇:

Rails 5 switches from strong etags to weak etags | BigBinary Blog — Rails 5 switches from strong etags to weak etags
BigBinary Blog

 

清除 cache 資料

如同我在另一篇的 cache store 的介紹,不同的 store 要做不同的清除,選用 File Store 當 cache 的店就必須要定期清除,不然他就會一直增加、一直增加,直到,塞滿你的硬碟,清除 cache store 很簡單,有兩種方法:

  1. Rails.cache.cleanup:清除「過期的」cache 資料,
  2. Rails.cache.clear:所有的 cache 都刪掉,適用於所有的 file store,如果你是用 file store,整個 cache 目錄內的檔案都刪掉,所以使用時要注意一下,不要刪了別的多功還在用的,不過也就是會讓他們重寫 cache 就是了,沒太大問題。
  3. rake tmp:cache:clear:這只在 file store 中有用,他會清除所有在 temp/cache 中的檔案,包含不是 cache 的檔案,在 file store 中,跟 Rails.cache.clear 是一樣的,如果你是用別的 store,它還是清除 temp/cache 中的檔案,所以,做了等於白做,笑。

這三個刪除法在文件上都註明不是每一種店都適用,我沒試過所有的,大家用時多注意、多測試吧,實務上,你可以把它寫到一個 ruby 程式,再用 crontab/rails runner 去做定期的刪除動作。 

 

網頁效率真實量測

如果你開發的網站,有一個最大回應時間的目標值,開發就不是那麼簡單了,你必須要有很多的測量工具,你也不能再依靠 log 了,比較準確的方式就是用網頁量測工具(web benchmark),找一個你喜歡的,常用的有 apache benchmark,使用上也很簡單:

$ ab -t 2 -c 2 https://www.test.com/

以上的指令是說:-t 2 = 做多測兩秒;-c 2 = 同時兩個(多功 works 測試),以下就是一份產生的測試報告,以這個命令,可以看到這個目標網頁:完成了五次,沒有錯誤,平均一個 Request 969ms,快一秒。

This is ApacheBench, Version 2.3 <$Revision: 1528965 $>
.....
Benchmarking www.test.com (be patient)
Finished 5 requests
Server Software:        Apache
Server Hostname:        www.test.com
....
Concurrency Level:      2
Time taken for tests:   2.424
seconds
Complete requests:      5
Failed requests:        0

Total transferred:      894645 bytes
HTML transferred:       891057 bytes
Requests per second:    2.06 [#/sec] (mean)
Time per request:       969.707 [ms] (mean)
Time per request:       484.853 [ms] (mean, across all concurrent requests)
Transfer rate:          360.39 [Kbytes/sec] received
Connection Times (ms)
              min  mean[+/-sd] median   max
Connect:        8   16   5.2     17      22
Processing:   673  817 140.0    856     976
Waiting:      612  743 127.3    778     887
Total:        688  833 136.8    870     991

Percentage of the requests served within a certain time (ms)
  50%    774
  66%    965
  75%    965
  80%    991
  90%    991
  95%    991
  98%    991
  99%    991
 100%    991 (longest request)

由於網頁回應時間的測量與測試是一個大工程,絕不是一個簡單的事,我只是起個頭,不是這篇文章的目的,我們也就不再做深入的介紹了。

 

實際使用的建議:

與其說 Cache 是個技術,不如說 cache 是門藝術,實務上,cache 一旦加入後,一定或對開發造成一些困擾,我真的建議 cache 要到程式開發的差不多時,最後再來開發,但是,我們都知道,網站都是不斷的演進,新功能慢慢的一點一點的加入,永遠都沒有開發完的一天,所以,講了等於白講,不過,有一點真正有用的建議是,要先了解 cache,然後先在一些效能關鍵的地方運用,不要多,這會對你瞭解 cache 有很大的幫助,回過頭來,對你在設計每一個功能時,也會知道要如何考慮未來如何加入 cache。

cache 在 rails 中,有著三種形式,基本上,核心都圍繞著如何管理過期:

  1. Key Based — 用獨特的值來做 cache 過期管理,這是 Rails 目前功能最完整的。
  2. Time Based — 用時間來管理,這點在 Rails 中的功能不算強,可以與 HTTP 結合運用,我想如果你開完了這篇就會知道,Rails 還有很大的改進空間,不過,可以用啦。
  3. 全手動 Purging — 這是最強的手動功能,全手動,也就是說你要自己寫程式來管理過期與否,你可以完全控制 cache expires 與否,甚至,重新更新舊的 cache 內容,所以連過期刪除都不用,只是,很強的手動就意味者你要寫自己的管理邏輯,我會用在效能很關鍵、即時更新又很重要,判斷又領亂的地方,畢竟,難寫也難維護啊。

開發 cache 時,也不是就是三選一,更多的清況下是「混用」,Key based + Time based 混用是很基本的,每個應用都有它實務上的考量,我碰到最多的挑戰就是,到底,網頁上的資料要如何「即時」,這個即時是如何定義的,可以允許晚一個小時更新嗎?例如:一個購物網,如果要顯示「即時」的購買數量,如果看的人多、買的人很少,問題不大,但如果買的人很多,那等於每一次購物量改變,cache 的內容就必須過期重新產生了,再如果每個進來的人都會買,那 cache 根本就是增加 overhead,拖慢時間效能,但是,換個設計,如果網頁上沒有購買數量顯示,或是,只做一個「售完」顯示,或是允許購買數量顯示五分鐘前的「非即時」,那就算是高流量網站,也可以大大的提高網頁效能,所以,cache 的難就是它與設計跟應用妥協有者交互的影響,我能給的建議是,寫之前,多想,多溝通,太多的 cache 都是寫了等於白寫。

我實在很希望大家分享一些 cache 的經驗與故事,一定很精彩,期待啊!

最後,不要忘了要將 cache 目錄加到你的 deploy script 中,如果你是用 capistrano 也就是:set :linked_dirs, %w{... tmp/cache ....}如果你沒加,那每次發佈 deploy 後,所有的 cache 內容就重新開始,當然,也許這就是你要的,每次 deploy 後,就重新開始!

 

 


喜歡作者的文章嗎?馬上按「關注」,當作者發佈新文章時,思書™就會 email 通知您。

思書是公開的寫作平台,創新的多筆名寫作方式,能用不同的筆名探索不同的寫作內容,無限寫作創意,如果您喜歡寫作分享,一定要來試試! 《 加入思書》

思書™是自由寫作平台,本文為作者之個人意見。


文章資訊

本文摘自:
分類於:
標籤:
日期:
創作於:2019/02/03,最後更新於:2019/10/04。
合計:9889字


分享這篇文章:
關於作者

很久以前就是個「寫程式的」,其實,什麼程式都不熟⋯⋯
就,這會一點點,那會一點點⋯⋯




參與討論!
現在就加入《思書》,馬上參與討論!
《思書》是一個每個人的寫作與論壇平台,特有的隱私管理,用筆名來區隔你討論內容,讓你的討論更深入,而且免費。 趕快來試試!
還未加入《思書》? 現在就登錄! 已經加入《思書》── 登入


看看作者的其他文章


看看思書的其他文章



×
登入
申請帳號

需要幫助
關於思書

暗黑模式?
字體大小
成人內容未過濾
更改語言版本?