课程复盘 | Rails 网站效能(2)
课程目标
- 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
里的这一行就可以了。
|
|
这样全站的 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中指定的版本来执行某个操作。
例如:
|
|
3.后端效能分析
后端效能要关注的是个别 HTTP Request 的反应时间,也就是 Response Time。
这个时间在 Rails log 中可以看到。
或是可以安装 rack-mini-profiler 这个 gem,就可以在画面上直接看到这个数据:
编辑 Gemile
|
|
执行 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:
发现到很多很类似的
而且根据 rack-mini-profiler 的数据,这一页总共发出了 26 个 SQL 查询,怎么会这么多?
关键在出在 app/views/posts/index.html.erb
|
|
这个循环中,每一次都需要读取 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 加上去即可:
|
|
在观察一次 Log,SQL 查询就只剩下两条了。一条捞 Posts,一条捞 Users。速度从 1173ms 提升到 117ms,快了十倍!
改进 posts#show 的 N+1
接下来点进任一篇文章,文章有许多留言,留言的作者也有一样的 N+1 问题,让我们处理一下:
|
|
includes 多个关联
includes 也可以一次捞多个关联的数据,首先让我们增加一个情境是 posts#index 页面显示每篇贴文的浏览用户,以及按讚的用户:
编辑 app/views/posts/index.html.erb
|
|
再次浏览 http://localhost:3000 看看 log,果然 N+1 又冒出来了,吓死人的多。
让我们加上 includes,修改 app/controllers/posts_controller.rb
:
|
|
其中 { :comments => :user }
这个 Hash 表示除了捞 comments 之外,还包括它的下一层 user 关联。
includes 有条件怎么办?
这个范例项目的 Comment model 有一个字段是 status 状态,表示这个留言可以是公开(public)或私密留言(private),因此在 posts index 页面上我们希望不要显示状态是私密的留言作者:
编辑 app/models/comment.rb
加上一个 scope:
|
|
接下来你可能会直接修改 app/views/posts/index.html.erb
套用这个 scope:
|
|
观察一下 rails log,很不幸的 N+1 又出现了,ActiveRecord 没这么聪明,它认为事先 includes 的 post.comments
跟这里的 post.comments.visible
是不一样的,所以发出了 N+1 Queries
。
|
|
map的用法。
我们需要在 Post model 增加一个有条件的关联,修改 app/models/post.rb
|
|
然后修改 app/controllers/posts_controller.rb
改用这个新的有条件的关联:
|
|
最后修改 app/views/posts/index.html.erb
|
|
这样就大功告成了,观察 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_paginate 或 kaminari来做分页功能。
find_each 技巧
如果真的需要捞出全部的数据做处理,就需要分次捞才不会一次把内存吃光。Rails 针对这个情境提供了批次方法。
一个常见的情境是修理数据,假设我们想要在 Post 上新增一个字段是 date,但是刚新增的字段没有数据,我们需要走访所有的 Post 贴文去补上这个数据:
执行 rails g migration add_date_to_posts
编辑 db/migrate/2017XXXXXXXXXX_add_date_to_posts.rb
|
|
执行 rake db:migrate
就会新增 date 字段,然后用 Post.find_each
走访所有贴文补上 date 数据,这个方法会每一千笔每一千笔去捞出 Posts 数据,而不是一次全部捞出来。
预加载(Preload)概念
留言有分公开(Public)和私密(Private)状态,让我们修改 Post show 页面来反应这个需求:改成显示全部公开的留言,以及我自己的私密留言。
修改 app/controllers/posts_controller.rb
|
|
修改 app/views/posts/show.html.erb
加上我们私密留言
|
|
看看 log 可以看到捞 comments 捞了两次,一次是捞公开留言,一次是捞我的留言。
不过,如果你仔细想想,这两个查询根本就可以一次就捞出来,修改 app/controllers/posts_controller.rb
|
|
all_comments 就是我们预先捞出来的 comments,利用了 SQL 条件
|
|
捞出所有公开或我的私密留言。然后 @comment 和 @my_comments 是用 select 这个数组方法,从内存中再分别过滤出公开留言和我的私密留言。
这就是预先加载(Preload)概念: 我们尽可能合并 SQL 查询一次捞出,然后再用数组方法 select 过滤出需要的结果。
再次看一下 log,只捞了一次 comments 了。
count 和 size 方法
count
和 size
方法都可以查询数量,这两个方法有什么差异吗?我们在 posts show 页面上显示一下留言的数量,请
编辑 app/views/posts/show.html.erb
,在最下方加入:
|
|
这里故意放两行来示范效果
然后看一下 log:
会看到有两条 COUNT 的 SQL。让我们改成 .size 方法看看:
|
|
会发现竟然没有 COUNT SQL 了。
- 调用 count 方法是对数据库送出一次 COUNT 的 SQL 查询
- 而size 是数组的方法,因为 @comments 这个对象已经在内存了,调用 size 是去计算这个 @comments里面元素的数量,因此不需要再发出 COUNT 的 SQL 查询。
在这个范例中,因为画面中已经显示了 @comments,表示这个数据已经从数据库中捞出,所以适合用 .size
方法,而不需要用 .count
重复再问一次数据库。
让我们换一个情境,在 posts index 页面上显示留言的数量,请修改 app/views/posts/index.html.erb
,加上留言数:
|
|
看一下 log,发现 N+1 又出来了。
这个情境下用 count 就不对了,因为 post.visible_comments
我们其实已经捞出来了,应该用 size 方法去算即可,不需要再问一次数据库:
|
|
再看一次 log,多的 SQL queries 现在都没了。
避免重复 SQL 查询
情境是我们想在 posts index 页面上显示我是否有按过讚:
修改 app/models/user.rb
,让我们新增一个方法判断 User 有没有针对一篇 Post 按过 Like:
|
|
修改 app/views/posts/index.html.erb
|
|
代码看起来很简单,一下就写好了,让我看看对效能有没有影响:
吓死人,怎么多出这么多多 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
|
|
再次看一下 log,发现多的 SQL 都不见了 👍👍👍
不过请放心:你是很难有先见之明知道要这样写的,因为不同页面会加载的数据不同,需要因地制宜的优化。
pluck 技巧
使用 ActiveRecord 从数据库中取出数据时,会形成 ActiveRecord 对象放进内存,而这个 ActiveRecord 类其实有点肥大,因为它本身包含很多操作方法等等。因此在只需要取出单纯数据,而不需要 ActiveRecord 任何功能的时候,可以用 pluck
方法,例如我们只想要取出所有用户的 email 数据:
和
两者的速度差了非常多:前者需要将所有用户捞出来变成 ActiveRecord 对象,然后再转成 email 的数组。后者直接就是 email 数组。