对优化Ruby on Rails性能的一些办法的探究

时间:2022-02-27 19:50:00

1.导致你的 Rails 应用变慢无非以下两个原因:

  1. 在不应该将 Ruby and Rails 作为首选的地方使用 Ruby and Rails。(用 Ruby and Rails 做了不擅长做的工作)
  2. 过度的消耗内存导致需要利用大量的时间进行垃圾回收。

Rails 是个令人愉快的框架,而且 Ruby 也是一个简洁而优雅的语言。但是如果它被滥用,那会相当的影响性能。有很多工作并不适合用 Ruby and Rails,你最好使用其它的工具,比如,数据库在大数据处理上优势明显,R 语言特别适合做统计学相关的工作。

内存问题是导致诸多 Ruby 应用变慢的首要原因。Rails 性能优化的 80-20 法则是这样的:80% 的提速是源自于对内存的优化,剩下的 20% 属于其它因素。为什么内存消耗如此重要呢?因为你分配的内存越多,Ruby GC(Ruby 的垃圾回收机制)需要做的工作也就越多。Rails 就已经占用了很大的内存了,而且平均每个应用刚刚启动后都要占用将近 100M 的内存。如果你不注意内存的控制,你的程序内存增长超过 1G 是很有可能的。需要回收这么多的内存,难怪程序执行的大部分时间都被 GC 占用了。

2 我们如何使一个 Rails 应用运行更快?

有三种方法可以让你的应用更快:扩容、缓存和代码优化。

扩容在如今很容易实现。Heroku 基本上就是为你做这个的,而 Hirefire 则让这一过程更加的自动化。其它的托管环境提供了类似的解决方案。总之,可以的话你用它就是了。但是请牢记扩容并不是一颗改善性能的银弹。如果你的应用只需在 5 分钟内响应一个请求,扩容就没有什么用。还有就是用 Heroku + Hirefire 几乎很容易导致你的银行账户透支。我已经见识过 Hirefire 把我一个应用的扩容至 36 个实体,让我为此支付了 $3100。我立马就手动吧实例减容到了 2 个, 并且对代码进行了优化.

Rails 缓存也很容易实施。Rails 4 中的块缓存非常不错。Rails 文档 是有关缓存知识的优秀资料。不过同扩容相比,缓存并不能成为性能问题的终极解决方案。如果你的代码无法理想的运行,那么你将发现自己会把越来越多的资源耗费在缓存上,直到缓存再也不能带来速度的提升。

让你的 Rails 应用更快的唯一可靠的方式就是代码优化。在 Rails 的场景中这就是内存优化。而理所当然的是,如果你接受了我的建议,并且避免把 Rails 用于它的设计能力范围之外,你就会有更少的代码要优化。

2.1 避免内存密集型Rails特性

Rails 一些特性花费很多内存导致额外的垃圾收集。列表如下。

2.1.1 序列化程序

序列化程序是从数据库读取的字符串表现为 Ruby 数据类型的实用方法。

?
1
2
3
4
5
class Smth < ActiveRecord::Base
 serialize :data, JSON
end
Smth.find(...).data
Smth.find(...).data = { ... }

它要消耗更多的内存去有效的序列化,你自己看:

?
1
2
3
4
5
6
7
8
class Smth < ActiveRecord::Base
 def data
 JSON.parse(read_attribute(:data))
 end
 def data=(value)
 write_attribute(:data, value.to_json)
 end
end

这将只要 2 倍的内存开销。有些人,包括我自己,看到 Rails 的 JSON 序列化程序内存泄漏,大约每个请求 10% 的数据量。我不明白这背后的原因。我也不知道是否有一个可复制的情况。如果你有经验,或者知道怎么减少内存,请告诉我。

2.1.2 活动记录

很容易与 ActiveRecord 操纵数据。但是 ActiveRecord 本质是包装了你的数据。如果你有 1g 的表数据,ActiveRecord 表示将要花费 2g,在某些情况下更多。是的,90% 的情况,你获得了额外的便利。但是有的时候你并不需要,比如,批量更新可以减少 ActiveRecord 开销。下面的代码,即不会实例化任何模型,也不会运行验证和回调。

Book.where('title LIKE ?', '%Rails%').update_all(author: 'David')
后面的场景它只是执行 SQL 更新语句。

?
1
2
3
4
5
6
7
8
update books
 set author = 'David'
 where title LIKE '%Rails%'
Another example is iteration over a large dataset. Sometimes you need only the data. No typecasting, no updates. This snippet just runs the query and avoids ActiveRecord altogether:
result = ActiveRecord::Base.execute 'select * from books'
result.each do |row|
 # do something with row.values_at('col1', 'col2')
end

2.1.3 字符串回调

Rails 回调像之前/之后的保存,之前/之后的动作,以及大量的使用。但是你写的这种方式可能影响你的性能。这里有 3 种方式你可以写,比如:在保存之前回调:

?
1
2
3
4
5
before_save :update_status
before_save do |model|
model.update_status
end
before_save “self.update_status”

前两种方式能够很好的运行,但是第三种不可以。为什么呢?因为执行 Rails 回调需要存储执行上下文(变量,常量,全局实例等等)就是在回调的时候。如果你的应用很大,你最终在内存里复制了大量的数据。因为回调在任何时候都可以执行,内存在你程序结束之前不可以回收。

有象征,回调在每个请求为我节省了 0.6 秒。

2.2 写更少的 Ruby

这是我最喜欢的一步。我的大学计算机科学类教授喜欢说,最好的代码是不存在的。有时候做好手头的任务需要其它的工具。最常用的是数据库。为什么呢?因为 Ruby 不善于处理大数据集。非常非常的糟糕。记住,Ruby 占用非常大的内存。所以举个例子,处理 1G 的数据你可能需要 3G 的或者更多的内存。它将要花费几十秒的时间去垃圾回收这 3G。好的数据库可以一秒处理这些数据。让我来举一些例子。

2.2.1 属性预加载

有时候反规范化模型的属性从另外一个数据库获取。比如,想象我们正在构建一个 TODO 列表,包括任务。每个任务可以有一个或者几个标签标记。规范化数据模型是这样的:

  • Tasks
  •  id
  •  name
  • Tags
  •  id
  •  name
  • Tasks_Tags
  •  tag_id
  •  task_id

加载任务以及它们的 Rails 标签,你会这样做:

这段代码有问题,它为每个标签创建了对象,花费很多内存。可选择的解决方案,将标签在数据库预加载。

?
1
2
3
4
5
6
7
8
tasks = Task.select <<-END
  *,
  array(
  select tags.name from tags inner join tasks_tags on (tags.id = tasks_tags.tag_id)
  where tasks_tags.task_id=tasks.id
  ) as tag_names
 END
 > 0.018 sec

这只需要内存存储额外一列,有一个数组标签。难怪快 3 倍。

2.2.2 数据集合

我所说的数据集合任何代码去总结或者分析数据。这些操作可以简单的总结,或者一些更复杂的。以小组排名为例。假设我们有一个员工,部门,工资的数据集,我们要计算员工的工资在一个部门的排名。

?
1
SELECT * FROM empsalary;
?
1
2
3
4
5
6
7
8
9
depname | empno | salary
-----------+-------+-------
 develop |  6 | 6000
 develop |  7 | 4500
 develop |  5 | 4200
 personnel |  2 | 3900
 personnel |  4 | 3500
 sales  |  1 | 5000
 sales  |  3 | 4800

你可以用 Ruby 计算排名:

?
1
2
3
4
5
6
7
8
9
10
salaries = Empsalary.all
salaries.sort_by! { |s| [s.depname, s.salary] }
key, counter = nil, nil
salaries.each do |s|
 if s.depname != key
 key, counter = s.depname, 0
 end
 counter += 1
 s.rank = counter
end

Empsalary 表里 100K 的数据程序在 4.02 秒内完成。替代 Postgres 查询,使用 window 函数做同样的工作在 1.1 秒内超过 4 倍。

?
1
2
3
SELECT depname, empno, salary, rank()
OVER (PARTITION BY depname ORDER BY salary DESC)
FROM empsalary;
?
1
2
3
4
5
6
7
8
9
depname | empno | salary | rank
-----------+-------+--------+------
 develop |  6 | 6000 | 1
 develop |  7 | 4500 | 2
 develop |  5 | 4200 | 3
 personnel |  2 | 3900 | 1
 personnel |  4 | 3500 | 2
 sales  |  1 | 5000 | 1
 sales  |  3 | 4800 | 2

4 倍加速已经令人印象深刻,有时候你得到更多,到 20 倍。从我自己经验举个例子。我有一个三维 OLAP 多维数据集与 600k 数据行。我的程序做了切片和聚合。在 Ruby 中,它花费了 1G 的内存大约 90 秒完成。等价的 SQL 查询在 5 内完成。

2.3 优化 Unicorn

如果你正在使用Unicorn,那么以下的优化技巧将会适用。Unicorn 是 Rails 框架中最快的 web 服务器。但是你仍然可以让它更运行得快一点。

2.3.1 预载入 App 应用

Unicorn 可以在创建新的 worker 进程前,预载入 Rails 应用。这样有两个好处。第一,主线程可以通过写入时复制的友好GC机制(Ruby 2.0以上),共享内存的数据。操作系统会透明的复制这些数据,以防被worker修改。第二,预载入减少了worker进程启动的时间。Rails worker进程重启是很常见的(稍后将进一步阐述),所以worker重启的速度越快,我们就可以得到更好的性能。

若需要开启应用的预载入,只需要在unicorn的配置文件中添加一行:

preload_app true
2.3.2 在 Request 请求间的 GC

请谨记,GC 的处理时间最大会占到应用时间的50%。这个还不是唯一的问题。GC 通常是不可预知的,并且会在你不想它运行的时候触发运行。那么,你该怎么处理?

首先我们会想到,如果完全禁用 GC 会怎么样?这个似乎是个很糟糕的想法。你的应用很可能很快就占满 1G 的内存,而你还未能及时发现。如果你服务器还同时运行着几个 worker,那么你的应用将很快会出现内存不足,即使你的应用是在自托管的服务器。更不用说只有 512M 内存限制的 Heroku。

其实我们有更好的办法。那么如果我们无法回避GC,我们可以尝试让GC运行的时间点尽量的确定,并且在闲时运行。例如,在两个request之间,运行GC。这个很容易通过配置Unicorn实现。

对于Ruby 2.1以前的版本,有一个unicorn模块叫做OobGC:

?
1
2
require 'unicorn/oob_gc'
 use(Unicorn::OobGC, 1) # "1" 表示"强制GC在1个request后运行"

对于Ruby 2.1及以后的版本,最好使用gctools(https://github.com/tmm1/gctools):

?
1
2
require 'gctools/oobgc'
use(GC::OOB::UnicornMiddleware)

但在request之间运行GC也有一些注意事项。最重要的是,这种优化技术是可感知的。也就是说,用户会明显感觉到性能的提升。但是服务器需要做更多的工作。不同于在需要时才运行GC,这种技术需要服务器频繁的运行GC. 所以,你要确定你的服务器有足够的资源来运行GC,并且在其他worker正在运行GC的过程中,有足够的worker来处理用户的请求。

2.4 有限的增长

我已经给你展示了一些应用会占用1G内存的例子。如果你的内存是足够的,那么占用这么一大块内存并不是个大问题。但是Ruby可能不会把这块内存返还给操作系统。接下来让我来阐述一下为什么。

Ruby通过两个堆来分配内存。所有Ruby的对象在存储在Ruby自己的堆当中。每个对象占用40字节(64位操作系统中)。当对象需要更多内存的时候,它就会在操作系统的堆中分配内存。当对象被垃圾回收并释放后,被占用的操作系统中的堆的内存将会返还给操作系统,但是Ruby自有的堆当中占用的内存只会简单的标记为free可用,并不会返还给操作系统。

这意味着,Ruby的堆只会增加不会减少。想象一下,如果你从数据库读取了1百万行记录,每行10个列。那么你需要至少分配1千万个对象来存储这些数据。通常Ruby worker在启动后占用100M内存。为了适应这么多数据,worker需要额外增加400M的内存(1千万个对象,每个对象占用40个字节)。即使这些对象最后被收回,这个worker仍然使用着500M的内存。

这里需要声明, Ruby GC可以减少这个堆的大小。但是我在实战中还没发现有这个功能。因为在生产环境中,触发堆减少的条件很少会出现。

如果你的worker只能增长,最明显的解决办法就是每当它的内存占用太多的时候,就重启该worker。某些托管的服务会这么做,例如Heroku。让我们来看看其他方法来实现这个功能。

2.4.1 内部内存控制

Trust in God, but lock your car 相信上帝,但别忘了锁车。(寓意:大部分外国人都有宗教信仰,相信上帝是万能的,但是日常生活中,谁能指望上帝能帮助自己呢。信仰是信仰,但是有困难的时候 还是要靠自己。)。有两个途径可以让你的应用实现自我内存限制。我管他们做,Kind(友好)和hard(强制).

Kind 友好内存限制是在每个请求后强制内存大小。如果worker占用的内存过大,那么该worker就会结束,并且unicorn会创建一个新的worker。这就是为什么我管它做“kind”。它不会导致你的应用中断。

获取进程的内存大小,使用 RSS 度量在 Linux 和 MacOS 或者 OS gem 在 windows 上。我来展示下在 Unicorn 配置文件里怎么实现这个限制:

?
1
2
3
4
5
6
7
8
9
10
class Unicorn::HttpServer
 KIND_MEMORY_LIMIT_RSS = 150 #MB
 alias process_client_orig process_client
 undef_method :process_client
 def process_client(client)
 process_client_orig(client)
 rss = `ps -o rss= -p #{Process.pid}`.chomp.to_i / 1024
 exit if rss > KIND_MEMORY_LIMIT_RSS
 end
end

硬盘内存限制是通过询问操作系统去杀你的工作进程,如果它增长很多。在 Unix 上你可以叫 setrlimit 去设置 RSSx 限制。据我所知,这种只在 Linux 上有效。MacOS 实现被打破了。我会感激任何新的信息。

这个片段来自 Unicorn 硬盘限制的配置文件:

?
1
2
3
4
5
6
7
8
9
after_fork do |server, worker|
 worker.set_memory_limits
end
class Unicorn::Worker
 HARD_MEMORY_LIMIT_RSS = 600 #MB
 def set_memory_limits
 Process.setrlimit(Process::RLIMIT_AS, HARD_MEMORY_LIMIT * 1024 * 1024)
 end
end

2.4.2 外部内存控制

自动控制没有从偶尔的 OMM(内存不足)拯救你。通常你应该设置一些外部工具。在 Heroku 上,没有必要因为它们有自己的监控。但是如果你是自托管,使用 monit,god 是一个很好的主意,或者其它的监视解决方案。

2.5 优化 Ruby GC

在某些情况下,你可以调整 Ruby GC 来改善其性能。我想说,这些 GC 调优变得越来越不重要,Ruby 2.1 的默认设置,后来已经对大多数人有利。

我的建议是最好不要改变 GC 的设置,除非你明确知道你想要做什么,而且有足够的理论知识知道如何提高性能。对于使用 Ruby 2.1 或之后的版本的用户,这点尤为重要。

我知道只有一种场合 GC 优化确实能带来性能的提升。那就是,当你要一次过载入大量的数据。你可以通过改变如下的环境变量来达到减少GC运行的频率:RUBY_GC_HEAP_GROWTH_FACTOR,RUBY_GC_MALLOC_LIMIT,RUBY_GC_MALLOC_LIMIT_MAX,RUBY_GC_OLDMALLOC_LIMIT,和 RUBY_GC_OLDMALLOC_LIMIT。

请注意,这些变量只适用于 Ruby 2.1 及之后的版本。对于 2.1 之前的版本,可能缺少某一个变量,或者变量不是使用这个名字。

RUBY_GC_HEAP_GROWTH_FACTOR 默认值 1.8,它用于当 Ruby 的堆没有足够的空间来分配内存的时候,每次应该增加多少。当你需要使用大量的对象的时候,你希望堆的内存空间增长的快一点。在这种场合,你需要增加该因子的大小。

内存限制是用于定义当你需要向操作系统的堆申请空间的时候,GC 被触发的频率。Ruby 2.1 及之后的版本,默认的限额为:

?
1
2
3
4
New generation malloc limit RUBY_GC_MALLOC_LIMIT 16M
Maximum new generation malloc limit RUBY_GC_MALLOC_LIMIT_MAX 32M
Old generation malloc limit RUBY_GC_OLDMALLOC_LIMIT 16M
Maximum old generation malloc limit RUBY_GC_OLDMALLOC_LIMIT_MAX 128M

让我简要的说明一下这些值的意义。通过设置以上的值,每次新对象分配 16M 到 32M 之间,并且旧对象每占用 16M 到 128M 之间的时候 (“旧对象” 的意思是,该对象至少被垃圾回收调用过一次), Ruby 将运行 GC。Ruby 会根据你的内存模式,动态的调整当前的限额值。

所以,当你只有少数几个对象,却占用了大量的内存(例如读取一个很大的文件到字符串对象中),你可以增加该限额,以减少 GC 被触发的频率。请记住,要同时增加 4 个限额值,而且最好是该默认值的倍数。

我的建议是可能和其他人的建议不一样。对我可能合适,但对于你却未必。这些文章将介绍,哪些对 Twitter 适用,而哪些对 Discourse 适用。

2.6 Profile

有时候,这些建议未必就是通用。你需要弄清楚你的问题。这时候,你就要使用 profiler。Ruby-Prof 是每个 Ruby 用户都会使用的工具。

想知道更多关于 profiling 的知识, 请阅读 Chris Heald's 和我的关于在 Rails 中 使用ruby-prof 的文章。还有一些也许有点过时的关于 memory profiling 的建议.

2.7 编写性能测试用例

最后,提高 Rails 性能的技巧中,虽然不是最重要的,就是确认应用的性能不会因你修改了代码而导致性能再次下降。

3 总结感言

对于一篇文章中,对于如何提高 Ruby 和 Rails 的性能,要面面俱到,确实不可能。所以,在这之后,我会通过写一本书来总结我的经验。如果你觉得我的建议有用,请登记 mailinglist ,当我准备好了该书的预览版之后,将会第一时间通知你。现在,让我们一起来动手,让 Rails 应用跑得更快一些吧!