ActiveRecord: опасная магия. Часть 2

от автора

В первой части статьи мы обсуждали, как обманчивая простота ActiveRecord приводит к проблеме N+1 запросов, и я предложила решение через joins и select. После публикации и обсуждений я поняла, что своём примере я упустила важнейший нюанс, который превращает моё же решение в бомбу замедленного действия. О том, сколько неочевидной логики скрыто под капотом ActiveRecord — вторая часть.

Работа над ошибками: нюанс в первой части

Напомню решение из первой части. Задача: вывести списанные товары за период с именами кладовщиков, избежав N+1.

def get_written_off_products(clerk_id, start_date, end_date)  Product    .joins(:written_off_by)    .where(      written_off_by: clerk_id,      written_off_date: start_date..end_date    )    .select(      'products.name',      'products.expiration_date',      'products.written_off_date',      'clerks.name AS clerk_name'    )    .order('products.written_off_date DESC')end

Решение рабочее, в базу улетает ровно один запрос:

SELECT products.name, products.expiration_date, products.written_off_date,       clerks.name AS clerk_nameFROM "products"INNER JOIN "clerks" ON "clerks"."id" = "products"."written_off_by"WHERE "products"."written_off_by" = ?  AND "products"."written_off_date" BETWEEN ? AND ?ORDER BY products.written_off_date DESC

Но я не сказала главного:

Скрытый текст

это решение работает только пока вы обращаетесь к алиасуproduct.clerk_name. Стоит вам (или коллеге через полгода) написать в цикле привычное обращение к ассоциации и вуаля:

products = get_written_off_products(clerk_id, start_date, end_date)products.each do |product|  puts "Имя кладовщика: #{product.clerk_name}"          # 0 дополнительных запросов  puts "Имя кладовщика: #{product.written_off_by.name}" # +1 запрос на КАЖДЫЙ товар!end

Давайте разбираться, почему N+1 вернулся!

Что на самом деле не делает joins

Логика нам подсказывает, раз мы «поджойнили» таблицу кладовщиков, значит, её данные у нас на руках. Эта логика приходит из мира чистого SQL и низкоуровневых инструментов. Например, в геме Sequel (альтернативная ORM) прямой JOIN действительно отдаёт данные обеих таблиц:

DB[:products].join(:clerks, id: :written_off_by).all# SELECT * FROM products INNER JOIN clerks ON ...# => [{id: 1, name: "Иван", written_off_by: 1}, ...]

Вторая таблица загрузилась, с ней можно работать сразу.

ActiveRecord же — это объектно-ориентированная ORM. Его контракт: вернуть вам граф объектов Product, а не плоские строки. Поэтому joins без select генерирует SQL, запрашивающий колонки только главной таблицы:

SELECT "products".* FROM "products"INNER JOIN "clerks" ON "clerks"."id" = "products"."written_off_by"

Таблица clerks участвует в соединении, т.е. по ней можно фильтровать в where, но её данные ActiveRecord даже не запрашивает. Объекты Clerk не создаются, ассоциация остаётся незагруженной. Когда вы вызываете product.written_off_by, фреймворк честно идёт в базу за недостающим кладовщиком.

Наш select('clerks.name AS clerk_name')тянет кусочек второй таблицы в результат, но, совсем не тем путём, каким загружаются ассоциации.

Магия № 1: куда попадает алиас из select

База вернула строки, в которых есть колонка clerk_name. Как ActiveRecord превращает её в вызов product.clerk_name? Здесь начинается метапрограммирование.

У каждого объекта ActiveRecord есть внутреннее хранилище — это набор атрибутов, доступный через метод attributes. Создавая объект из строки ответа базы, ActiveRecord складывает туда все колонки ответа, не сверяясь со схемой таблицы. Пришла колонка? Кладет в атрибут:

product = Product.joins(:written_off_by)                 .select('products.*', 'clerks.name AS clerk_name')                 .firstproduct.attributes# => {"id"=>1, "name"=>"Товар 1", ..., "clerk_name"=>"Иван"}

Алиас стал полноценным атрибутом экземпляра, наравне с родными полями. А вот дальше самое интересное. Проверим, существует ли метод clerk_name в классе:

Product.instance_methods.include?(:clerk_name)  # => false !product.respond_to?(:clerk_name)                # => true  ?!

Метод в классе не определён, но объект на него отвечает. Никакого противоречия: когда вы вызываете product.clerk_name, Ruby не находит такого метода и передаёт управление в method_missing, который ActiveRecord переопределил (модуль ActiveModel::AttributeMethods). Тот заглядывает в атрибуты экземпляра, находит ключ clerk_name и возвращает значение из памяти. Ни одного обращения к базе. Метод respond_to? переопределён аналогично (через respond_to_missing?), поэтому объект «знает» о своём динамическом атрибуте.

Отсюда два важных следствия. Во-первых, дополнительному запросу при чтении алиаса просто неоткуда взяться, данные уже пришли с JOIN и лежат в хэше. Во-вторых, такие объекты, какclerk_name,существуют только у экземпляров, загруженных именно этим запросом. У обычного Product.first вызов clerk_name упадёт с NoMethodError. Эта строка без типизации из схемы и без полноценного объекта Clerk за ней создана для отчёта, но для передачи дальше по коду опасна.

Магия № 2: как includes кеширует ассоциации

Если нужны настоящие объекты Clerk, а не строковый слепок одного поля, то путь один: загрузка ассоциации через includes.

Product.includes(:written_off_by).each do |product|  puts product.written_off_by.name  # 0 дополнительных запросовend

По умолчанию (если нет условий по второй таблице) includes работает в режиме preload, т.е.два отдельных запроса:

SELECT "products".* FROM "products"SELECT "clerks".* FROM "clerks" WHERE "clerks"."id" IN (1, 2)

А дальше магия на уровне памяти Ruby. У каждого объекта ActiveRecord, помимо атрибутов, есть вторая скрытая структура — это кеш ассоциаций (внутренняя переменная @association_cache). Это хэш вида «имя ассоциации → объект-обёртка с загруженными данными». ActiveRecord берёт результаты второго запроса, создаёт полноценные объекты Clerk и раскладывает их по кешам соответствующих продуктов, помечая ассоциации флагом loaded?:

product = Product.includes(:written_off_by).firstproduct.association(:written_off_by).loaded?  # => trueproduct_via_joins = Product.joins(:written_off_by).firstproduct_via_joins.association(:written_off_by).loaded?  # => false !

Вот и весь секрет. Вызов product.written_off_by первым делом проверяет кеш ассоциаций: если ассоциация загружена, то объект отдаётся из памяти без SQL, если нет, то идет запрос в базу. После includes флаг выставляется, после joins нет.

Кстати, includes умеет делать и один запрос: если добавить условие по ассоциированной таблице (например, .where("clerks.name IS NOT NULL").references(:clerks)), ActiveRecord автоматически переключится с preload на eager_load — это один массивный LEFT OUTER JOIN, где фреймворк сам раздаёт всем колонкам обеих таблиц уникальные алиасы, а потом наполняет кеш ассоциаций. Сколько всего в одном невинном методе!

Два пути чтения данных: attributes против association_cache

Сведём всё вместе. Ключ к пониманию «магии»: у объекта ActiveRecord есть два независимых хранилища, и обращения к ним выглядят в коде почти одинаково, а работают совершенно по-разному.

product.clerk_name

product.written_off_by.name

Где ищутся данные?

атрибуты экземпляра (@attributes)

кеш ассоциаций (@association_cache)

Кто наполняет?

любые колонки из ответа БД, в т.ч. алиасы из select

только includes / preload / eager_load

Наполняет ли joins?

да, если колонка есть в select

нет

Механизм доступа

method_missing → чтение из хэша

метод ассоциации → проверка loaded?

SQL при обращении

нет

нет, если загружена;

+1 запрос, если нет

Что возвращается?

«сырое» значение колонки

полноценный объект Clerk

Доверяй, но проверяй: инструменты отладки запросов

Единственная надёжная защита — это смотреть, какие запросы реально улетают в базу. К счастью, инструменты встроены в сам Rails.

Метод to_sql покажет сгенерированный SQL, не выполняя запрос. Идеально для быстрой проверки в консоли:

puts Product.joins(:written_off_by).where(written_off_by: 1).to_sql# SELECT "products".* FROM "products" INNER JOIN "clerks" ON ...

Метод explain пойдёт дальше, попросит саму базу рассказать план выполнения. Так ловятся отсутствующие индексы: если видите Seq Scan (PostgreSQL) или SCAN (SQLite) по большой таблице, то база перебирает её целиком:

Product.joins(:written_off_by).where(written_off_by: 1).explain# SEARCH clerks USING INTEGER PRIMARY KEY (rowid=?)# SCAN products   <-- нет индекса по written_off_by!

Выводы

Вернёмся к решению из первой части. Оно по-прежнему хорошее, но для своей задачи: joins + select с алиасами идеален для отчётов, где нужно вытащить пару полей из связанных таблиц одним запросом, не тратя память на сотни лишних объектов. Но теперь мы знаем цену: алиас живёт в атрибутах конкретных экземпляров, и обращаться можно только к нему. Шаг в сторону и привычное обращение к ассоциации порождает N+1, потому что кеш ассоциаций пуст: joins его не наполняет.

Если же нужны полноценные объекты ассоциированной модели, то используйте includes: два предсказуемых запроса, наполненный кеш ассоциаций и свобода обращаться к product.written_off_by сколько угодно раз без единого лишнего SELECT.

ActiveRecord прячет за одинаково выглядящими вызовами принципиально разную механику: атрибуты «на лету» через method_missing, кеши ассоциаций, автопереключение preload/eager_load. Не угадывайте, как оно работает, а просто проверяйте: to_sql, explain, логи, счётчики запросов. Магия перестаёт быть злой ровно в тот момент, когда вы заглядываете глубже в суть вещей.

Благодарю, что дочитали до конца! Буду рада обсудить ваши наблюдения в комментариях.

ссылка на оригинал статьи https://habr.com/ru/articles/1061820/