本章內容與 Part 2-6 Rails 網站效能有些重複,可以先去看 Part 2-6。
網站效能
We should forget about small efficiencies, say about 97% of the time: premature optimization is the root of all evil - Donald Knuth
即使程式的執行結果正確,但是如果你的網站效能不佳,載入頁面需要花很久時間,那們網站的使用性就會變得很差,甚至慢到無法使用。硬體的進步雖然可以讓我們不必再斤斤計較程式碼的執行速度,但是開發者還是需要擁有合理的成本觀念,要買快十倍的CPU或硬碟不只花十倍的錢也買不到,帶來的效能差異還不如你平常就避免寫出拖慢效能十倍甚至百倍的程式碼。
效能問題其實可以分成兩種,一種是完全沒有意識到抽象化工具、開發框架的效能盲點,而寫下了執行效能差勁的程式碼。另一種則是對現有程式的效能不滿意,研究如何最佳化,例如利用快取機制隔離執行速度較慢的高階程式,來大幅提昇執行效能。
這一章會先介紹第一種問題,這是一些使用Rails這種高階框架所需要注意的效能盲點(anti-patterns),避免寫出不合理執行速度的程式。接下來,我們再進一步學習如何最佳化Rails程式。下一章則介紹使用快取機制來大幅增加網站效能。
另一個你會常聽到的名詞是擴展性(Scalability)。網站的擴展性不代表絕對的效能,而是研究如何在合理的硬體成本下,可以透過水平擴展持續增加系統容量。
ActiveRecord和SQL
ActiveRecord抽象化了SQL操作,是頭號第一大效能盲點所在,你很容易沉浸在他帶來的開發高效率上,忽略了他的效能盲點直到上線爆炸。存取資料庫是一種相對很慢的I/O的操作:每一條SQL query都得耗上時間、執行回傳的結果也會被轉成ActiveRecord物件全部放進記憶體,會不會佔用太多?因此你得對會產生出怎樣的SQL queries有基本概念。
N+1 queries
N+1 queries是資料庫效能頭號殺手。ActiveRecord的Association功能很方便,所以很容易就寫出以下的程式:
# model
class User < ActieRecord::Base
has_one :car
end
class Car < ApplicationRecord
belongs_to :user
end
# your controller
def index
@users = User.page(params[:page])
end
# view
<% @users.each do |user| %>
<%= user.car.name %>
<% end %>
我們在View中讀取user.car.name
的值。但是這樣的程式導致了N+1 queries問題,假設User有10筆,這程式會產生出11筆Queries,一筆是查User,另外10筆是一筆一筆去查Car,嚴重拖慢效能。
SELECT * FROM `users` LIMIT 10 OFFSET 0
SELECT * FROM `cars` WHERE (`cars`.`user_id` = 1)
SELECT * FROM `cars` WHERE (`cars`.`user_id` = 2)
SELECT * FROM `cars` WHERE (`cars`.`user_id` = 3)
...
...
...
SELECT * FROM `cars` WHERE (`cars`.`user_id` = 10)
解決方法,加上includes
:
# your controller
def index
@users = User.includes(:car).page(params[:page])
end
如此SQL query就只有兩個,只用一個就撈出所有Cars資料。
SELECT * FROM `users` LIMIT 10 OFFSET 0
SELECT * FROM `cars` WHERE (`cars`.`user_id` IN('1','2','3','4','5','6','7','8','9','10'))
如果user
還有parts
零件的關聯資料想要一起撈出來,includes
也支援hash寫法:@users = User.includes(:car => :parts ).page(params[:page])
Bullet是一個外掛可以在開發時偵測N+1 queries問題。
索引(Indexes)
沒有幫資料表加上索引也是常見的效能殺手,作為搜尋條件的資料欄位如果沒有加索引,SQL查詢的時候就會一筆筆檢查資料表中的所有資料,當資料一多的時候相差的效能就十分巨大。一般來說,以下的欄位都必須記得加上索引:
- 外部鍵(Foreign key)
- 會被排序的欄位(被放在
order
方法中) - 會被查詢的欄位(被放在
where
方法中) - 會被group的欄位(被放在
group
方法中)
如何幫資料庫加上索引請參考Migrations一章。
lol_dba提供了Rake任務可以幫忙找忘記加的索引。
使用select
ActiveRecord預設的SQL會把所有欄位的資料都讀取出來,如果其中有text或binary欄位資料量很大,就會每次都佔用很多不必要的記憶體拖慢效能。使用select可以只讀取出你需要的資料:
Event.select(:id, :name, :description).limit(10)
進一步我們可以利用scope先設定好select範圍:
class User < ApplicationRecord
scope :short, -> { select(:id, :name, :description) }
end
User.short.limit(10)
計數快取 Counter Cache
如果需要常計算has_many的Model有多少筆資料,例如顯示文章列表時,也要顯示每篇有多少留言回覆。
<% @topics.each do |topic| %>
主題:<%= topic.subject %>
回覆數:<%= topic.posts.size %>
<% end %>
這時候Rails會產生一筆筆的SQL count查詢:
SELECT * FROM `posts` LIMIT 5 OFFSET 0
SELECT count(*) AS count_all FROM `posts` WHERE (`posts`.topic_id = 1 )
SELECT count(*) AS count_all FROM `posts` WHERE (`posts`.topic_id = 2 )
SELECT count(*) AS count_all FROM `posts` WHERE (`posts`.topic_id = 3 )
SELECT count(*) AS count_all FROM `posts` WHERE (`posts`.topic_id = 4 )
SELECT count(*) AS count_all FROM `posts` WHERE (`posts`.topic_id = 5 )
Counter cache功能可以把這個數字存進資料庫,不再需要一筆筆的SQL count查詢,並且會在Post數量有更新的時候,自動更新這個值。
首先,你必須要在Topic Model新增一個欄位叫做posts_count,依照慣例是_count
結尾,型別是integer,有預設值0。
rails g migration add_posts_count_to_topic
編輯Migration:
class AddPostsCountToTopic < ActiveRecord::Migration[5.1]
def change
add_column :topics, :posts_count, :integer, :default => 0
Topic.pluck(:id).each do |i|
Topic.reset_counters(i, :posts) # 全部重算一次
end
end
end
編輯Models,加入:counter_cache => true
:
class Topic < ApplicationRecord
has_many :posts
end
class Posts < ApplicationRecord
belongs_to :topic, :counter_cache => true
end
這樣同樣的@topic.posts.size
程式,就會自動變成使用@topic.posts_count
,而不會用SQL count查詢一次。
Batch finding
如果需要撈出全部的資料做處理,強烈建議最好不要用all方法,因為這樣會把全部的資料一次放進記憶體中,如果資料有成千上萬筆的話,效能就墜毀了。解決方法是分次撈,每次幾撈幾百或幾千筆。雖然自己寫就可以了,但是Rails提供了Batch finding方法可以很簡單的使用:
Article.find_each do |a|
# iterate over all articles, in chunks of 1000 (the default)
end
Article.find_each( :batch_size => 100 ) do |a|
# iterate over published articles in chunks of 100
end
或是
Article.find_in_batches do |articles|
articles.each do |a|
# articles is array of size 1000
end
end
Article.find_in_batches( :batch_size => 100 ) do |articles|
articles.each do |a|
# iterate over all articles in chunks of 100
end
end
Transaction for group operations
在Transaction交易範圍內的SQL效能會加快,因為最後只需要COMMIT
一次即可:
my_collection.each do |q|
Quote.create({:phrase => q})
end
# Add transaction
Quote.transaction do
my_collection.each do |q|
Quote.create({:phrase => q})
end
end
全文搜尋Full-text search engine
如果需要搜尋text欄位,因為資料庫沒辦法加索引,所以會造成table scan把資料表所有資料都掃描一次,效能會非常低落。這時候可以使用外部的全文搜尋伺服器來做索引,目前常見有以下選擇:
- Elasticsearch全文搜尋引擎和elasticsearch-rails gem
- Apache Solr(Lucenel)全文搜尋引擎和Sunspot gem
- PostgreSQL內建有全文搜尋功能,可以搭配 texticle gem或 pg_search gem
- Sphinx全文搜尋引擎和thinking_sphinx gem
SQL 效能分析
QueryReviewer這個套件透過SQL EXPLAIN
分析SQL query的效率
逆正規化(de-normalization)
一般在設計關聯式資料庫的table時,思考的都是正規化的設計。透過正規化的設計,可以將資料不重複的儲存,省空間,更新也不易出錯。但是這對於複雜的查詢有時候就力有未逮。因此必要時可以採用逆正規化的設計。犧牲空間,增加修改的麻煩,但是讓讀取這事件變得更快更簡單。
上述章節的Counter Cache,其實就是一種逆正規化的應用,只是Rails幫你包裝好了。如果你要自己實作的話,可以善用Callback或Observer來作更新。以下是一個應用的範例,Event的總金額,是透過Invoice#amount的總和得知。另外,我們也想知道該活動最後一筆Invoice的時間:
class Event < ApplicationRecord
has_many :invoices
def amount
self.invoices.sum(:amount)
end
def last_invoice_time
self.invoices.last.created_at
end
end
class Invoice < ApplicationRecord
belongs_to :event
end
如果有一頁是列出所有活動的總金額和最後Invoice時間,那麼這一頁就會產生2N+1筆SQL查詢(N是活動數量)。為了改善這一頁的讀取效能,我們可以在events資料表上新增兩個欄位amount和last_invoice_time。首先,我們新增一個Migration:
add_column :events, :amount, :integer, :default => 0
add_column :events, :last_invoice_time, :datetime
# Data migration current data
Event.find_each do |e|
e.amount = e.invoices.sum(:amount)
e.last_invoice_time = e.invoices.last.try(:created_at) # e.invoices.last 可能是 nil
e.save(:validate => false)
end
接著程式就可以改成:
class Event < ApplicationRecord
has_many :invoices
def update_invoice_cache
self.amount = self.invoices.sum(:amount)
self.last_invoice_time = self.invoices.last.try(:created_at)
self.save(:validate => false)
end
end
class Invoice < ApplicationRecord
belongs_to :event
after_save :update_event_cache_data
protected
def update_event_cache_data
self.event.update_invoice_cache
end
end
如此就可以將成本轉嫁到寫入,而最佳化了讀取時間。
最佳化效能
關於程式效能最佳化,Donald Knuth大師曾開示「We should forget about small efficiencies, say about 97% of the time: premature optimization is the root of all evil”」,在效能還沒有造成問題前,就為了優化效能而修改程式和架構,只會讓程式更混亂不好維護。
也就是說,當效能還不會造成問題時,程式的維護性比考慮效能重要。80/20法則:會拖慢整體效能的程式,只佔全部程式的一小部分而已,所以我們只最佳化會造成問題的程式。接下來的問題就是,如何找到那一小部分的效能瓶頸,如果用猜的去找那3%造成效能問題的程式,再用感覺去比較改過之後的效能好像有比較快,這種作法一點都不科學而且浪費時間。善用分析工具找效能瓶頸,最佳化前需要測量,最佳化後也要測量比較。
把所有東西都快取起來並不是解決效能的作法,這只會讓程式有更多的一致性問題,更難維護。另外也不要跟你的框架過不去,硬是要去改Rails核心,這會導致程式有嚴重的維護性問題。最後,思考出正確的演算法總是比埋頭改程式有效,只要資料一大,不論程式怎麼改,挑選O(1)的演算法一定就是比O(n)快。
效能分析
效能分析工具可以幫助我們找到哪一部分的程式最需要效能優化,哪些部分最常被使用者執行,如果能夠優化效益最高。
- rack-mini-profiler在頁面的左上角顯示花了多少時間,並且提供報表,推薦安裝
- request-log-analyzer這套工具可以分析Rails log檔案
- 透過商業Monitor產品:Skylight、New Relic或Scout
程式量測工具
以下工具可以幫助我們量測程式的效能:
- Benchmark standard library
- Rails benchmark helper Rails 內建的一些 Helper
- Rails Performance Testing 介紹的 rails/rails-perftest 工具
- ruby-prof
- evanphx/benchmark-ips
- SamSaffron/memory_profiler
HTTP 量測工具
以下工具可以量測網站伺服器的連線和Requests數量:
- httperf: 可以參考使用 httperf 做網站效能分析一文
- wrk: Modern HTTP benchmarking tool
- Apache ab: Apache HTTP server benchmarking tool
由Web伺服器提供靜態檔案
由Web伺服器提供檔案會比經過Rails應用伺服器快上十倍以上,如果是不需要權限控管的靜態檔案,可以直接放在public目錄下讓使用者下載。
如果是需要權限控管得經過Rails,你會在controller才用send_file
送出檔案,這時候可以打開:x_sendfile
表示你將傳檔的工作委交由Web伺服器的xsendfile模組負責。當然,Web伺服器得先安裝好x_sendfile功能:
由 CDN 提供靜態檔案
靜態檔案也放在CDN上讓全世界的使用者在最近的下載點讀取。CDN需要專門的CDN廠商提供服務,其中推薦AWS CloudFront和CloudFlare線上就可以完成申請和設定的。
如果要讓你的Assets例如CSS, JavaScript, Images也讓使用者透過CDN下載,只要修改config/environments/production.rb的config.action_controller.asset_host
為CDN網址即可。
瀏覽器網頁載入效能 Client-side web performance
後端伺服器的Response time固然重要,但對終端使用者來說,瀏覽器完成載入網頁的Page Load time才是真正的感受。因此針對CSS、JavaScript等等靜態內容也有一些可以最佳化的工作,包括:
- 打開 Gzip
- 加上快取 HTTP Headers
- 壓縮JavaScript和CSS
- 使用CDN
更多文件和工作請參考:
- Rails Front-End 優化 早年筆者寫的文章,看看就好
- Speed Up Rails By Starting on the Front
- Yahoo! Exceptional Performance Yahoo 的教學文件
- Google Make the Web Faster Google 的教學文件
- Google PageSpeed Google 提供的工具可以分析你的網頁效能
如果有用HTTPS安全連線的話,推薦打開網站伺服器的HTTP/2(前身是SPDY)支援,最新的最佳化技巧又有了一些變化,詳見更快更安全: 每個網站都應該升級到 HTTP/2一文。
如何寫出執行速度較快的Ruby程式碼
不過有時候「執行速度較快」的程式碼不代表好維護、好除錯的程式碼,這一點需要多加注意。
事實上,Rails 有許多方法其實並不是以效能為第一考量,而是以「程式設計師的幸福最大化」為原則。這個設計的哲學請參考Ruby on Rails 基本主義。
使用更快的Ruby函式庫
有C Extension的Ruby函式庫總是比較快的,如果常用可以考慮安裝:
- XML parser http://nokogiri.org/
- JSON parser http://github.com/brianmario/yajl-ruby/ 或 https://github.com/ohler55/oj
- HTTP client http://github.com/pauldix/typhoeus
- escape_utils: 請參考 Escape Velocity
使用外部程式
Ruby不是萬能,有時候直接呼叫外部程式是最快的作法:
def thumbnail(temp, target)
system("/usr/local/bin/convert #{escape(temp)} -resize 48x48! #{escape(target}")
end