14. Oktober 2026
Das N+1-Problem und die Rolle von includes
Das Rails N+1-Problem entsteht, wenn eine Liste von Hauptobjekten geladen wird und anschließend für jedes einzelne Objekt eine Assoziation abgefragt wird. Ein typisches Beispiel ist eine Liste von Beiträgen, für die jeweils der Autor angezeigt wird.
posts = Post.limit(50)
posts.each do |post|
puts post.author.name
endOhne vorab geladene Assoziation lädt ActiveRecord zunächst die Beiträge und fragt dann beim Zugriff auf post.author die Autoren einzeln nach. includes verhindert diese Folgeabfragen, indem es die Assoziation vorab lädt.
posts = Post.includes(:author).limit(50)
posts.each do |post|
puts post.author.name
endDamit ist Rails includes die übliche erste Antwort auf N+1. Wichtig ist aber: includes beschreibt nicht zwingend eine bestimmte SQL-Strategie.
preload und eager_load: zwei unterschiedliche Strategien
ActiveRecord kennt für das ActiveRecord eager loading vor allem zwei Varianten:
| Methode | SQL-Strategie | Geeignet, wenn |
|---|---|---|
preload | Immer separate Abfragen | Die Assoziation nur zum Anzeigen oder Verarbeiten gebraucht wird |
eager_load | Immer ein LEFT OUTER JOIN | Die assoziierte Tabelle in der Abfrage benötigt wird |
includes | Entscheidet abhängig von der Relation | Beide Fälle möglich sind |
preload lädt zuerst die Hauptdatensätze und dann die zugehörigen Datensätze in einer weiteren Abfrage. Bei Post.preload(:author) sind das typischerweise eine Abfrage für Beiträge und eine für die passenden Autoren.
posts = Post.preload(:author).where(published: true)eager_load erzeugt dagegen immer einen LEFT OUTER JOIN. Die Daten von Beiträgen und Autoren kommen damit aus einer SQL-Abfrage.
posts = Post.eager_load(:author).where(published: true)Ein Join ist nötig, wenn Bedingungen oder Sortierungen auf Spalten der Assoziation beruhen. Genau an dieser Stelle verhält sich includes anders als ein festes Alias für preload.
Wann includes auf einen JOIN wechselt
Solange eine inkludierte Assoziation nicht in where oder order referenziert wird, kann Rails getrennte Abfragen verwenden. Sobald die Relation die Assoziation für Filter oder Sortierung braucht, wechselt includes auf einen Join.
posts = Post.includes(:author)
.where(authors: { active: true })
.order("authors.name")Hier muss die Datenbank die Tabelle authors kennen, um zu filtern und zu sortieren. Rails verwendet dafür einen LEFT OUTER JOIN.
Das ist funktional praktisch, sollte aber bewusst sein: includes kann je nach später ergänzter Bedingung sein SQL-Verhalten ändern. Wer eine feste Strategie ausdrücken will, verwendet direkt preload oder eager_load.
Das übersehene Problem: Speicherverbrauch
includes verhindert Datenbankabfragen beim späteren Zugriff. Es begrenzt aber nicht die Menge geladener Daten. Alle Datensätze der geladenen Assoziation werden als Ruby-Objekte im Speicher gehalten.
Angenommen, eine Übersichtsseite lädt Projekte samt tasks:
projects = Project.includes(:tasks)Wenn die Ansicht nur wissen muss, ob ein Projekt offene Aufgaben hat, werden mit dieser Relation trotzdem sämtliche Aufgaben geladen. Bei großen Assoziationen ist das unnötig teuer: Nicht nur die SQL-Ergebnismenge wächst, sondern auch die Zahl der ActiveRecord-Objekte im Speicher.
Der Join-Fall löst dieses Problem ebenfalls nicht. eager_load kann die Abfrageanzahl reduzieren, aber eine große Ergebnismenge bleibt groß. Ein Join kann sie sogar auf Zeilenebene vervielfachen, weil ein Projekt mit mehreren Aufgaben mehrfach im Resultat erscheint.
Bewusst nur das laden, was gebraucht wird
Die Gegenmaßnahme ist nicht, includes grundsätzlich zu vermeiden. Entscheidend ist, die Anforderung genauer zu modellieren.
Wenn nur einzelne Spalten benötigt werden, kann select die gelesene Datenmenge begrenzen:
projects = Project.select(:id, :name).where(active: true)Wenn eine große Menge verarbeitet werden muss, helfen Batches statt einer vollständigen Materialisierung:
Project.where(active: true).find_each do |project|
# Verarbeitung pro Batch
endFür reine Anzahlen sollte keine vollständige Assoziation geladen werden. Ein counter_cache eignet sich, wenn ein häufig benötigter Zähler am Elternobjekt verfügbar sein soll. Alternativ liefert eine Aggregat-Abfrage die benötigte Anzahl direkt aus der Datenbank.
Project.left_joins(:tasks)
.group(:id)
.count("tasks.id")Die zentrale Frage lautet daher nicht nur: Verhindert diese Relation Rails N+1? Sondern auch: Welche Objekte braucht der aufrufende Code tatsächlich?
Prüft, wo in eurer Codebase includes steht und ob ihr wirklich alle Datensätze der Assoziation braucht.