网站效能

网站效能

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 把资料表所有资料都扫描一次,效能会非常低落。这时候可以使用外部的全文搜寻服务器来做索引,目前常见有以下选择:

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) 快。

效能分析

效能分析工具可以帮助我们找到哪一部分的程式最需要效能优化,哪些部分最常被使用者执行,如果能够优化效益最高。

程式量测工具

以下工具可以帮助我们量测程式的效能:

HTTP 量测工具

以下工具可以量测网站服务器的连线和 Requests 数量:

由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 等等静态内容也有一些可以最佳化的工作,包括:

如果有用 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

其他线上资源