Rails 记一次 Puma 配置导致的性能问题

lanzhiheng · 2023年04月03日 · 最后由 jicheng1014 回复于 2023年04月10日 · 911 次阅读

以往的性能问题大多都出现在数据库查询中,着实没想到这次会是因为 Puma 的配置不当。原文链接:https://step-by-step.tech/posts/performance-issue-for-puma-configuration

最近这一两周其实打击挺大的,因为这段日子笔者每天都花大量时间在优化回流的服务端系统。精力都集中在减少 N+1 查询上,几乎把常用接口的 N+1 问题都解决掉了,慢查询也优化到所剩无几。然而,系统的性能还是不稳定,每到峰值时期,响应时间犹如脱缰的野马径直往上飙。

每次优化掉一些耗时较长的请求,都会信誓旦旦地跟同事说“今天应该不卡了”,然而每次到了高峰期,总会啪啪打脸,有大量响应时间 400ms 以上的请求不断冒出来,按都按不住,整个系统几乎处于不可用的状态。老实说以前总觉得响应时间 500-600ms 没什么的,但是最近看到大量请求的响应时间超过 300ms 的时候,血压也会跟着升高。

Screenshot 2023-04-03 at 07.49.17.png

优化进入到了死胡同,笔者也很纳闷,数据库慢查询几乎没多少了,CPU 的使用率也不是很高,内存也一直在 5 ~ 8G 之间徘徊,按理说我这 8 核 16G 的机器资源还有很大的盈余,升级机器也解决不了什么问题,实在是不知道性能瓶颈在哪。

一筹莫展之际,笔者突发奇想,不知道是不是 Puma 的 Worker 不够,CPU 利用率不够高导致的?看了一下现在的配置是

threads 0,32
workers 4

而且就在puma.rb这个配置文件里面,笔者还发现了这样的代码片段。

before_fork do
  require 'puma_worker_killer'
  PumaWorkerKiller.config do |config|
    config.ram           = 5120 # mb
    config.frequency     = 5    # seconds
    config.percent_usage = 0.98
    config.rolling_restart_frequency = 6 * 3600 # 12 hours in seconds, or 12.hours if using Rails
    config.pre_term = -> (worker) { puts "Worker #{worker.inspect} being killed" }
    config.rolling_pre_term = -> (worker) { puts "Worker #{worker.inspect} being killed by rolling restart" }
  end
  PumaWorkerKiller.start
end

看到这段话的时候

before_fork do
  ...
  config.ram           = 5120 # mb
end

真的是茅舍顿开,并不是回流服务多省内存,有这个配置在难怪内存使用一直在 5G ~ 8G 这个区间徘徊。因为回流前期比较穷,笔者都在尽可能节约服务器成本,Ruby 又是出了名吃内存的主,所以引入了PumaWorkerKiller来强行让 puma 的内存使用限制在 5G 左右的水平(当时机器总内存是 8G),适当的时候 kill 掉一些线程来释放内存。

在这些配置的干扰下,客户量上来了,CPU 跟内存却还一直停留在较低水平。更要命的是配置不在代码库中,往往很难察觉到它们的存在。

马上把内存限制扩大到 10G

before_fork do
  ....
  config.ram           = 10240 # mb
  ....
end

Worker 数量也调整大一点,跟 CPU 的核数对应上

threads 0,32
workers 8

经历过两周优化的挫折,笔者已经不再对自己的优化手段报有什么期望了,每次都是期望越大失望越大。然而万万没想到,这次优化效果立竿见影,原来 500ms 的指标一下子就跌到了正常水平了。

Screenshot 2023-04-03 at 07.51.51.png

我是想过会有改善,就是没想到改善这么明显....当初为了节省费用而编写的配置代码,如今却成了性能瓶颈。后来笔者有把 Worker 数量调整回 4 个,发现单单提升了内存的限制,系统性能也会有所提升,但不够明显,还是要上管齐下才行,看来 worker 数量跟 CPU 的核数一致会是比较合适的选择。

Puma的维护者也建议我们要自己多尝试

Feel free to experiment, but be careful not to set the number of maximum threads to a large number, as you may exhaust resources on the system (or cause contention for the Global VM Lock, when using MRI).

我喜欢这种顶着限速跑的感觉:

15491680245269_.pic.jpg

人是容易先入为主的动物,特别是在一个地方呆久了,往往会“如入鲍鱼之肆,久而不闻其臭”。以往的性能问题大多都出现在数据库查询中,笔者也下意识地花了很多精力去做数据库调优,却是万万没想到,这次的性能瓶颈出现在平时关注最少的 CPU 跟内存上。

毕竟从指标上看 CPU 跟内存有很大的盈余,却没想到这表面的“盈余”是人为限制的结果。无论是对待 Bug 还是系统性能瓶颈,适当跳出来以局外人的身份审视说不定会有意想不到的效果。这次误打误撞把性能问题解决掉了,也算是运气好吧。

请教一下 puma 的 threads 和 worker 一般是怎么配置的

哈哈哈,之前在使用 Puma Worker Killer 这个 gem 的时候就非常谨慎,除非能确定是内存问题导致系统极慢/崩溃且又找不到内存泄露原因的时候,才会上这个 gem. Readme 里非常谦虚的提了句

This gem can also make your performance WORSE. When a worker is killed, and comes back it takes CPU cycles and time. If you are frequently restarting your workers then you're killing your performance

我很好奇,如果去掉这个 gem, 就让系统内存正常发挥,服务器的性能是怎样的呢?

关于 puma 一些简单的结论:

  1. 线程数配置到 5 个
  2. 进程数配置在 cpu 核数

就是最优配置,性能最重要的是验证接口 QPS 能力。

lyfi2003 回复

@rookie 可以参考这里的配置哈。

spike76 回复

😬 其实上了会好点,我之前就是经常爆内存才上了的。其实重启一下就解决了,上这个就是有个人帮我们定时“重启”一下。

我觉得可能你的这个配置跟我的理解有点不一样

killer 的 ram 的限制,是基于 worker 的,注意,是单数,也就是说,最多的情况下,每个进程消耗的内存

目前的配置是 每个进程消耗最多 10G 时启动。这样的话其实本质上来说无法在内存消耗大的时候 kill

目前你感受到的快,感觉应该是 killer 不工作了,所以不会有重启的延时了

问题其实是出在 thread(这里的 thread 实际是线程) , 0,32 意味着 worker 维护的线程池从 0 到 32 个,闲时关闭到 0, 最多开启到 32,

如果是这个配置的话,会有大量的 开启,关闭 线程,每次开启线程的时候,都要分配一些基础内存,这个过程很慢,关闭线程的时候又有锁,对 worker 是全剧锁的,会慢,所以这个开启关闭线程本身是比较吃资源的,性能会下降

建议像亚飞兄说的,少开一些线程,另外我的建议是尽量不要最小值为 0,

我的 api 项目 也是 8c16G, thread 开的是 5..10 效果还行,不知道适合不适合你

另外老哥,余量建议 30% 以上,咱一次当机的损失,早就超服务器损失了..... 当然如果做了弹性扩容,就没太大问题

jicheng1014 回复

之前有调整过

worker 8
threads 16, 16 

内存一直都是 10G 左右徘徊,kill 进程的日志也是有。相信这个时候 killer 肯定是工作的。

性能还是比调整之前好太多了。后面不确定 thread 应该用多少才调整回

worker 8
threads 0, 32

高峰期基本上内存也是满的。

我明天用你跟亚飞的配置线程调整到 5 左右试试看。只是现在这样了,thread 的调整估计提升感觉也不太明显。

jicheng1014 回复

后面准备多开个机器做高可用,前面加负载均衡了。现在平均下来余量还在 30% 以上。

lanzhiheng 回复

嗯勒,自从上了 ECS 动态扩容,就没有用过这个了

spike76 回复

ECS 动态扩容吗?我回头也试试。哈哈

一般来说 puma 的线程数固定在 5 就够了。进程数可以根据实际情况设置,不一定总是等于 CPU 的超线程数,也有可能需要小于,进程数大多数情况下甚至可以设置到 1.25x ~ 1.5x 的超线程数。

It’s frequently said that you shouldn’t have more child processes per server than CPUs. This is only partly true. It’s a good starting point, but actual CPU usage is the metric you should watch and optimize. In practice, most applications will probably settle at a process count that is 1.25-1.5x the number of available hyperthreads.

On Heroku, use log-runtime-metrics to get a CPU load metric written to your logs. I would look at the 5 and 15 minute load averages - if they are consistently close to or higher than 1, you are maxing out CPU and need to reduce child process counts.

关于 puma 的配置可以参考这篇文章: https://www.speedshop.co/2017/10/12/appserver.html。内容大概就是介绍了 puma 线程数和进程数的配置,还有如何开启 puma 的 cow 以节省进程内存占用,以及容器大小相关的配置。文章作者也是 puma 的维护者

图 1 的 dashboard 是什么软件?

nine 回复

newrelic

需要 登录 后方可回复, 如果你还没有账号请 注册新账号