ruby-gc

Ruby(MRI)服务 GC 时间上升、内存不回落,Puma worker 被重启或 OOMKilled。

现象

接口 p99 周期性抖动,日志或监控出现 GC 停顿;GC.stat[:time] 快速增长,major_gc_count 频繁增加,worker RSS 居高不下。Puma 可能因内存超限被重启,容器出现 oom_kill。

快速定位

先看进程和 cgroup 内存,再看 GC 统计:

ps -o pid,rss,etime,cmd -C ruby
grep -E 'VmRSS|VmSwap' /proc/<PID>/status
cat /sys/fs/cgroup/memory.current
cat /sys/fs/cgroup/memory.events

在应用内或 rails runner 中读取统计:

bundle exec rails runner 'p GC.stat.slice(:count, :major_gc_count, :minor_gc_count, :time, :heap_live_slots, :heap_free_slots, :heap_allocated_pages, :old_objects)'

对运行中的进程可用 rbtrace 读取:

gem install rbtrace
rbtrace -p <PID> -e 'GC.stat.each { |k, v| puts "#{k}=#{v}" }'

计算 GC 时间占比:间隔 60 秒取两次 GC.stat[:time],用增量除以 60000 ms;同时观察每分钟 major GC 次数。定位分配来源:

bundle exec ruby -r memory_profiler -e 'report = MemoryProfiler.report { run_workload }; report.pretty_print(to_file: "/tmp/mem.txt")'

易误判:RSS 不下降不等于 Ruby 对象泄漏 —— MRI 会保留堆页,释放的对象槽位会被复用但页面不一定还给 OS。应看 major GC 后的 heap_live_slots 是否持续增长,以及 RUBY_GC_HEAP_GROWTH_FACTOR、malloc arena 和 native 扩展的影响。

常见根因

  1. 类变量/常量/全局缓存无上限,长期持有 Hash、Array 或查询结果。
  2. 分配速率过高:循环里拼字符串、逐条实例化 ActiveRecord、解析超大 JSON。
  3. 老年代对象增长过快,major GC 频繁;增长因子与 old object 阈值不匹配。
  4. glibc malloc arena 碎片,多线程下 RSS 高且不归还;未设置 MALLOC_ARENA_MAX。
  5. Native 扩展泄漏(图像处理、XML 解析、数据库驱动)。
  6. Puma preload_app! 后 CoW 页面被写脏,每个 worker 的私有内存持续增长。
  7. 后台线程/连接池/job payload 长期持有对象。
  8. 测试或 gem 误开 GC.disable/GC.stress,导致回收行为异常。

修复

相关命令

读完自测

  1. 如何判断 GC 是否已经成为性能瓶颈?
  2. RSS 很高但 heap_live_slots 稳定,说明什么?
  3. 为什么开启 preload_app! 后 worker 内存仍会增长?
答案
  1. 取 GC.stat[:time] 增量计算占墙钟时间的比例,并关联 p99 抖动与 major GC 频率;占比高且与延迟相关才算瓶颈。
  2. 更可能是 allocator 碎片、CoW 私有页或 native 内存,而不是 Ruby 活对象泄漏。
  3. GC 和业务写入会弄脏 fork 后本应共享的页面,触发 copy-on-write,每个 worker 逐渐拥有私有内存。