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操作,是头号第一大效能盲点所在,你很容易沉浸在他带来的开发高效率上,忽略了他的效能盲点直到上线爆炸。存取数据库是一种相对很慢的I/O的操作:每一条SQL query都得耗上时间、执行回传的结果也会被转成ActiveRecord物件全部放进内存,会不会占用太多?因此你得对会产生出怎样的SQL 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是一个外挂可以在开发时侦测N+1 queries问题。
没有帮资料表加上索引也是常见的效能杀手,作为搜寻条件的资料字段如果没有加索引,SQL查询的时候就会一笔笔检查资料表中的所有资料,当资料一多的时候相差的效能就十分巨大。一般来说,以下的字段都必须记得加上索引:
order
方法中)where
方法中)group
方法中)如何帮数据库加上索引请参考Migrations一章。
rails_indexes提供了Rake任务可以帮忙找忘记加的索引。
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)
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 ] )
如果需要常计算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查询一次。
如果需要捞出全部的资料做处理,强烈建议最好不要用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交易范围内的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
不会变的资料可以用常数在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才有快取效果。
如果需要搜寻text字段,因为数据库没办法加索引,所以会造成table scan把资料表所有资料都扫描一次,效能会非常低落。这时候可以使用外部的全文搜寻服务器来做索引,目前常见有以下选择:
QueryReviewer这个套件透过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里。
一般在设计关联式数据库的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)快。
效能分析工具可以帮助我们找到哪一部分的程式最需要效能优化,哪些部分最常被使用者执行,如果能够优化效益最高。
How fast can this server serve requests?
You need know basic statistics
不过有时候“执行速度较快”的程式码不代表好维护、好除错的程式码,这一点需要多加注意。
有C Extension的Ruby函式库总是比较快的,如果常用可以考虑安装:
由Web服务器提供档案会比你用Application服务器快上十倍以上,如果是不需要权限控管的静态档案,可以直接放在public目录下让使用者下载。
如果是需要权限控管得经过Rails,你会在controller才用send_file
送出档案,这时候可以打开:x_sendfile
表示你将传档的工作委交由Web服务器的xsendfile模组负责。当然,Web服务器得先安装好x_sendfile功能:
静态档案也放在CDN上让全世界的使用者在最近的下载点读取。CDN需要专门的CDN厂商提供服务,其中推荐AWS CloudFront和CloudFlare线上就可以完成申请和设定的。
如果要让你的Assets例如CSS, JavaScript, Images也让使用者透过CDN下载,只要修改config/environments/production.rb的config.action_controller.asset_host
为CDN网址即可。
参考Rails Front-End 优化
Ruby不是万能,有时候直接呼叫外部程式是最快的作法:
def thumbnail(temp, target)
system("/usr/local/bin/convert #{escape(temp)} -resize 48x48! #{escape(target}")
end