В первой части статьи мы обсуждали, как обманчивая простота 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 |
|
Где ищутся данные? |
атрибуты экземпляра ( |
кеш ассоциаций ( |
|
Кто наполняет? |
любые колонки из ответа БД, в т.ч. алиасы из |
только |
|
Наполняет ли |
да, если колонка есть в |
нет |
|
Механизм доступа |
|
метод ассоциации → проверка |
|
SQL при обращении |
нет |
нет, если загружена; +1 запрос, если нет |
|
Что возвращается? |
«сырое» значение колонки |
полноценный объект |
Доверяй, но проверяй: инструменты отладки запросов
Единственная надёжная защита — это смотреть, какие запросы реально улетают в базу. К счастью, инструменты встроены в сам 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/