网站效能
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.postscount
,而不会用_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才用sendfile
送出档案,这时候可以打开:x_sendfile
表示你将传档的工作委交由_Web服务器的xsendfile模组负责。当然,Web服务器得先安装好x_sendfile功能:
由 CDN 提供静态档案
静态档案也放在CDN上让全世界的使用者在最近的下载点读取。CDN需要专门的CDN厂商提供服务,其中推荐AWS CloudFront和CloudFlare线上就可以完成申请和设定的。
如果要让你的Assets例如CSS, JavaScript, Images也让使用者透过CDN下载,只要修改config/environments/production.rb的config.actioncontroller.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