> 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 < ActiveRecord::Base
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'))
~~~
> [Bullet](http://github.com/flyerhzm/bullet)是一個外掛可以在開發時偵測N+1 queries問題。
### 索引(Indexes)
沒有幫資料表加上索引也是常見的效能殺手,作為搜尋條件的資料欄位如果沒有加索引,SQL查詢的時候就會一筆筆檢查資料表中的所有資料,當資料一多的時候相差的效能就十分巨大。一般來說,以下的欄位都必須記得加上索引:
* 外部鍵(Foreign key)
* 會被排序的欄位(被放在`order`方法中)
* 會被查詢的欄位(被放在`where`方法中)
* 會被group的欄位(被放在`group`方法中)
如何幫資料庫加上索引請參考[Migrations](https://ihower.tw/rails4/migrations.html)一章。
> [rails_indexes](http://github.com/eladmeidar/rails_indexes)提供了Rake任務可以幫忙找忘記加的索引。
### 使用select
ActiveRecord預設的SQL會把所有欄位的資料都讀取出來,如果其中有text或binary欄位資料量很大,就會每次都佔用很多不必要的記憶體拖慢效能。使用select可以只讀取出你需要的資料:
~~~
Event.select(:id, :name, :description).limit(10)
~~~
進一步我們可以利用scope先設定好select範圍:
~~~
class User < ActiveRecord::Base
scope :short, -> { select(:id, :name, :description) }
end
User.short.limit(10)
~~~
### 有些情況可以用joins取代includes
~~~
Group.includes(:group_memberships).where( ["group_memberships.created_at > ?", Time.now - 30.days ] )
~~~
以上的查詢只有在條件中用到group_memberships,所以可以換成joins增加效率:
~~~
Group.joins(:group_memberships).where( ["group_memberships.created_at > ?", Time.now - 30.days ] )
~~~
### 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
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 < ActiveRecord::Base
has_many :posts
end
class Posts < ActiveRecord::Base
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效能會加快,如果是相關的SQL可以包在一起。
~~~
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
~~~
### Use Constant for domain data
不會變的資料可以用常數在Rails啟動時就放到記憶體。
~~~
class Rating < ActiveRecord::Base
G = Rating.find_by_name('G')
PG = Rating.find_by_name('PG')
R = Rating.find_by_name('R')
#....
end
Rating::G
Rating::PG
Rating::R
~~~
> 注意在development mode中不會作用,要在production mode才有快取效果。
### 全文搜尋Full-text search engine
如果需要搜尋text欄位,因為資料庫沒辦法加索引,所以會造成table scan把資料表所有資料都掃描一次,效能會非常低落。這時候可以使用外部的全文搜尋伺服器來做索引,目前常見有以下選擇:
* [Elasticsearch](http://www.elasticsearch.org/)全文搜尋引擎和[elasticsearch-rails](https://github.com/elasticsearch/elasticsearch-rails) gem
* [Apache Solr(Lucenel)](http://lucene.apache.org/solr/)全文搜尋引擎和[Sunspot](https://github.com/sunspot/sunspot) gem
* PostgreSQL內建有全文搜尋功能,可以搭配 [texticle](https://github.com/textacular/textacular) gem或[pg_search](https://github.com/Casecommons/pg_search) gem
* [Sphinx](http://sphinxsearch.com/)全文搜尋引擎和[thinking_sphinx](http://freelancing-god.github.com/ts/en/) gem
### SQL 分析
[QueryReviewer](https://github.com/nesquena/query_reviewer)這個套件透過`SQL EXPLAIN`分析SQL query的效率。
另外在Rails 3.2的開發模式中,有以下的設定:
~~~
# Log the query plan for queries taking more than this (works
# with SQLite, MySQL, and PostgreSQL).
# config.active_record.auto_explain_threshold_in_seconds = 0.5
~~~
當SQL執行超過0.5秒,就會自動幫你分析在Log裡。
### 逆正規化(de-normalization)
一般在設計關聯式資料庫的table時,思考的都是正規化的設計。透過正規化的設計,可以將資料不重複的儲存,省空間,更新也不易出錯。但是這對於複雜的查詢有時候就力有未逮。因此必要時可以採用逆正規化的設計。犧牲空間,增加修改的麻煩,但是讓讀取這事件變得更快更簡單。
上述章節的Counter cache,其實就是一種逆正規化的應用,只是Rails幫你包裝好了。如果你要自己實作的話,可以善用Callback或Observer來作更新。以下是一個應用的範例,Event的總金額,是透過Invoice#amount的總和得知。另外,我們也想知道該活動最後一筆Invoice的時間:
~~~
class Event < ActiveRecord::Base
has_many :invoices
def amount
self.invoices.sum(:amount)
end
def last_invoice_time
self.invoices.last.created_at
end
end
class Invoice < ActiveRecord::Base
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 < ActiveRecord::Base
has_many :invoices
def update_invoice_cache
self.amount_cache = self.invoices.sum(:amount)
self.last_invoice_time = self.invoices.last.try(:created_at)
self.save(:validate => false)
end
end
class Invoice < ActiveRecord::Base
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)快。
## 效能分析工具
效能分析工具可以幫助我們找到哪一部分的程式最需要效能優化,哪些部分最常被使用者執行,如果能夠優化效益最高。
* [request-log-analyzer](http://github.com/wvanbergen/request-log-analyzer)這套工具可以分析Rails log檔案
* 透過商業Monitor產品:[New Relic](http://www.newrelic.com/)、[Scout](http://www.scoutapp.com/)
* Rack::Bug Rails middleware 可以在開發的時候,插入一個工具列分析每個request
* ruby-prof gem
* Rails command line
## 效能量測
* Benchmark standard library
* Rails command line
* Rails helper methods: Creating report in your log file
### 一般性工具(黑箱)
* [httperf](https://code.google.com/p/httperf/): 可以參考[使用 httperf 做網站效能分析](http://ihower.tw/blog/archives/1749)一文
* [wrk](https://github.com/wg/wrk): Modern HTTP benchmarking tool
* [Apache ab](http://httpd.apache.org/docs/2.2/programs/ab.html): Apache HTTP server benchmarking tool
How fast can this server serve requests?
* Use web server to serve static files as baseline measurement
* Do not run from the same server (I/O and CPU)
* Run from a machine as close as possible
You need know basic statistics
* compare not just their means but their standard deviations and confidence intervals as well.
* Approximately 68% of the data points lie within one standard deviation of the mean
* 95% of the data is within 2 standard deviation of the mean
## 如何寫出執行速度較快的Ruby程式碼
* [如何寫出有效率的 Ruby Code](http://ihower.tw/blog/archives/1691)
* [Writing Fast Ruby](https://speakerdeck.com/sferik/writing-fast-ruby)
* [JuanitoFatas/fast-ruby](https://github.com/JuanitoFatas/fast-ruby)
不過有時候「執行速度較快」的程式碼不代表好維護、好除錯的程式碼,這一點需要多加注意。
## 使用更快的Ruby函式庫
有C Extension的Ruby函式庫總是比較快的,如果常用可以考慮安裝:
* XML parser [http://nokogiri.org/](http://nokogiri.org/)
* JSON parser [http://github.com/brianmario/yajl-ruby/](http://github.com/brianmario/yajl-ruby/)
* HTTP client [http://github.com/pauldix/typhoeus](http://github.com/pauldix/typhoeus)
* [escape_utils](https://github.com/brianmario/escape_utils): 請參考 [Escape Velocity](https://github.com/blog/1475-escape-velocity)
## 由Web伺服器提供靜態檔案
由Web伺服器提供檔案會比你用Application伺服器快上十倍以上,如果是不需要權限控管的靜態檔案,可以直接放在public目錄下讓使用者下載。
如果是需要權限控管得經過Rails,你會在controller才用`send_file`送出檔案,這時候可以打開`:x_sendfile`表示你將傳檔的工作委交由Web伺服器的xsendfile模組負責。當然,Web伺服器得先安裝好x_sendfile功能:
* [Apache mod_xsendfile](https://tn123.org/mod_xsendfile)
* [Nginx XSendfile](http://wiki.nginx.org/XSendfile)
## 由 CDN 提供靜態檔案
靜態檔案也放在CDN上讓全世界的使用者在最近的下載點讀取。CDN需要專門的CDN廠商提供服務,其中推薦[AWS CloudFront](http://aws.amazon.com/cloudfront/)和[CloudFlare](https://www.cloudflare.com/)線上就可以完成申請和設定的。
如果要讓你的Assets例如CSS, JavaScript, Images也讓使用者透過CDN下載,只要修改config/environments/production.rb的`config.action_controller.asset_host`為CDN網址即可。
## Client-side web performance
參考[Rails Front-End 優化](http://ihower.tw/blog/archives/1707)
* [YSlow](http://yslow.org/)
* [Google PageSpeed](http://code.google.com/speed/page-speed/)
## 使用外部程式
Ruby不是萬能,有時候直接呼叫外部程式是最快的作法:
~~~
def thumbnail(temp, target)
system("/usr/local/bin/convert #{escape(temp)} -resize 48x48! #{escape(target}")
end
~~~
## 投影片
* [Rails Performance Best Practices](http://www.slideshare.net/ihower/rails-performance)
## 其他線上資源
* [Performance Testing Rails Applications](http://guides.rubyonrails.org/v3.2.13/performance_testing.html)