N+1 Query And Performance Optimizations | Ruby On Rails For Beginners Part 9
Warum Ruby-Apps langsam werden
Die meisten Performance-Probleme in Ruby-Anwendungen entstehen nicht durch die Sprache selbst, sondern durch vorhersehbare Muster: unkontrollierte Datenbankabfragen, Speicherverschwendung und fehlende Messungen. Wir haben bei USEO über die letzten Jahre dutzende Rails-Anwendungen auditiert und sehen dieselben sieben Probleme immer wieder.
Dieser Artikel zeigt die Probleme mit konkreten Code-Beispielen, Metriken und den Tools, die wir in der Praxis einsetzen.
1. N+1 Queries: der häufigste Performance-Killer
N+1 ist das Problem, das wir in praktisch jeder Rails-App finden, die noch kein Performance-Audit hatte. Ein typisches Beispiel:
# Schlecht: 1 Query für Posts + N Queries für Authors
Post.all.each do |post|
puts post.author.name
end
# Gut: 2 Queries total
Post.includes(:author).each do |post|
puts post.author.name
end
Das klingt trivial, aber in der Praxis verstecken sich N+1-Probleme in Partials, Serializers und Service-Objekten, wo sie nicht sofort auffallen.
USEO’s Take
Bei einem SaaS-Kunden mit ca. 50’000 aktiven Nutzern haben wir eine Dashboard-Seite analysiert, die 8.2 Sekunden Ladezeit hatte. rack-mini-profiler zeigte 847 SQL-Queries pro Request. Die Ursache: verschachtelte N+1-Probleme über drei Ebenen (Order -> LineItems -> Product -> Category).
Nach dem Einsatz von bullet im Development und gezieltem Eager Loading:
- Vorher: 847 Queries, 8.2s Response Time
- Nachher: 12 Queries, 380ms Response Time
Das bullet-Gem gehört in jede Rails-App als Development-Dependency:
# Gemfile
group :development do
gem 'bullet'
end
# config/environments/development.rb
config.after_initialize do
Bullet.enable = true
Bullet.rails_logger = true
Bullet.add_footer = true
end
Ergänzend dazu: Spalten, die regelmässig in WHERE- oder ORDER BY-Klauseln auftauchen, brauchen einen Index. EXPLAIN ANALYZE in PostgreSQL zeigt sofort, ob ein Sequential Scan stattfindet, der durch einen Index ersetzt werden sollte.
2. Unkontrollierte Objektallokation
Ruby erzeugt bei jeder String-Konkatenation, jedem map-Aufruf und jedem Hash-Literal neue Objekte. In Hot Paths summiert sich das.
# Schlecht: erzeugt bei jedem Aufruf einen neuen String
def cache_key
"user_#{id}_profile"
end
# Besser: Frozen String, wird nicht dupliziert
def cache_key
"user_#{id}_profile".freeze
end
# Am besten: Magic Comment am Anfang der Datei
# frozen_string_literal: true
Für die Analyse der Objektallokation nutzen wir memory_profiler:
require 'memory_profiler'
report = MemoryProfiler.report do
# Code, der untersucht werden soll
1000.times { User.find_by(email: "test@example.com") }
end
report.pretty_print(to_file: 'memory_report.txt')
USEO’s Take
Bei einer E-Commerce-App haben wir mit derailed_benchmarks den Memory-Footprint pro Gem gemessen. Das Ergebnis war aufschlussreich:
$ bundle exec derailed bundle:mem
TOP: 54.3 MB
activesupport: 12.1 MB
rails/all: 8.4 MB
nokogiri: 6.2 MB
image_processing: 5.8 MB (nur im Admin gebraucht!)
...
image_processing wurde nur im Admin-Bereich verwendet, aber global geladen. Durch Lazy Loading des Gems sparten wir 5.8 MB RAM pro Worker-Prozess. Bei 16 Puma-Workern sind das 92 MB weniger.
3. Schlechtes Caching kostet doppelt
Caching ist kein Schalter, den man einmal umlegt. Falsch konfiguriertes Caching erzeugt Stale Data, Cache Stampedes oder verschwendet Speicher.
# Fragment Caching mit Versionierung
<% cache [post, post.updated_at] do %>
<%= render post %>
<% end %>
# Low-Level Cache mit Expiry
Rails.cache.fetch("user_#{id}_dashboard", expires_in: 15.minutes) do
expensive_dashboard_calculation
end
Wichtig bei Redis als Cache-Backend:
# config/environments/production.rb
config.cache_store = :redis_cache_store, {
url: ENV['REDIS_URL'],
expires_in: 1.hour,
size: 5.megabytes,
race_condition_ttl: 10.seconds # verhindert Cache Stampedes
}
Die Option race_condition_ttl ist entscheidend: Wenn ein Cache-Eintrag abläuft und gleichzeitig 50 Requests denselben Wert neu berechnen wollen, hält diese Einstellung den alten Wert für 10 Sekunden, während ein einziger Request den Cache neu befüllt.
4. Langsame Iterationen in Hot Paths
# Schlecht: erzeugt zwei Arrays
users.select { |u| u.active? }.map { |u| u.email }
# Besser: ein Durchlauf, ein Array
users.filter_map { |u| u.email if u.active? }
# Bei grossen Datensätzen: Batches statt alles in den RAM laden
User.where(active: true).find_each(batch_size: 500) do |user|
UserMailer.weekly_digest(user).deliver_later
end
find_each und find_in_batches sind Pflicht, sobald man über mehr als ein paar hundert Records iteriert. Ohne Batching lädt ActiveRecord die gesamte Ergebnismenge in den Speicher.
USEO’s Take
Ein Hintergrund-Job bei einem Kunden verarbeitete CSV-Imports mit 200’000 Zeilen. Der Worker brauchte 4 GB RAM und wurde regelmässig vom OOM-Killer beendet. Die Lösung:
# Vorher: alles auf einmal einlesen
CSV.read("import.csv").each do |row|
Product.create!(row_to_attributes(row))
end
# Nachher: zeilenweise streamen + Batch Insert
rows = []
CSV.foreach("import.csv", headers: true) do |row|
rows << row_to_attributes(row)
if rows.size >= 1000
Product.insert_all(rows)
rows.clear
end
end
Product.insert_all(rows) if rows.any?
- Vorher: 4 GB RAM, 12 Minuten, regelmässige Abstürze
- Nachher: 180 MB RAM, 2.5 Minuten, keine Abstürze
5. Memory Bloat und GC-Tuning
Ruby’s Garbage Collector ist generational und inkrementell, aber die Default-Einstellungen sind konservativ. Bei Anwendungen mit hohem Durchsatz lohnt sich Tuning.
Die wichtigsten Umgebungsvariablen:
# Grösserer initialer Heap = weniger GC-Zyklen beim Start
RUBY_GC_HEAP_INIT_SLOTS=600000
# Langsameres Heap-Wachstum = weniger Speicherverschwendung
RUBY_GC_HEAP_GROWTH_FACTOR=1.1
# jemalloc als Allocator (deutlich weniger Fragmentation)
LD_PRELOAD=/usr/lib/x86_64-linux-gnu/libjemalloc.so
USEO’s Take
Der Wechsel zu jemalloc ist die einzelne Massnahme mit dem besten Aufwand-Nutzen-Verhältnis, die wir kennen. Bei einer Rails-App mit 12 Puma-Workern:
- Vorher (glibc malloc): 3.8 GB total nach 24h, stetig steigend
- Nachher (jemalloc): 2.1 GB total nach 24h, stabil
Das ist 45% weniger Speicherverbrauch, ohne eine Zeile Code zu ändern. Der Grund: jemalloc ist besser im Umgang mit der Fragmentierung, die Ruby’s Allokationsmuster erzeugen.
Seit Ruby 3.2 gibt es zudem Variable Width Allocation (VWA), das Objekte kompakter im Speicher ablegt. Allein das Upgrade von Ruby 3.1 auf 3.2 brachte bei einem unserer Projekte 15% weniger Speicherverbrauch.
6. Unkomprimierte Assets verlangsamen alles
Frontend-Performance wird in Rails-Projekten oft vernachlässigt. Dabei sind die Massnahmen einfach:
# config/environments/production.rb
config.assets.css_compressor = :sass
config.assets.js_compressor = :terser
Auf Webserver-Ebene:
# nginx.conf
gzip on;
gzip_types text/css application/javascript application/json image/svg+xml;
gzip_min_length 1000;
# Brotli (bessere Kompression als gzip)
brotli on;
brotli_types text/css application/javascript;
Statische Assets gehören hinter ein CDN. Bei einer Schweizer Anwendung mit Nutzern in der DACH-Region reduzierte ein CDN die Asset-Ladezeiten um durchschnittlich 60%.
7. Ohne Profiling ist alles Raten
Performance-Optimierung ohne Messung ist Spekulation. Diese Tools gehören in jedes Rails-Projekt:
rack-mini-profiler zeigt Timing-Informationen direkt im Browser:
# Gemfile
gem 'rack-mini-profiler'
# Optionaler Flamegraph-Support
gem 'stackprof'
gem 'flamegraph'
Ein Aufruf von ?pp=flamegraph am Ende einer URL generiert einen Flamegraph, der sofort zeigt, wo die Zeit verbraucht wird.
Für Produktions-Monitoring nutzen wir je nach Kundenpräferenz AppSignal, Scout APM oder Datadog. Der entscheidende Punkt: ohne APM in Production sieht man Probleme erst, wenn sich Nutzer beschweren.
USEO’s Take
Unser Standard-Setup für neue Projekte:
rack-mini-profiler+stackprofim Developmentbulletfür N+1-Erkennung im Development und Testderailed_benchmarksfür Boot-Zeit und Memory pro Gem- APM-Tool in Production mit Alerting bei p95 > 500ms
Dieses Setup hat sich bei über 20 Rails-Projekten bewährt. Die Investition in Tooling am Anfang spart Wochen Debugging später.
Ruby-Version macht einen Unterschied
Die Performance-Verbesserungen zwischen Ruby-Versionen sind real und messbar:
| Upgrade | Typische Verbesserung |
|---|---|
| Ruby 2.7 -> 3.0 | 10-15% schnellere Requests (MJIT) |
| Ruby 3.1 -> 3.2 | 15% weniger RAM (VWA) |
| Ruby 3.2 -> 3.3 | YJIT deutlich stabiler, 15-25% schneller |
YJIT (ab Ruby 3.1, produktionsreif ab 3.3) ist der grösste Performance-Sprung seit Jahren. Aktivierung:
RUBY_YJIT_ENABLE=1 bundle exec puma
Bei einem unserer Projekte brachte YJIT allein eine Reduktion der durchschnittlichen Response Time von 120ms auf 85ms.
Fazit
Ruby-Performance-Probleme sind lösbar, wenn man sie systematisch angeht. Die grössten Hebel in der Reihenfolge ihres typischen Impacts:
- N+1 Queries eliminieren (oft 10x schnellere Responses)
jemallocaktivieren (40-50% weniger RAM, null Codeänderungen)- YJIT aktivieren (15-25% schnellere Responses ab Ruby 3.3)
- Caching-Strategie implementieren (Redis + Fragment Caching)
- Profiling-Toolchain etablieren (damit Probleme sichtbar werden)
Wer diese fünf Punkte umsetzt, hat 90% der typischen Performance-Probleme im Griff.