Eager loading: preload vs. eager_load

Ruby on Rails beinhaltet seit Langem das sogenannte Eager Loading. Es löst das N+1 Anfragen Problem.

Eager loading

Der Ruby on Rails Guide beschreibt exakt, was mit Eager Loading gemeint ist:

Active Record lets you specify in advance all the associations that are going to be loaded. This is possible by specifying the includes method of the Model.find call. With includes, Active Record ensures that all of the specified associations are loaded using the minimum possible number of queries.

Dabei gibt es 2 unterschiedliche Ansätze bezüglich des generierten SQL.

Das Beispiel

Als Grundlage dienen folgende Assoziation:

class Order < ApplicationRecord
end

class User < ApplicationRecord
  has_many :orders
end

Eager loading ist relevant, wenn alle User mit ihren Bestellungen geladen werden sollen:

User.all.each { |user| user.orders.map(&:total) }

Dieser naive Ansatz erzeugt das SQL:

SELECT "users".* FROM "users";
SELECT "orders".* FROM "orders" WHERE "orders"."user_id" = 1;
SELECT "orders".* FROM "orders" WHERE "orders"."user_id" = 2;
SELECT "orders".* FROM "orders" WHERE "orders"."user_id" = 3;

Das Beispiel beschreibt das N+1 Problem: es wird pro User eine weitere Anfrage auf die Order-Tabelle gemacht.
Rails bietet Lösungen.

1.) eager_load

Der Zugriff mit ActiveRecord::QueryMethods#eager_load:

User.eager_load(:orders)

erzeugt einen LEFT JOIN mit der assoziierten Tabelle (orders):

SELECT "users"."id" AS t0_r0, "users"."email" AS t0_r1, "users"."created_at" AS t0_r2, "users"."updated_at" AS t0_r3, "orders"."id" AS t1_r0, "orders"."user_id" AS t1_r1, "orders"."product_id" AS t1_r2, "orders"."created_at" AS t1_r3, "orders"."updated_at" AS t1_r4
FROM "users"
LEFT OUTER JOIN "orders"
  ON "orders"."user_id" = "users"."id";

Aus den ursprünglichen N+1 Datenbankanfragen wird eine einzelne große Anfrage. ActiveRecord iteriert dann über die Ergebnismenge und baut daraus die entsprechenden User und Order Objekte.
Dieser Ansatz ist robust, zumal wenn in der Selektion (WHERE condition) auf gejointe Tabellen referenziert wird.
Wenn in der WHERE Bedingungen keine Tabelle definiert ist, nimmt Rails die Zieltabelle:

User.eager_load(:orders).where(created_at: Time.current)

SQL:

SELECT "users"."id" AS t0_r0, "users"."email" AS t0_r1, "users"."created_at" AS t0_r2, "users"."updated_at" AS t0_r3, "orders"."id" AS t1_r0, "orders"."user_id" AS t1_r1, "orders"."product_id" AS t1_r2, "orders"."created_at" AS t1_r3, "orders"."updated_at" AS t1_r4
  FROM "users"
  LEFT OUTER JOIN "orders"
    ON "orders"."user_id" = "users"."id"
  WHERE "users"."created_at" = '2017-02-27 22:23:34';

2.) preload

Der alternative Ansatz ActiveRecord::QueryMethods#preload:

User.preload(:orders)

erzeugt SQL, dass pro Assoziation eine zusätzliche Datenbankanfrage enthält:

SELECT "users".* FROM "users";
SELECT "orders".* FROM "orders" WHERE "orders"."user_id" IN (1, 2, 3);

Hinterher iteriert Rails dann wieder über die Ergebnismengen und erzeugt entsprechende Objekte.
Allerdings darf die Selektion keine Referenz auf assoziierte Tabellen enthalten. Denn sonst kommt es zu SQL Fehlern:

User.preload(:orders).where(user_id: 1)
# => ActiveRecord::StatementInvalid: SQLite3::SQLException: no such column: users.user_id

Auf der anderen Seite ist preload ganz klar eine Optimierung. So zum Beispiel werden unnötige Datenbankanfragen vermieden.
So zum Beispiel, wenn es aufgrund nicht gefundener User:

User.where(created_at: 1.day.from_now).preload(:orders)
# => []

auch keine Bestellungen geben kann. Die Anfrage auf orders wird vermieden:

SELECT "users".* FROM "users" WHERE "users"."created_at" = '2017-02-27 22:33:59';

Komfort: includes

Auf diese beiden Ansätze setzt ActiveRecord::QueryMethods#includes auf.
Seit Ruby on Rails 4, muss beim includes explizit mit ActiveRecord::QueryMethods#references definiert werden, auf welche Tabelle sich die Selektion bezieht. Je nachdem ob es die Zieltabelle oder eine gejointe Tabelle ist, wird dann ein preload oder ein eager_load ausgeführt.
Wenn Zieltabelle referenziert wird:

User.includes(:orders)
    .where(created_at: 1.day.ago..Time.current)
    .references(:users)

Das SQL:

SELECT "users".* 
  FROM "users"
  WHERE ("users"."created_at" BETWEEN '2017-02-26 22:38:18' AND '2017-02-27 22:38:18');
SELECT "orders".*
  FROM "orders"
  WHERE "orders"."user_id" IN (1, 2, 3);

Wenn Join Tabelle referenziert wird:

User.includes(:orders)
    .where("paid_at < ?", 1.year.ago)
    .references(:orders)

Das SQL:

SELECT "users"."id" AS t0_r0, "users"."email" AS t0_r1, "users"."created_at" AS t0_r2, "users"."updated_at" AS t0_r3, "users"."paid_at" AS t0_r4, "orders"."id" AS t1_r0, "orders"."user_id" AS t1_r1, "orders"."product_id" AS t1_r2, "orders"."created_at" AS t1_r3, "orders"."updated_at" AS t1_r4
  FROM "users"
  LEFT OUTER JOIN "orders"
    ON "orders"."user_id" = "users"."id"
  WHERE "paid_at" = '2016-02-27 22:42:25';

Also auch mit includes muss beachtet werden, auf welche Tabelle sich eine WHERE Bedingung bezieht. Wenn mit der Selektion eine gejointe Tabelle gemeint ist, ist references zwingend notwendig. Ansonsten gibt es entweder eine SQL Exception (no such column) oder aber schlimmer noch falsche Resultate.
Sofern kein references gesetzt ist, geht Rails davon aus, dass sich die Selektion auf die Zieltabelle bezieht (wie bei eager_load auch).
Grundsätzlich macht es immer Sinn, den Tabellennamen der Spalte eindeutig zu referenzieren. Ansonsten kann es zu einer SQL Exception kommen (ambiguous column name).