网站效能

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是数据库效能头号杀手。ActiveRecordAssociation功能很方便,所以很容易就写出以下的程式:

  1. # model
  2. class User < ActieRecord::Base
  3. has_one :car
  4. end
  5. class Car < ApplicationRecord
  6. belongs_to :user
  7. end
  8. # your controller
  9. def index
  10. @users = User.page(params[:page])
  11. end
  12. # view
  13. <% @users.each do |user| %>
  14. <%= user.car.name %>
  15. <% end %>

我们在View中读取user.car.name的值。但是这样的程式导致了N+1 queries问题,假设User有10笔,这程式会产生出11笔Queries,一笔是查User,另外10笔是一笔一笔去查Car,严重拖慢效能。

  1. SELECT * FROM `users` LIMIT 10 OFFSET 0
  2. SELECT * FROM `cars` WHERE (`cars`.`user_id` = 1)
  3. SELECT * FROM `cars` WHERE (`cars`.`user_id` = 2)
  4. SELECT * FROM `cars` WHERE (`cars`.`user_id` = 3)
  5. ...
  6. ...
  7. ...
  8. SELECT * FROM `cars` WHERE (`cars`.`user_id` = 10)

解决方法,加上includes

  1. # your controller
  2. def index
  3. @users = User.includes(:car).page(params[:page])
  4. end

如此SQL query就只有两个,只用一个就捞出所有Cars资料。

  1. SELECT * FROM `users` LIMIT 10 OFFSET 0
  2. 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会把所有字段的资料都读取出来,如果其中有textbinary字段资料量很大,就会每次都佔用很多不必要的内存拖慢效能。使用select可以只读取出你需要的资料:

  1. Event.select(:id, :name, :description).limit(10)

进一步我们可以利用scope先设定好select范围:

  1. class User < ApplicationRecord
  2. scope :short, -> { select(:id, :name, :description) }
  3. end
  4. User.short.limit(10)

计数快取 Counter Cache

如果需要常计算has_manyModel有多少笔资料,例如显示文章列表时,也要显示每篇有多少留言回复。

  1. <% @topics.each do |topic| %>
  2. 主题:<%= topic.subject %>
  3. 回复数:<%= topic.posts.size %>
  4. <% end %>

这时候Rails会产生一笔笔的SQL count查询:

  1. SELECT * FROM `posts` LIMIT 5 OFFSET 0
  2. SELECT count(*) AS count_all FROM `posts` WHERE (`posts`.topic_id = 1 )
  3. SELECT count(*) AS count_all FROM `posts` WHERE (`posts`.topic_id = 2 )
  4. SELECT count(*) AS count_all FROM `posts` WHERE (`posts`.topic_id = 3 )
  5. SELECT count(*) AS count_all FROM `posts` WHERE (`posts`.topic_id = 4 )
  6. SELECT count(*) AS count_all FROM `posts` WHERE (`posts`.topic_id = 5 )

Counter cache功能可以把这个数字存进数据库,不再需要一笔笔的SQL count查询,并且会在Post数量有更新的时候,自动更新这个值。

首先,你必须要在Topic Model新增一个字段叫做posts_count,依照惯例是count结尾,型别是_integer,有默认值0。

  1. rails g migration add_posts_count_to_topic

编辑Migration

  1. class AddPostsCountToTopic < ActiveRecord::Migration[5.1]
  2. def change
  3. add_column :topics, :posts_count, :integer, :default => 0
  4. Topic.pluck(:id).each do |i|
  5. Topic.reset_counters(i, :posts) # 全部重算一次
  6. end
  7. end
  8. end

编辑Models,加入:counter_cache => true

  1. class Topic < ApplicationRecord
  2. has_many :posts
  3. end
  4. class Posts < ApplicationRecord
  5. belongs_to :topic, :counter_cache => true
  6. end

这样同样的@topic.posts.size程式,就会自动变成使用@topic.postscount,而不会用_SQL count查询一次。

Batch finding

如果需要捞出全部的资料做处理,强烈建议最好不要用all方法,因为这样会把全部的资料一次放进内存中,如果资料有成千上万笔的话,效能就坠毁了。解决方法是分次捞,每次几捞几百或几千笔。虽然自己写就可以了,但是Rails提供了Batch finding方法可以很简单的使用:

  1. Article.find_each do |a|
  2. # iterate over all articles, in chunks of 1000 (the default)
  3. end
  4. Article.find_each( :batch_size => 100 ) do |a|
  5. # iterate over published articles in chunks of 100
  6. end

或是

  1. Article.find_in_batches do |articles|
  2. articles.each do |a|
  3. # articles is array of size 1000
  4. end
  5. end
  6. Article.find_in_batches( :batch_size => 100 ) do |articles|
  7. articles.each do |a|
  8. # iterate over all articles in chunks of 100
  9. end
  10. end

Transaction for group operations

Transaction交易范围内的SQL效能会加快,因为最后只需要COMMIT一次即可:

  1. my_collection.each do |q|
  2. Quote.create({:phrase => q})
  3. end
  4. # Add transaction
  5. Quote.transaction do
  6. my_collection.each do |q|
  7. Quote.create({:phrase => q})
  8. end
  9. end

全文搜寻Full-text search engine

如果需要搜寻text字段,因为数据库没办法加索引,所以会造成table scan把资料表所有资料都扫描一次,效能会非常低落。这时候可以使用外部的全文搜寻服务器来做索引,目前常见有以下选择:

SQL 效能分析

QueryReviewer这个套件透过SQL EXPLAIN分析SQL query的效率

逆正规化(de-normalization)

一般在设计关联式数据库的table时,思考的都是正规化的设计。透过正规化的设计,可以将资料不重复的储存,省空间,更新也不易出错。但是这对于复杂的查询有时候就力有未逮。因此必要时可以采用逆正规化的设计。牺牲空间,增加修改的麻烦,但是让读取这事件变得更快更简单。

上述章节的Counter Cache,其实就是一种逆正规化的应用,只是Rails帮你包装好了。如果你要自己实作的话,可以善用CallbackObserver来作更新。以下是一个应用的范例,Event的总金额,是透过Invoice#amount的总和得知。另外,我们也想知道该活动最后一笔Invoice的时间:

  1. class Event < ApplicationRecord
  2. has_many :invoices
  3. def amount
  4. self.invoices.sum(:amount)
  5. end
  6. def last_invoice_time
  7. self.invoices.last.created_at
  8. end
  9. end
  10. class Invoice < ApplicationRecord
  11. belongs_to :event
  12. end

如果有一页是列出所有活动的总金额和最后Invoice时间,那么这一页就会产生2N+1SQL查询(N是活动数量)。为了改善这一页的读取效能,我们可以在events资料表上新增两个字段amountlast_invoice_time。首先,我们新增一个Migration:

  1. add_column :events, :amount, :integer, :default => 0
  2. add_column :events, :last_invoice_time, :datetime
  3. # Data migration current data
  4. Event.find_each do |e|
  5. e.amount = e.invoices.sum(:amount)
  6. e.last_invoice_time = e.invoices.last.try(:created_at) # e.invoices.last 可能是 nil
  7. e.save(:validate => false)
  8. end

接着程式就可以改成:

  1. class Event < ApplicationRecord
  2. has_many :invoices
  3. def update_invoice_cache
  4. self.amount = self.invoices.sum(:amount)
  5. self.last_invoice_time = self.invoices.last.try(:created_at)
  6. self.save(:validate => false)
  7. end
  8. end
  9. class Invoice < ApplicationRecord
  10. belongs_to :event
  11. after_save :update_event_cache_data
  12. protected
  13. def update_event_cache_data
  14. self.event.update_invoice_cache
  15. end
  16. 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才用sendfile送出档案,这时候可以打开:x_sendfile表示你将传档的工作委交由_Web服务器的xsendfile模组负责。当然,Web服务器得先安装好x_sendfile功能:

由 CDN 提供静态档案

静态档案也放在CDN上让全世界的使用者在最近的下载点读取。CDN需要专门的CDN厂商提供服务,其中推荐AWS CloudFrontCloudFlare线上就可以完成申请和设定的。

如果要让你的Assets例如CSS, JavaScript, Images也让使用者透过CDN下载,只要修改config/environments/production.rbconfig.actioncontroller.asset_host为_CDN网址即可。

浏览器网页加载效能 Client-side web performance

后端服务器的Response time固然重要,但对终端使用者来说,浏览器完成加载网页的Page Load time才是真正的感受。因此针对CSSJavaScript等等静态内容也有一些可以最佳化的工作,包括:

  • 打开 Gzip
  • 加上快取 HTTP Headers
  • 压缩JavaScriptCSS
  • 使用CDN更多文件和工作请参考:

如果有用HTTPS安全连线的话,推荐打开网站服务器的HTTP/2(前身是SPDY)支援,最新的最佳化技巧又有了一些变化,详见更快更安全: 每个网站都应该升级到 HTTP/2一文。

如何写出执行速度较快的Ruby程式码

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

事实上,Rails 有许多方法其实并不是以效能为第一考量,而是以「程式设计师的幸福最大化」为原则。这个设计的哲学请参考Ruby on Rails 基本主义

使用更快的Ruby函式库

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

使用外部程式

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

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

其他线上资源