Hegwin.Me

溯洄从之,道阻且长。溯游从之,宛在水中央。

Rails 应用的内存优化

Improve Memory for Your Rails App

我今天在郑州参加了为期两天的 Ruby Summit China 2018;Summit 在郑州“大玉米”举行。我没想到在郑州这样一个互联网公司密度不是那么高的城市,可以举办这样规模的互联网大会(Ruby Summit只是其中一个分会场)。更令人惊喜的是,Ruby语言之父松本行弘(Matz)也有来参加做开场演讲。于是乎,我专程从上海来到郑州参加这个Summit——也算是程序员界的追星行为了。我早早地坐在第一排的位置,成功蹭到和 Matz 的合影。

Summit 第一天下午,一位来自 薄荷网 的 Ruby 工程师分享了他在Ruby应用内存调优的经验。我觉得内存这个话题非常有趣,在听分享的时候也记了一些笔记。我打算把这个话题分为三个部分,由易到难,简要分析一下。

重启大法好

似乎总有一种印象,Rails app 一直都存在内存泄露的问题。随着Rails app的运行时间延长,它消耗的内存会越来越多。

一个粗暴的解决方式就是定期重启 Rails server worker,比如 unicorn 或者 puma 都有对应的 killer gem,可以在内存消耗到一定程度时kill worker。

但实际情况是,Ruby 和 Rails 都一直在进行着内存的优化。在Ruby Summit的Keynote: Ruby after 25 years中,松本行弘本人也提到了GC和Heap的问题,寻找不同的方法来优化Ruby的性能。如果我们的应用出现了内存问题,我们需要着手去解决这些问题,而不是单纯的使用worker killer。

那么,我们应该怎样判断问题出在哪里呢?

研究学习篇

在 Web App 运行的不同阶段,我们遇到的内存问题可能是不同的。简单来说,我们可以将其分为两个阶段,一是 App 的启动阶段,这时候加载各种依赖的gems,这些gems会初始化很多对象,二是App 的运行阶段,这时候会涉及到接受请求、查询和组织数据、返回结果等。

Booting Phase

在 App 的 boot 阶段,我们可以利用 derailed_benchmarks 来查看各个 gem 在初始化阶段对内存的占用。使用起来很简单:

# Gemfile
gem 'derailed_benchmarks', group: :development
gem 'stackprof', group: :development
# bash
$ bundle exec derailed bundle:mem

你可能会看到这样的结果:

TOP: 25.1094 MiB
  rails/all: 33.3594 MiB
    rails: 12.0938 MiB (Also required by: active_record/railtie, active_model/railtie, and 8 others)
      active_support: 5.6875 MiB (Also required by: active_support/railtie, active_support/i18n_railtie, and 14 others)
        active_support/logger: 4.3125 MiB
          active_support/logger_silence: 4.1875 MiB
            concurrent: 4.1094 MiB (Also required by: sprockets/manifest)

另外, Github上有人整理了一个 leaky gem list,也可以参考一下,替换掉存在内存泄露的 gems。

Running Phase

而在 App 运行阶段,我们可以使用另一个 gem oink 来查看到底是怎样的请求花费了太多内存,从而定位到可能出问题的地方。

oink虽然有好几年没有维护了,但它现在依然是可用的。它的工作方式是作为一个中间件(middleware)插入到 Rails App中,来监控内存使用情况。同时,它会把内存使用记录在日志里,它提供了一个CLI来汇总这些logs帮你找出消耗内存最多的 actions。

# Gemfile
gem "oink"

# config/application.rb
module YourRailsApp
  class Application < Rails::Application
    config.load_defaults 5.2
    config.middleware.use Oink::Middleware
  end
end

在程序运行一段时候后,执行他的 CLI 来查看统计结果:

$ oink --threshold=75 /tmp/logs/*

你可能会看到如下的结果:

---- MEMORY THRESHOLD ----
THRESHOLD: 75 MB

-- SUMMARY --
Worst Requests:
1. Feb 02 16:26:06, 157524 KB, SportsController#show
2. Feb 02 20:11:54, 134972 KB, DashboardsController#show
3. Feb 02 19:06:13, 131912 KB, DashboardsController#show

Worst Actions:
10, DashboardsController#show
9, GroupsController#show
5, PublicPagesController#show

Aggregated Totals:
Action                                  Max     Mean    Min     Total   Number of requests
SportsController#show                   101560  19754   4       5590540 283
CommentsController#create               8344    701     4       253324  361
ColorSchemesController#show             10124   739     4       68756   93

oink的最后一个release 是在2013年,虽然现在仍然可以使用,但是我们可能还是需要更积极的产品来代替他,比如各种 APM 产品。

在找到是哪个 action 出现内存问题后,我们可以使用 memory_profiler 来分析内存的使用情况。这是一个稳定更新的 gem,一直都在发布新的版本。

内存高手篇

在定位到问题代码之后,如果是我们自己手写的代码,修复起来多半很容易。但情况如果是业务就是有这种需求,而它涉及到框架甚至是系统设置时,我们不得不使用更高级的方法。

Tuning GC

Ruby 语言本身是有不少基于环境变量的内存设置的,在 Ruby 的 GC 文档里可以看到。但是奇妙的是,我只在日语版本的文档里看到了对这些变量的说明,而英文版本的文档里却没有逐个介绍。

举几个例子, Ruby 的 GC 有这些设置:

  • RUBY_GC_HEAP_INIT_SLOTS (默认: 10000) - 最初分配的 slots 数量。
  • RUBY_GC_HEAP_FREE_SLOTS (默认: 4096) - 如果GC之后没有足够的空闲slots,就会分配一个新的页面,空闲slots的数量就会增加。
  • RUBY_GC_HEAP_GROWTH_FACTOR (默认: 1.8) - 在Ruby中,这个系数用于每次分配slots时增加分配的内存大小。这意味着slots的总数会以指数形式增加。 这是一种机制,以确保快速达到运行中的Ruby程序所需的槽的数量。

至于如何优化GC参数,需要结合运行的系统进行配置。在这方面,甚至出现了一个 SaaS 服务 TuneMyGC 来帮助人们实现 Rails GC 优化。

Memory Fragment

另一方面,Rails App 在运行时的内存增长,真的都是内存泄漏(Memory Leak)吗?有没有可能是内存膨胀(Memory Bloat)?

这里面就是涉及到一个问题:内存碎片(Memory Fragment)。

Ruby 的 VM 本质是一个双堆栈机,我们可能会遇到这样的问题。

Memory-Fragment.jpg

在分配的 8 个 slots 中,我们释放掉了Slot 1, 2, 和 6。现在我们需要分配一片 block 占用 4 个 slots。在上面的情况中,我们是办不到的,尽管我们有 4个 slots 的空闲空间。我们不得不去分配新的 slots,而原有的 heap page 可能最终也无法有效释放。这就是内存碎片的问题。

目前有个比较简单的解决办法,那就是使用 jemalloc

Rails 常用的一个后台任务gem sidekiq的作者,在他的一篇文章 Taming Rails memory bloat 中提到,使用了 jemalloc 代替 malloc 之后,sidekiq的内存占用有非常显著的提升。

The results have been described as “miraculous”. That’s 40GB worth of Sidekiq processes shrunk to 9GB, a 4x reduction.

jemalloc 的使用也比较简单,我们首先需要安装它:

# Mac
brew install jemalloc

# Linux
apt-get install -y libjemalloc-dev

在编译Ruby时加上 --with-jemalloc (RVM可以直接在 rvm install 2.x.x 后面加上 --with-jemalloc,rbenv则要指定环境变量 RUBY_CONFIGURE_OPTS='--with-jemalloc' rbenv install 2.x.x );之后在运行 Ruby 时,我们只要这样:

$ MALLOC_CONF=stats_print:true ruby -e "puts RbConfig::CONFIG['MAINLIBS']"

以上就是我的比较浅显的笔记了,覆盖到了一些我所了解的情况。如果需要深入学习,则需要对每个小的议题深入学习了。

< Back