文章目录
  1. 1. 课程目标
  2. 2. 课程复盘
    1. 2.1. 1. CDN
      1. 2.1.1. 如何在 Rails 上实现
      2. 2.1.2. 在哪可以找到 CDN 服务
    2. 2.2. 2. 关于 bundle exec
    3. 2.3. 3.后端效能分析
      1. 2.3.1. 安装第三方效能分析服务
      2. 2.3.2. 后端效能提速的方向
    4. 2.4. 4.避免 N+1 SQL 查询
      1. 2.4.1. 改进 posts#show 的 N+1
      2. 2.4.2. includes 多个关联
      3. 2.4.3. includes 有条件怎么办?
      4. 2.4.4. 用工具自动侦测 N+1 Queries
    5. 2.5. 5.ActiveRecord 优化技巧
      1. 2.5.1. 避免 .all 查询
      2. 2.5.2. find_each 技巧
      3. 2.5.3. 预加载(Preload)概念
      4. 2.5.4. count 和 size 方法
      5. 2.5.5. 避免重复 SQL 查询
      6. 2.5.6. pluck 技巧

课程目标

  • Part 1: 前言
    • 网站效能基础概念
  • Part 2: 前端效能
    • 前端效能分析
    • 减少 Requests 数量和大小
    • HTTP 最佳化
    • 关键渲染路径
    • CDN
  • Part 3: 后端效能
    • 项目准备
    • 后端效能分析
    • 避免 N+1 SQL 查询
    • ActiveRecord 优化技巧
    • 数据库 SQL 优化
    • 计数缓存 (Counter Cache)
    • 改进 Render Partial 的效能
    • 数据库索引
    • 后端缓存和延展性(Scalability)

课程复盘

1. CDN

服务器如果离用户比较近,网络传输的时间就会比较快。不过如果用户散布是世界各地的话,那么服务器放哪里都会有人距离比较远。这种情况怎么办呢?

这时候我们就会使用 CDN (Content delivery network) 这种服务来加速静态档案的下载。

CDN 就是专门用来提供静态档案的服务器,用户从距离最近的 CDN 服务器下载静态档案,如果 CDN 上面没有需要的档案,那么 CDN 会从我们的服务器上下载一份回去缓存起来。

只有静态档案例如 CSS/JavaScript和图片等等会放在 CDN 上,如果是动态内容用户一定要访问我们自己的服务器

首先我们需要将 HTML 网页上静态档案网址分开,改成 CDN 的网址:

  • 例如服务器网站是 www.jd.com,你会从这个网址先下载 HTML
  • 但是 HTML 上的图片网址则是不同的,例如是 cdn.jd.com 好了,这个是 CDN 的网址
    • CDN 技术做的是针对不同地区的用户,自动提供他们最近的点下载档案
  • 不同地区的用户,针对 cdn.jd.com 会解析出不同的 IP 地址,选择用最近的服务器
  • 如果 CDN 上有档案缓存,就直接从 CDN 上吐给用户
  • 如果 CDN 上没有档案缓存,就从原站拉一份,快取在 CDN 上

如何在 Rails 上实现

不需要去改网站上的 image_tag 一个一个处理。

只要在 Rails 上改全站的图片来源,只要修改 config/enviorments/production.rb 里的这一行就可以了。

config/enviorments/production.rb
1
config.action_controller.asset_host = "https://cdn.jd.com"

这样全站的 image/css/js 网址,就会全部变成

  • cdn.jd.com/images/demo.jpg
  • cdn.jd.com/assets/admin.css
  • cdn.jd.com/assets/admin.js

在哪可以找到 CDN 服务

中国境内 CDN 服务(网站需要备案才能申请使用)

国外 CDN (随时申请随时使用,但是中国境内没有 CDN 节点)

2. 关于 bundle exec

命令bundle exec 只是负责加载 Gemfile.lock 中的 gem.

如果你用gem install rake安装了10.1.0版本的rake(假设是最新的),当你直接使用调用rake时,使用的会是这个最新版本的rake。

如果项目的Gemfile中指定的版本是0.9.6(或者是Gemfile.lock中是0.9.6)的话,你如果不加bundle exec,将会用rake 10.1.0的版本去执行本来应该由0.9.6版本的rake写出的Rake task。

会不会出问题?可能会,可能不会。因为很有可能原作者使用0.9.6版本的rake写的Rake task中没有什么被废弃的部分,10.1.10也能正确执行。但是不兼容的情况也会发生。

bundle exec就是为了解决这样的问题而存在的:在目前的Bundle环境里执行某个操作,这样对于不同的人来说,不论系统里是什么版本的Gem,总是会使用该项目Gemfile中指定的版本来执行某个操作

例如:

1
2
bundle exec rake db:migrate
bundle exec rake fake

3.后端效能分析

后端效能要关注的是个别 HTTP Request 的反应时间,也就是 Response Time。

  • 这个时间在 Rails log 中可以看到。

  • 或是可以安装 rack-mini-profiler 这个 gem,就可以在画面上直接看到这个数据:

编辑 Gemile

Gemfile
1
+ gem 'rack-mini-profiler'

执行 bundle,重开 Rails。


这样网页左上角就会出现 Response Time 的数据,点开来后还可以进到进一步的分析。

安装第三方效能分析服务

80/20法则:会拖慢整体效能的程式,只佔全部程式的一小部分而已,所以我们只最佳化会造成问题的程式。接下来的问题就是,如何找到那一小部分的效能瓶颈。善用分析工具找效能瓶颈,最佳化前需要测量,最佳化后也要测量比较。

透过以下的效能分析服务,可以收集网站实际营运的数据,找出哪些部分是效能不好的地方加以改善:

如果你有实际营运的网站,请挑一家注册试用,根据它的说明会需要安装它的 gem。

后端效能提速的方向

对后端来说,

  • 一个方向是提供 Rails 和 Ruby 代码的效能,
  • 一个方向是提供数据库方面的效能。

根据经验,很大的机率会慢在数据库的读取上,这是因为 Rails 开发者很容易沉浸在 ActiveRecord 带来的开发高效率上,而忽略了 ActiveRecord 很容易不小心就产生了效能差劲的 SQL 查询。存取数据库是一种相对于 CPU 运算很慢的 I/O 硬盘操作:每一条 SQL 查询都得耗上时间、执行回传的结果也会被转成 ActiveRecord 对象然后放进内存。

4.避免 N+1 SQL 查询

N+1 queries 是数据库效能头号杀手。ActiveRecord 的关联功能功能很方便,但很容易发出过多的 SQL 查询。在示范项目中,每篇贴文(Post) belongs_to 作者(User),请打开示范项目的首页 http://localhost:3000,观察一下 Rails Log:

发现到很多很类似的

1
SELECT "users".* FROM "users" WHERE "users"."id" = ? LIMIT ? [["id", XXX], ["LIMIT", 1]]

而且根据 rack-mini-profiler 的数据,这一页总共发出了 26 个 SQL 查询,怎么会这么多?

关键在出在 app/views/posts/index.html.erb

1
2
3
4
5
6
<% @posts.each do |post| %>
<tr>
<td><%= link_to post.title, post_path(post) %></td>
<td><%= post.user.display_name %></td>
</tr>
<% end %>

这个循环中,每一次都需要读取 post.user,造成了所谓的 N+1 问题,当一页 Post 有 25 笔时,总共发出了 26 个 SQL 查询,一笔是 SELECT * FROM posts,另外 25 笔是一笔一笔去 SELECT * FROM users WHERE users.id = XXX,严重拖慢了效能。

Rails 针对重复的 SQL 查询有做缓存,所以截图中有的是 CACHE User Load。截图中最后一个 SQL 查询 SELECT COUNT(*) FROM posts 是计算分页的总页数用到的。

解决方法也不难,我们需要在捞 posts 数据的时候,就要先告诉 ActiveRecord 我们也需要 posts 的 user 数据,这样 ActiveRecord 就会预先捞出所有需要的 users 数据。

用到的语法是加上 includes,请修改 app/controllers/posts_controller.rb,把需要一起捞出来的关联 model 加上去即可:

app/controllers/posts_controller.rb
1
2
3
4
def index
- @posts = Post.page(params[:page])
+ @posts = Post.includes(:user).page(params[:page])
end

在观察一次 Log,SQL 查询就只剩下两条了。一条捞 Posts,一条捞 Users。速度从 1173ms 提升到 117ms,快了十倍!

改进 posts#show 的 N+1

接下来点进任一篇文章,文章有许多留言,留言的作者也有一样的 N+1 问题,让我们处理一下:

app/controllers/posts_controller.rb
1
2
3
4
5
def show
@post = Post.find(params[:id])
- @comments = @post.comments
+ @comments = @post.comments.includes(:user)
end

includes 多个关联

includes 也可以一次捞多个关联的数据,首先让我们增加一个情境是 posts#index 页面显示每篇贴文的浏览用户,以及按讚的用户:

编辑 app/views/posts/index.html.erb

app/views/posts/index.html.erb
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
<table class="table">
<tr>
<th>标题</th>
<th>作者</th>
+ <th>留言用户</th>
+ <th>按讚用户</th>
</tr>
<% @posts.each do |post| %>
<tr>
<td><%= link_to post.title, post_path(post) %></td>
<td><%= post.user.display_name %></td>
+ <td><%= post.comments.map{ |c| c.user.display_name }.join(",") %></td>
+ <td><%= post.liked_users.map{ |u| u.display_name }.join(",") %></td>
</tr>
<% end %>
</table>

再次浏览 http://localhost:3000 看看 log,果然 N+1 又冒出来了,吓死人的多。

让我们加上 includes,修改 app/controllers/posts_controller.rb

app/controllers/posts_controller.rb
1
2
3
4
def index
- @posts = Post.includes(:user).page(params[:page])
+ @posts = Post.includes(:user, :liked_users, { :comments => :user } ).page(params[:page])
end

其中 { :comments => :user } 这个 Hash 表示除了捞 comments 之外,还包括它的下一层 user 关联。

includes 有条件怎么办?

这个范例项目的 Comment model 有一个字段是 status 状态,表示这个留言可以是公开(public)或私密留言(private),因此在 posts index 页面上我们希望不要显示状态是私密的留言作者:

编辑 app/models/comment.rb 加上一个 scope:

app/models/comment.rb
1
2
3
4
5
6
7
8
class Comment < ApplicationRecord
belongs_to :user
belongs_to :post
+ scope :visible, -> { where( :status => "public") }
end

接下来你可能会直接修改 app/views/posts/index.html.erb 套用这个 scope:

app/views/posts/index.html.erb
1
2
- <td><%= post.comments.map{ |c| c.user.display_name }.join(",") %></td>
+ <td><%= post.comments.visible.map{ |c| c.user.display_name }.join(",") %></td>

观察一下 rails log,很不幸的 N+1 又出现了,ActiveRecord 没这么聪明,它认为事先 includes 的 post.comments 跟这里的 post.comments.visible 是不一样的,所以发出了 N+1 Queries

1
<td><%= post.comments.visible.map{ |c| c.user.display_name }.join(",") %></td>

map的用法。

我们需要在 Post model 增加一个有条件的关联,修改 app/models/post.rb

app/models/post.rb
1
2
has_many :comments
+ has_many :visible_comments, -> { visible }, :class_name => "Comment"

然后修改 app/controllers/posts_controller.rb 改用这个新的有条件的关联:

app/controllers/posts_controller.rb
1
2
3
4
def index
- @posts = Post.includes(:user, :liked_users, { :comments => :user } ).page(params[:page])
+ @posts = Post.includes(:user, :liked_users, { :visible_comments => :user } ).page(params[:page])
end

最后修改 app/views/posts/index.html.erb

app/views/posts/index.html.erb
1
2
- <td><%= post.comments.map{ |c| c.user.display_name }.join(",") %></td>
+ <td><%= post.visible_comments.map{ |c| c.user.display_name }.join(",") %></td>

这样就大功告成了,观察 Rails log 可以看到 N+1 Queries 不见了。

用工具自动侦测 N+1 Queries

Bullet 这个 gem https://github.com/flyerhzm/bullet 可以在开发时协助侦测 N+1 queries 问题

5.ActiveRecord 优化技巧

除了 N+1 之外,还有一些使用 ActiveRecord 要注意的地方,其中一个最重要的观念就是内存的使用:数据库的数据放在硬盘,当我们使用 ActiveRecord 读取数据时,会将数据从硬盘读出,变成 Ruby 对象放在内存中,这是会耗费内存资源的,我们需要优化内存的使用。

避免 .all 查询

硬盘的空间比内存大得多,放在数据库的数据可能成千上万笔。因此当你用例如 Post.all 查询时,会将所有的 Post 数据读进内存,当数据很多时就会非常慢。

解决方法我们都很熟悉了,就是使用分页的机制,使用 will_paginatekaminari来做分页功能。

find_each 技巧

如果真的需要捞出全部的数据做处理,就需要分次捞才不会一次把内存吃光。Rails 针对这个情境提供了批次方法
一个常见的情境是修理数据,假设我们想要在 Post 上新增一个字段是 date,但是刚新增的字段没有数据,我们需要走访所有的 Post 贴文去补上这个数据:

执行 rails g migration add_date_to_posts

编辑 db/migrate/2017XXXXXXXXXX_add_date_to_posts.rb

db/migrate/2017XXXXXXXXXX_add_date_to_posts.rb
1
2
3
4
5
6
7
8
9
10
class AddDateToPosts < ActiveRecord::Migration[5.1]
def change
+ add_column :posts, :date, :date
+
+ Post.find_each do |post|
+ post.date = post.created_at.to_date
+ post.save( :validate => false )
+ end
end
end

执行 rake db:migrate 就会新增 date 字段,然后用 Post.find_each 走访所有贴文补上 date 数据,这个方法会每一千笔每一千笔去捞出 Posts 数据,而不是一次全部捞出来。

预加载(Preload)概念

留言有分公开(Public)和私密(Private)状态,让我们修改 Post show 页面来反应这个需求:改成显示全部公开的留言,以及我自己的私密留言。

修改 app/controllers/posts_controller.rb

app/controllers/posts_controller.rb
1
2
3
4
5
6
7
8
9
def show
@post = Post.find(params[:id])
- @comments = @post.comments.includes(:user)
+ @comments = @post.comments.visible.includes(:user)
+ if current_user
+ @my_comments = @post.comments.where( :status => "private", :user_id => current_user.id ).includes(:user)
+ end
end

修改 app/views/posts/show.html.erb 加上我们私密留言

app/views/posts/show.html.erb
1
2
3
4
5
6
7
8
9
10
11
12
13
14
</table>
+ <% if current_user %>
+ <h2>My Comments</h2>
+
+ <table class="table">
+ <% @my_comments.each do |comment| %>
+ <tr>
+ <td><%= comment.content %></td>
+ <td><%= comment.user.display_name %></td>
+ </tr>
+ <% end %>
+ </table>
+ <% end %>

看看 log 可以看到捞 comments 捞了两次,一次是捞公开留言,一次是捞我的留言。

不过,如果你仔细想想,这两个查询根本就可以一次就捞出来,修改 app/controllers/posts_controller.rb

app/controllers/posts_controller.rb
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
def show
@post = Post.find(params[:id])
- @comments = @post.comments.visible.includes(:user)
if current_user
- @my_comments = @post.comments.where( :status => "private", :user_id => current_user.id ).includes(:user)
+ all_comments = @post.comments.where("status = ? OR (status = ? AND user_id = ?)", "public", "private", current_user.id).includes(:user)
+ @comments = all_comments.select{ |x| x.status == "public" }
+ @my_comments = all_comments.select{ |x| x.status == "private" }
+ else
+ @comments = @post.comments.visible.includes(:user)
end
end

all_comments 就是我们预先捞出来的 comments,利用了 SQL 条件

1
"status = ? OR (status = ? AND user_id = ?)"

捞出所有公开或我的私密留言。然后 @comment 和 @my_comments 是用 select 这个数组方法,从内存中再分别过滤出公开留言和我的私密留言。

这就是预先加载(Preload)概念: 我们尽可能合并 SQL 查询一次捞出,然后再用数组方法 select 过滤出需要的结果。

再次看一下 log,只捞了一次 comments 了。

count 和 size 方法

countsize 方法都可以查询数量,这两个方法有什么差异吗?我们在 posts show 页面上显示一下留言的数量,请
编辑 app/views/posts/show.html.erb,在最下方加入:

app/views/posts/show.html.erb
1
2
Total: <%= @comments.count %>
Total: <%= @comments.count %>

这里故意放两行来示范效果
然后看一下 log:

会看到有两条 COUNT 的 SQL。让我们改成 .size 方法看看:

app/views/posts/show.html.erb
1
2
3
4
- Total: <%= @comments.count %>
- Total: <%= @comments.count %>
+ Total: <%= @comments.size %>
+ Total: <%= @comments.size %>

会发现竟然没有 COUNT SQL 了。

  • 调用 count 方法是对数据库送出一次 COUNT 的 SQL 查询
  • 而size 是数组的方法,因为 @comments 这个对象已经在内存了,调用 size 是去计算这个 @comments里面元素的数量,因此不需要再发出 COUNT 的 SQL 查询。

在这个范例中,因为画面中已经显示了 @comments,表示这个数据已经从数据库中捞出,所以适合用 .size 方法,而不需要用 .count 重复再问一次数据库。

让我们换一个情境,在 posts index 页面上显示留言的数量,请修改 app/views/posts/index.html.erb,加上留言数:

app/views/posts/index.html.erb
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
<table class="table">
<tr>
<th>标题</th>
<th>作者</th>
+ <th>留言数</th>
<th>留言用户</th>
<th>按讚用户</th>
</tr>
<% @posts.each do |post| %>
<tr>
<td>
<% if current_user && current_user.like_post?(post) %>
👍👍👍
<% end %>
<%= link_to post.title, post_path(post) %>
</td>
<td><%= post.user.display_name %></td>
+ <td><%= post.visible_comments.count %></td>
<td><%= post.visible_comments.map{ |c| c.user.display_name }.join(",") %></td>
<td><%= post.liked_users.map{ |u| u.display_name }.join(",") %></td>
</tr>
<% end %>
</table>

看一下 log,发现 N+1 又出来了。

这个情境下用 count 就不对了,因为 post.visible_comments 我们其实已经捞出来了,应该用 size 方法去算即可,不需要再问一次数据库:

1
2
- <td><%= post.visible_comments.count %></td>
- <td><%= post.visible_comments.size %></td>

再看一次 log,多的 SQL queries 现在都没了。

避免重复 SQL 查询

情境是我们想在 posts index 页面上显示我是否有按过讚:

修改 app/models/user.rb,让我们新增一个方法判断 User 有没有针对一篇 Post 按过 Like:

app/models/user.rb
1
2
3
4
def like_post?(post)
# 或是写 self.likes.where( :post_id => post.id ).first.present? 也可以
self.likes.where( :post_id => post.id ).exists?
end

修改 app/views/posts/index.html.erb

app/views/posts/index.html.erb
1
2
3
4
5
6
7
- <td><%= link_to post.title, post_path(post) %></td>
+ <td>
+ <% if current_user && current_user.like_post?(post) %>
+ 👍👍👍
+ <% end %>
+ <%= link_to post.title, post_path(post) %>
+ </td>

代码看起来很简单,一下就写好了,让我看看对效能有没有影响:

吓死人,怎么多出这么多多 SELECT "likes".* FROM "likes" WHERE "likes"."user_id" = ? AND "likes"."post_id" = ? ORDER BY "likes"."id" ASC LIMIT ?,每篇贴文都去查询一次有没有 Like。

如果你已经有了上一节预加载(Preload)观念,就会联想到这个 likes 数据,我们在上一章其实已经捞出来了,也就是 liked_users,我们应该去检查贴文的 liked_users 里面有没有我自己,就可以判断我有没有按过讚了。

修改 app/models/user.rb

app/models/user.rb
1
2
3
4
5
def like_post?(post)
- self.likes.where( :post_id => post.id ).exists?
# post.liked_users 实际上在 controler 中已经被取出放进内存了,这里用数组的 include? 方法去检查里面有没有我自己
+ post.liked_users.include?(self)
end

再次看一下 log,发现多的 SQL 都不见了 👍👍👍

不过请放心:你是很难有先见之明知道要这样写的,因为不同页面会加载的数据不同,需要因地制宜的优化。

pluck 技巧

使用 ActiveRecord 从数据库中取出数据时,会形成 ActiveRecord 对象放进内存,而这个 ActiveRecord 类其实有点肥大,因为它本身包含很多操作方法等等。因此在只需要取出单纯数据,而不需要 ActiveRecord 任何功能的时候,可以用 pluck 方法,例如我们只想要取出所有用户的 email 数据:

1
emails = User.all.map{ |u| u.email }


1
emails = User.pluck(:email)

两者的速度差了非常多:前者需要将所有用户捞出来变成 ActiveRecord 对象,然后再转成 email 的数组。后者直接就是 email 数组。

文章目录
  1. 1. 课程目标
  2. 2. 课程复盘
    1. 2.1. 1. CDN
      1. 2.1.1. 如何在 Rails 上实现
      2. 2.1.2. 在哪可以找到 CDN 服务
    2. 2.2. 2. 关于 bundle exec
    3. 2.3. 3.后端效能分析
      1. 2.3.1. 安装第三方效能分析服务
      2. 2.3.2. 后端效能提速的方向
    4. 2.4. 4.避免 N+1 SQL 查询
      1. 2.4.1. 改进 posts#show 的 N+1
      2. 2.4.2. includes 多个关联
      3. 2.4.3. includes 有条件怎么办?
      4. 2.4.4. 用工具自动侦测 N+1 Queries
    5. 2.5. 5.ActiveRecord 优化技巧
      1. 2.5.1. 避免 .all 查询
      2. 2.5.2. find_each 技巧
      3. 2.5.3. 预加载(Preload)概念
      4. 2.5.4. count 和 size 方法
      5. 2.5.5. 避免重复 SQL 查询
      6. 2.5.6. pluck 技巧