加载中...

网站效能


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是一个外挂可以在开发时侦测N+1 queries问题。

索引(Indexes)

没有帮资料表加上索引也是常见的效能杀手,作为搜寻条件的资料字段如果没有加索引,SQL查询的时候就会一笔笔检查资料表中的所有资料,当资料一多的时候相差的效能就十分巨大。一般来说,以下的字段都必须记得加上索引:

  • 外部键(Foreign key)
  • 会被排序的字段(被放在order方法中)
  • 会被查询的字段(被放在where方法中)
  • 会被group的字段(被放在group方法中)

如何帮数据库加上索引请参考Migrations一章。

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全文搜寻引擎和elasticsearch-rails gem
  • Apache Solr(Lucenel)全文搜寻引擎和Sunspot gem
  • PostgreSQL内建有全文搜寻功能,可以搭配 texticle gem或pg_search gem
  • Sphinx全文搜寻引擎和thinking_sphinx gem

SQL 分析

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里。

逆正规化(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这套工具可以分析Rails log档案
  • 透过商业Monitor产品:New Relic、Scout
  • 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: 可以参考使用 httperf 做网站效能分析一文
  • wrk: Modern HTTP benchmarking tool
  • Apache ab: 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
  • Writing Fast Ruby
  • JuanitoFatas/fast-ruby

不过有时候“执行速度较快”的程式码不代表好维护、好除错的程式码,这一点需要多加注意。

使用更快的Ruby函式库

有C Extension的Ruby函式库总是比较快的,如果常用可以考虑安装:

  • XML parser http://nokogiri.org/
  • JSON parser http://github.com/brianmario/yajl-ruby/
  • HTTP client http://github.com/pauldix/typhoeus
  • escape_utils: 请参考 Escape Velocity

由Web服务器提供静态档案

由Web服务器提供档案会比你用Application服务器快上十倍以上,如果是不需要权限控管的静态档案,可以直接放在public目录下让使用者下载。

如果是需要权限控管得经过Rails,你会在controller才用send_file送出档案,这时候可以打开:x_sendfile表示你将传档的工作委交由Web服务器的xsendfile模组负责。当然,Web服务器得先安装好x_sendfile功能:

  • Apache mod_xsendfile
  • Nginx XSendfile

由 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

参考Rails Front-End 优化

  • YSlow
  • Google PageSpeed

使用外部程式

Ruby不是万能,有时候直接呼叫外部程式是最快的作法:

def thumbnail(temp, target)
  system("/usr/local/bin/convert #{escape(temp)} -resize 48x48! #{escape(target}")
end

投影片

  • Rails Performance Best Practices

其他线上资源

  • Performance Testing Rails Applications

还没有评论.