文章目录
  1. 1. 课程目标
  2. 2. 课程复盘
    1. 2.1. 1. 数据库 SQL 优化
    2. 2.2. 2. 计数缓存 (Counter Cache)
      1. 2.2.1. 计数缓存 (Counter Cache)
      2. 2.2.2. 再一个逆规范化的例子
      3. 2.2.3. 小结论:什么时候用逆规范化做优化?
    3. 2.3. 3. 改进 Render Partial 的效能
    4. 2.4. 4. 数据库索引
      1. 2.4.1. 常见的效能错误
      2. 2.4.2. SQL explain 机制
    5. 2.5. 5. 后端缓存和延展性(Scalability)
      1. 2.5.1. 内存缓存
      2. 2.5.2. 网站延展性

课程目标

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

课程复盘

1. 数据库 SQL 优化

越了解 SQL,使用 ActiveRecord 就越得心应手。浏览 http://localhost:3000/posts/report 这一页:

这一页会显示哪些 Post 的订阅数(Subscription)最多,依照留言数排序:

看一下 log,不太妙:

本来的实作有什么问题呢?打开 app/controllers/posts_controller.rb 这个档案进行优化:

app/controllers/posts_controller.rb
1
2
3
4
def report
- @posts = Post.all.include(:user).sort_by{ |post| post.subscriptions.size }.reverse[0,10]
+ @posts = Post.includes(:user).joins(:subscriptions).group("posts.id").select("posts.*, COUNT(subscriptions.id) as subscriptions_count").order("subscriptions_count DESC").limit(10)
end

接着修改 app/views/posts/report.html.erb

app/views/posts/report.html.erb
1
2
- <td><%= post.subscriptions.size %></td>
+ <td><%= post.subscriptions_count %></td>

优化之后的结果:

解说:因为订阅数并不是 Post 的一个字段,我们不能直接写 Post.order(“subscriptions_count DESC”).limit(10),需要想办法去计算订阅数:

  • 本来的做法需要捞出所有的 Post 贴文到内存中,每篇贴文计算订阅数,然后数组排序之后,取出前十名
  • 新的做法是 SQL 的 group 语法,交由数据库的引擎来计算,最后刚刚好取出 10 笔数据成为 Ruby 对象放入内存

在购物网站中,这个其实就是购物车分析的功能:你可以分析哪些商品最常被加入购物车但是没有结帐。
像这种计算报表类型的应用,如果你把数据从数据库都取出用 Ruby 来计算的话,效能会非常差。你需要利用 SQL 来让数据库引擎来做内部运算,效能才会快。

2. 计数缓存 (Counter Cache)

在数据库教程中,我们提过逆规范化(Denormalized)的概念,这一章让我们来实际实作看看。

想要做的情境是 posts index 页面上,我们想要显示订阅数(Subscriptions)。首先编辑 app/views/posts/index.html.erb 加上订阅数:

app/views/posts/index.html.erb
1
2
3
4
5
6
7
<th>留言数</th>
+ <th>订阅数</th>
# 略
<td><%= post.visible_comments.size %></td>
+ <td><%= post.subscriptions.size %></td>


不意外的,这样写造成了很多 SQL 查询:


跟显示 visible_comments 留言数不同,订阅的数据并没有被预先加载,所以需要一笔一笔去 COUNT。要怎么改善呢?

如果你熟悉 SQL 的话,可以用 SQL 解决,编辑 app/controllers/posts_controller.rb

app/controllers/posts_controller.rb
1
2
3
4
5
6
def index
@posts = Post.includes(:user, :liked_users, { :visible_comments => :user } ).page(params[:page])
+ post_ids = @posts.map{ |p| p.id }
+ @subscriptions_count = Post.where( :id => post_ids).joins(:subscriptions).group("posts.id").count
end

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

app/views/posts/index.html.erb
1
2
- <td><%= post.subscriptions.size %></td>
+ <td><%= @subscriptions_count[post.id] %></td>

观察一下 log,我们只用一条 SQL 就可以计算这一页贴文的所有订阅数:

@subscriptions_count 这个变量是个 Hash,键是 post ID,值是订阅数,例如 {403=>59, 404=>89, 405=>10, 406=>93, 407=>10, 408=>47, 409=>90, 410=>78, 411=>79, 412=>43, 413=>58, 414=>13, 415=>61, 416=>76, 417=>97, 418=>59, 419=>41, 420=>68, 421=>44, 422=>44, 423=>85, 424=>95, 425=>12, 426=>54, 427=>78}
不过,这一页是流量最高的首页,有没有办法可以更快更简单?

计数缓存 (Counter Cache)

像这种 Post has_many subscriptions 的一对多关系,如果经常要显示有多少笔数据,与其每次都用 SQL 计算,我们可以用逆规范化的概念,直接新增一个 posts 的字段把订阅的数字存下来,这样显示的时候直接就可以显示了,不需要再计算。然后每次新增或删除 Subscription 时,需要记得去更新这个值即可。

Rails 内建就有计数缓存 (Counter Cache) 的功能:

执行 rails g migration add_subscriptions_to_posts

编辑 db/migrate/2017XXXXXXXXXX_add_subscriptions_to_posts.rb,新增一个字段 subscriptions_count 到 posts 上,表示这篇贴文有多少订阅数:

db/migrate/2017XXXXXXXXXX_add_subscriptions_to_posts.rb
1
2
3
4
5
6
7
8
9
10
class AddSubscriptionsToPosts < ActiveRecord::Migration[5.1]
def change
+ add_column :posts, :subscriptions_count, :integer, :default => 0
+ Post.pluck(:id).each do |i|
+ Post.reset_counters(i, :subscriptions) # 刚新增的字段都是 0,需要将计数全部重算一次
+ end
end
end

编辑 app/models/subscription.rb,加上 counter_cache,这会告诉 Rails 如果有新增或删除 Subscription 时,自动去更新 Post 的 subscriptions_count 数字:

app/models/subscription.rb
1
2
3
4
- belongs_to :post
+ belongs_to :post, :counter_cache => true
# 或 belongs_to :post, :counter_cache => "subscriptions_count"

执行 rake db:migrate

修改 app/views/posts/index.html.erb,直接显示这个数字:

app/views/posts/index.html.erb
1
2
- <td><%= @subscriptions_count[post.id] %></td>
+ <td><%= post.subscriptions_count %></td>

修改 app/controllers/posts_controller.rb,不需要再计算订阅数了:

app/controllers/posts_controller.rb
1
2
3
4
5
6
def index
@posts = Post.includes(:user, :liked_users, { :visible_comments => :user } ).page(params[:page])
- post_ids = @posts.map{ |p| p.id }
- @subscriptions_count = Post.where( :id => post_ids).joins(:subscriptions).group("posts.id").count
end

Rails 内建的 Counter Cache 功能比较简单,如果你需要更多功能,请参考 https://github.com/magnusvk/counter_culture 这个 gem。

再一个逆规范化的例子

需求情境:在 posts index 页面上,显示每篇贴文的最后订阅的时间

逆规范化解法:

  1. 在 posts table 上新增一个一个 last_subscribed_at 时间字段
  2. 在有人订阅时,例如 subscriptions controller 的 create action 中,去更新该篇 post 的 last_subscribed_at 值
  3. 在有人取消订阅时,例如 subscriptions controller 的 destroy action 中,去更新该篇 post 的 last_subscribed_at 值

跟 Counter Cache 概念一样,只是实作麻烦一点,我们需要手动在正确的时机去维护 last_subscribed_at 的值

小结论:什么时候用逆规范化做优化?

如果不常显示该数据,而且你会写 SQL 做计算的话,我们可以用纯 SQL 的方式来解决。但是如果需要经常显示该数据,就可以考虑用逆规范化的方式,将数据缓存下来。这样效能可以更好。但是缺点就是需要维护该数据的正确性,要写的 Ruby 代码也比较多。

考量:读取的频率 v.s. 更新缓存数据的成本

3. 改进 Render Partial 的效能

这个是常用的技巧。
这一章让我们看一个 Rails View 的效能改善,情境是当同一个 partial 需要不断 render 时,可以改用 collection 的写法,效能会更好。

例如我们将 index 页面中每一笔 post 改成用 partial 处理:

修改 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
<% @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.size %></td>
- <td><%= post.subscriptions_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>
+ <%= render :partial => "post", :locals => { :post => post } %>
<% end %>

新增 app/views/posts/_post.html.erb

app/views/posts/_post.html.erb
1
2
3
4
5
6
7
8
9
10
11
12
13
<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.size %></td>
<td><%= post.subscriptions_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>

看一下 log,其中不断调用 Rendered posts/_post.html.erb 这个 partial 样板:

怎么改进呢?Rails 针对这种情况,有提供一个优化的写法,再次编辑 app/views/posts/index.html.erb

app/views/posts/index.html.erb
1
2
3
4
5
6
- <% @posts.each do |post| %>
- <%= render :partial => "post", :locals => { :post => post } %>
- <% end %>
+ <%= render :partial => "post", :collection => @posts, :as =>
:post %>

整个 @posts.each 循环都拿掉了,新的 :collection 参数就会帮你做循环。


为什么这种用法会比较快呢?本来的写法 Rails 需要针对每个 partial 都做一次编译处理,新的写法 Rails 知道这些 partial 原来是同一个 partial,因此只需要编译处理一次。

4. 数据库索引

在数据库教程中(在 SQL 语言: DML 这一章最后一节),我们有提到加上 Indexes 索引的重要,忘记帮数据库加上索引也是常见的效能杀手,作为搜寻条件的字段如果没有加索引,SQL 查询的时候就会一笔笔检查资料表中的所有资料,当资料一多的时候相差的效能就十分巨大,没索引是 O(N),有索引是 O(logN)。

一般来说,以下的字段都必须记得加上索引:

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

让我们补上忘记的索引:

执行 rails g migration add_indexes

编辑 db/migrate/20170XXXXXXXXX_add_indexes.rb

db/migrate/20170XXXXXXXXX_add_indexes.rb
1
2
3
4
5
6
7
8
9
10
11
12
class AddIndexes < ActiveRecord::Migration[5.1]
def change
+ add_index :comments, :status
+ add_index :comments, :post_id
+ add_index :likes, :post_id
+ add_index :likes, :user_id
+
+ add_index :subscriptions, :post_id
+ add_index :subscriptions, :user_id
end
end

执行 rake db:migrate

常见的效能错误

一个常犯的错误是用 created_at 来进行排序,例如想要依照新建时间排序,让新的贴文在上面:

1
@posts = Post.order("created_at DESC").page(params[:page])

由于 created_at 这个字段我们并没有加上索引,如果你只是想要排序,应该改用 id 字串:

1
@posts = Post.order("id DESC").page(params[:page])

因为id是主键本身就有索引,而且它是自动递增的数字。所以根据 id 来排序和根据 created_at 来排序结果是一样的。

SQL explain 机制

对一个复杂的 SQL 查询来说,有没有索引到底有没有派上用场?SQL 在数据库中到底是如何运行的?需要实际用数据库进行分析才会知道。

explain 这个方法可以调用数据库的分析报告:


不同种的数据库(SQLite、PostgreSQL、MySQL)的报告格式不一样,这里就不细说了。

5. 后端缓存和延展性(Scalability)

内存缓存

超高流量的网站会需要用到缓存来进一步提升后端效能。
ihower老师的Rails 实战圣经:缓存

No code is faster than no code. - Merb core tenet

网站延展性

另一个跟「网站效能(Performance)」常一起听到的名词是「网站延展性(Scalability)」。当网站的用户越来越多,流量越来越大的时候,需要想办法扩展网站的承载能力。

扩展的有两种方式:

  1. 垂直扩展:升级服务器,例如用更快的 CPU、用大的硬盘、用多的内存
  2. 水平扩展:增加(租用)更多服务器


垂直扩展在初期比较简单,因为网站代码不需要变更,只需要原地硬件升级即可。但是硬件升级是有上限的,越高等级的服务器越贵。CPU 再怎么快,总不可能我们去租一台超级电脑吧。

水平扩展则比较合乎成本,因为一百台平价的电脑,比起一台超级电脑还便宜。但是水平扩展会需要网站架构的运维能力,对技术的要求比较高。

常见的网站架构演进,请参考 5 Common Server Setups For Your Web Application 这篇文章的图例:

一开始只需要一台服务器

接下来将数据库独立成一台服务器

前面放一台 Load Balancer 服务器分散流量,这样增加更多台应用服务器(Application server,也就是我们的 Rails server)

前面再放 HTTP 缓存服务器


数据库也需要拆分,可以分成 Master 和 Slave 数据库,读写分离。

以上是还算是入门等级的架构,要继续延展的话,就是另一门深似海的学问了。网站的延展性,就是去研究如何在合理的硬件成本下,透过水平扩展持续增加系统容量。这件事情跟 Rails 技术就比较没有关系了。

文章目录
  1. 1. 课程目标
  2. 2. 课程复盘
    1. 2.1. 1. 数据库 SQL 优化
    2. 2.2. 2. 计数缓存 (Counter Cache)
      1. 2.2.1. 计数缓存 (Counter Cache)
      2. 2.2.2. 再一个逆规范化的例子
      3. 2.2.3. 小结论:什么时候用逆规范化做优化?
    3. 2.3. 3. 改进 Render Partial 的效能
    4. 2.4. 4. 数据库索引
      1. 2.4.1. 常见的效能错误
      2. 2.4.2. SQL explain 机制
    5. 2.5. 5. 后端缓存和延展性(Scalability)
      1. 2.5.1. 内存缓存
      2. 2.5.2. 网站延展性