В Ruby‑разработке ActiveRecord давно стал стандартом: он интуитивно понятен, встроен в Rails и позволяет быстро проводить CRUD‑операции. По мере роста проекта его «удобство» нередко начинает оборачиваться скрытыми проблемами.
Есть ощущение, что решение «из коробки» не только все сделает за вас, но и убережет от ошибок. Обманчивая простота ActiveRecord при работе с БД особенно сказывается на начинающих разработчиках. Не нужно писать мудреные SQL-запросы, т.к. есть интуитивно понятный интерфейс, а MVP надо сделать "еще вчера" и не хочется усложнять.
Мне очень знакома эта ситуация. Я начинала свое практическое знакомство с Ruby и Rails на стажировке. Нам с командой ребят надо было создать MVP приложения для работы со складскими остатками небольшой сети магазинов. Мы придумали схему БД, связи и всю начинку и достаточно быстро выкатили продукт. Это ли не чудо, что буквально за пару недель можно с нуля создать работающий продукт? Пусть сырой и немного кривой, но речь сейчас не об этом.
Естественно, проблемы начались и достаточно быстро.
Проблема 1: цикл "вжух и готово"
Нам потребовалось формировать отчеты о списании просроченной продукции. Казалось бы, обычная задача, но что же могло пойти не так?
Схема cкладского продукта с основными полями.
class Product < ApplicationRecord
name: string (название)
description: text (описание)
expiration_date: date (срок годности)
received_by: integer (ID кладовщика, принявшего на склад)
written_off_by: integer (ID кладовщика, списавшего со склада, nullable)
write_off_date: datetime (дата списания, nullable)
belongs_to :received_by, class_name: 'Clerk', foreign_key: :received_by
belongs_to :written_off_by, class_name: 'Clerk', foreign_key: :written_off_by, optional: true
end
Стоит базовая задача: найти все товары, которые были списаны конкретным сотрудником за период. И в этот момент, если не разобраться в запросах в БД ("под капотом" ActiveRecord), начинается та самая злая магия.
def get_written_off_products(clerk_id, start_date, end_date)
products = Product.where(
written_off_by: clerk_id,
write_off_date: start_date..end_date
)
products.each do |product|
puts "Продукт: #{product.name}"
puts "Дата списания: #{product.write_off_date}"
puts "Имя кладовщика: #{product.written_off_by.name}"
end
products
end
Для опытного разработчика проблема очевидна. Что же здесь не так?
Давайте разбираться.
С помощью Product.where мы находим все продукты с ID кладовщика за определенный период времени. Далее для вывода в отчет нам необходимы название продукта, имя кладовщика и дата списания.
В цикле products.each при каждой итерации происходит запрос в БД для получения данных о кладовщике (имя). Это не проблема, если записей в БД пару сотен, а если это склад уровня маркетплейса? Вот и сказочке конец - злая магия победила добро.
Эта проблема называется N+1: один запрос для списка продуктов и N дополнительных запросов для получения имени кладовщика.
Как можно исправить ситуацию? Мне ближе решение с использованием INNER_JOIN.
Скрытый текст
Данное решение отлично работает на новом проекте в другой компании.
def get_written_off_products(clerk_id, start_date, end_date)
Product
.joins(:written_off_by)
.where(
written_off_by: clerk_id,
write_off_date: start_date..end_date
)
.select(
'products.name',
'products.expiration_date',
'products.written_off_date',
'clerks.name AS clerk_name'
)
.order('products.write_off_date DESC')
end
Одним запросом в БД мы получаем таблицу продуктов, "джойним" таблицу кладовщиков по ID и отсекаем даты по диапазону. С помощью select выбираем нужные поля для отображения и сортируем по дате списания.
Что еще в коде выглядит магическим?
На мой взгляд, .joins(:written_off_by) не указывает явно, что ассоциирована таблица кладовщиков. Безусловно, это можно посмотреть в модели Product, но ведь так теряется фокус и время.
Проблема 2: трудности перевода
Конструкции Ruby читаются, как обычные английские фразы — писать код на нём естественно. Например, выражения вроде users.each или if user.active? воспринимаются, как простые предложения на английском.
Но, как говорится, есть нюанс: некоторые слова, похожие по смыслу (например, any? и exists?), в Ruby on Rails — это методы с разной реализацией и назначением.
Допустим, нам нужен отчет по компаниям и информация, есть ли у них заказы.
companies = Company
.includes(:orders)
.where(active: true)
.order(name: :asc)
companies.each do |company|
status = company.orders.any? ? "есть заказы" : "нет заказов"
puts "#{company.name}: #{status}"
end
Пример выглядит, как n + 1? Подождите делать выводы. Особенность метода any? в том, что он работает с уже выгруженной коллекцией (должна быть заранее) и не отправляет запросы в БД в таком случае.
Однако есть метод с похожим по смыслу названием exists?, но реализация у него совсем иная.
companies = Company
.includes(:orders)
.where(active: true)
.order(name: :asc)
companies.each do |company|
status = company.orders.exists? ? "есть заказы" : "нет заказов"
puts "#{company.name}: #{status}"
end
Вот здесь и кроется ошибка. Метод exists? отправляет запрос в БД и уже не работает с загруженной коллекцией:
SELECT 1 FROM orders WHERE company_id = ? LIMIT 1;
Мы снова получаем ошибку n + 1, а могли бы ее избежать, используя метод any? и не боясь при этом цикла.
Выводы:
Есть такое выражение (автора не нашла):
Если не знать физику, то буквально всё будет казаться волшебством.
При кажущейся простоте готового решения необходимо понимать, как это работает под капотом. Что помогло мне разобраться?
более опытные коллеги
документация Ruby on Rails
исходный код Ruby on Rails (точная реализация всего)
stackoverflow
Проблемы кажутся очевидными, но магическое мышление в отношении готовых решений играет злую шутку с программистами любого уровня.
Благодарю, что дочитали до конца!
Комментарии (6)

TMTH
25.11.2025 13:05Мне ближе решение с использованием INNER_JOIN
Вы джойните
ProductкClerk, при том, что, судя по параметрам метода (clerk_id), продавец у вас всего один. Вытащить его по ID отдельным запросом скорее всего будет дешевле, чем делать джоин. Более того, в "небольшой сети магазинов" кладовщиков обычно не очень много - десятки (ну сотни, если их там тысячи это уже не "небольшая сеть"), поэтому эти данные прекрасно кэшируются - меняются редко, места занимают не много, хит рэйт дают хороший. Т.е. этот дополнительный запрос даже и исполняться, чаще всего, не будет.
Laranto Автор
25.11.2025 13:05Все верно, для полутора землекопов (клерков) и циклом пройтись не дорого будет. Это я как раз пример "ничего страшного", а потом магазин превращается ВБ/Озон через год и все переписывать. Помечтать, если )
includedlibrary
Я к злой магии ещё бы отнёс возможность устанавливать различные коллбеки прямо в модели. Иногда это сильно мешает пониманию того, почему что-то не работает или наоборот работает (ну например перед сохранением, какое-то поле зануляется). В случае же с коллбеками, которые вызываются перед удалением, всё ещё хуже. Есть два метода - destroy_all и delete_all. Первый подгружает все данные из бд, затем для каждой записи вызывает коллбеки, а потом уже удаляет запись, т. е. всего будет n запросов на удаление и один на подгрузку. А второй делает запрос вида DELETE FROM table where ..., т. е. является более эффективным, но не вызывает коллбеки. Всё вышеописанное может и приводит к не очевидному на первый взгляд поведению.
Laranto Автор
Да, отличный пример. Эти методы еще более коварны, когда необходимо каскадное удаление и остается от удаленного юзера какое-нибудь уведомление или связанная часть в случае с delete_all.
includedlibrary
Такие проблемы можно решить на уровне БД, создав внешний ключ. Сейчас мы так и делаем - либо создаём внешний ключ с ON DELETE CASCADE, либо с ON DELETE SET NULL
Laranto Автор
Мы вообще пользуемся Sequel на не рельсовом руби-проекте, там эти вещи пишутся значительно очевиднее, в том числе ключи.