В 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)


  1. includedlibrary
    25.11.2025 13:05

    Я к злой магии ещё бы отнёс возможность устанавливать различные коллбеки прямо в модели. Иногда это сильно мешает пониманию того, почему что-то не работает или наоборот работает (ну например перед сохранением, какое-то поле зануляется). В случае же с коллбеками, которые вызываются перед удалением, всё ещё хуже. Есть два метода - destroy_all и delete_all. Первый подгружает все данные из бд, затем для каждой записи вызывает коллбеки, а потом уже удаляет запись, т. е. всего будет n запросов на удаление и один на подгрузку. А второй делает запрос вида DELETE FROM table where ..., т. е. является более эффективным, но не вызывает коллбеки. Всё вышеописанное может и приводит к не очевидному на первый взгляд поведению.


    1. Laranto Автор
      25.11.2025 13:05

      Да, отличный пример. Эти методы еще более коварны, когда необходимо каскадное удаление и остается от удаленного юзера какое-нибудь уведомление или связанная часть в случае с delete_all.


      1. includedlibrary
        25.11.2025 13:05

        Такие проблемы можно решить на уровне БД, создав внешний ключ. Сейчас мы так и делаем - либо создаём внешний ключ с ON DELETE CASCADE, либо с ON DELETE SET NULL


        1. Laranto Автор
          25.11.2025 13:05

          Мы вообще пользуемся Sequel на не рельсовом руби-проекте, там эти вещи пишутся значительно очевиднее, в том числе ключи.


  1. TMTH
    25.11.2025 13:05

    Мне ближе решение с использованием INNER_JOIN

    Вы джойните Product к Clerk, при том, что, судя по параметрам метода (clerk_id), продавец у вас всего один. Вытащить его по ID отдельным запросом скорее всего будет дешевле, чем делать джоин. Более того, в "небольшой сети магазинов" кладовщиков обычно не очень много - десятки (ну сотни, если их там тысячи это уже не "небольшая сеть"), поэтому эти данные прекрасно кэшируются - меняются редко, места занимают не много, хит рэйт дают хороший. Т.е. этот дополнительный запрос даже и исполняться, чаще всего, не будет.


    1. Laranto Автор
      25.11.2025 13:05

      Все верно, для полутора землекопов (клерков) и циклом пройтись не дорого будет. Это я как раз пример "ничего страшного", а потом магазин превращается ВБ/Озон через год и все переписывать. Помечтать, если )