课程复盘 | Rails 网站效能(3)
课程目标
- 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/views/posts/report.html.erb
|
|
优化之后的结果:
解说:因为订阅数并不是 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
加上订阅数:
|
|
不意外的,这样写造成了很多 SQL 查询:
跟显示 visible_comments
留言数不同,订阅的数据并没有被预先加载,所以需要一笔一笔去 COUNT。要怎么改善呢?
如果你熟悉 SQL 的话,可以用 SQL 解决,编辑 app/controllers/posts_controller.rb
:
|
|
编辑 app/views/posts/index.html.erb
|
|
观察一下 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 上,表示这篇贴文有多少订阅数:
|
|
编辑 app/models/subscription.rb
,加上 counter_cache
,这会告诉 Rails 如果有新增或删除 Subscription 时,自动去更新 Post 的 subscriptions_count
数字:
|
|
执行 rake db:migrate
修改 app/views/posts/index.html.erb
,直接显示这个数字:
|
|
修改 app/controllers/posts_controller.rb
,不需要再计算订阅数了:
|
|
Rails 内建的 Counter Cache 功能比较简单,如果你需要更多功能,请参考 https://github.com/magnusvk/counter_culture 这个 gem。
再一个逆规范化的例子
需求情境:在 posts index 页面上,显示每篇贴文的最后订阅的时间
逆规范化解法:
- 在 posts table 上新增一个一个 last_subscribed_at 时间字段
- 在有人订阅时,例如 subscriptions controller 的 create action 中,去更新该篇 post 的 last_subscribed_at 值
- 在有人取消订阅时,例如 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/_post.html.erb
|
|
看一下 log,其中不断调用 Rendered posts/_post.html.erb
这个 partial 样板:
怎么改进呢?Rails 针对这种情况,有提供一个优化的写法,再次编辑 app/views/posts/index.html.erb
:
|
|
整个 @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
|
|
执行 rake db:migrate
常见的效能错误
一个常犯的错误是用 created_at
来进行排序,例如想要依照新建时间排序,让新的贴文在上面:
|
|
由于 created_at
这个字段我们并没有加上索引,如果你只是想要排序,应该改用 id 字串:
|
|
因为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)」。当网站的用户越来越多,流量越来越大的时候,需要想办法扩展网站的承载能力。
扩展的有两种方式:
- 垂直扩展:升级服务器,例如用更快的 CPU、用大的硬盘、用多的内存
- 水平扩展:增加(租用)更多服务器
垂直扩展在初期比较简单,因为网站代码不需要变更,只需要原地硬件升级即可。但是硬件升级是有上限的,越高等级的服务器越贵。CPU 再怎么快,总不可能我们去租一台超级电脑吧。
水平扩展则比较合乎成本,因为一百台平价的电脑,比起一台超级电脑还便宜。但是水平扩展会需要网站架构的运维能力,对技术的要求比较高。
常见的网站架构演进,请参考 5 Common Server Setups For Your Web Application 这篇文章的图例:
一开始只需要一台服务器
接下来将数据库独立成一台服务器
前面放一台 Load Balancer 服务器分散流量,这样增加更多台应用服务器(Application server,也就是我们的 Rails server)
前面再放 HTTP 缓存服务器
数据库也需要拆分,可以分成 Master 和 Slave 数据库,读写分离。
以上是还算是入门等级的架构,要继续延展的话,就是另一门深似海的学问了。网站的延展性,就是去研究如何在合理的硬件成本下,透过水平扩展持续增加系统容量。这件事情跟 Rails 技术就比较没有关系了。