geektimes

ActiveRecord немного про грабли, Relations и индексы

  • четверг, 6 ноября 2014 г. в 02:10:53
http://habrahabr.ru/post/242225/

Хочу рассказать Вам о наболевшем: о работе с AR в целом и с Relation в частности; предостеречь от стандартных садовых изделий, которые легко могут испортить жизнь и сделать код медленным и прожорливым. Повествование будет основываться на Rails 3.2 и ActiveRecord того же разлива. В Rails 4, конечно же, много чего нового и полезного, но на него ещё перейти нужно, да и фундамент в любом случае один и тот же.

Сей материал, по большей части, предназначен для начинающих, ибо автору очень больно смотреть на извлечение содержимого целых таблиц в память в виде ActiveRecord объектов и на прочие отстрелы конечностей при использовании AR. Разработчикам, познавшим дзен, топик вряд ли принесёт пользу, они могут лишь Помочь, дополнив его своими примерами и назиданиями.



Уж сколько раз твердили миру…


Если вы начали работать с Relation (да и с любым ActiveRecord объектом вообще), то нужно чётко представлять одну вещь: в какой момент мы «овеществляем» выборку, то есть в какой момент мы перестаём конструировать SQL-запрос. Иначе говоря: когда происходит выборка данных и мы переходим к из обработке в памяти. Почему это важно? Да потому что неловкое:

Product.all.find{|p| p.id == 42}

Может повесить сервер, забрать всю оперативку и сделать ещё много пакостей. А то же самое, но иными словами:

Product.find(42)

отработает быстро и без последствий. Таки образом find и find — это совсем не одно и то же! Почему? Да потому что в первом случае мы сказали Product.all и выстрелили себе в ногу, так как это означает извлечь всё содержимое таблицы products и для каждой строки построить AR-объект, создать из них массив и уж по нему пройтись find, который является методом класса Array (вообще говоря, find из Enumerable, но это уже детали). Во втором случае всё гораздо лучше: find — это метод AR и предназначен для поиска по pk. То есть мы генерируем запрос

SELECT * FROM products WHERE products.id = 42;

Выполняем его, получаем одну строку и всё.

Примечание: справедливости ради стоит отметить, что этот пример работает в Rails 3; в Rails 4 all≡scoped и прострелить конечность так просто не получится, разве что вместо all вызвать to_a, но это совсем тяжёлый случай.

Что такое хорошо и что такое плохо


Теперь, разобравшись почему работа с AR — это большая ответственность, разберёмся с тем, как же не выстрелить себе в ногу. Сие довольно просто: надо пользоваться методами, которые предоставляет нам AR. Вот они: where, select, pluck, includes, joins, scoped, unscoped, find_each и ещё несколько, о которых можно узнать в документации или в соседнем хабе. А вот чем лучше не пользоваться перечислить будет очень сложно и, в то же время, очень просто: нежелательно пользоваться всем остальным, так как почти все оставшееся многообразие методов превращает Relation в Array со всеми вытекающими последствиями.

Простые рецепты


Теперь, приведу несколько стандартных и не очень конструкций, которые облегчают жизнь, но о которых очень часто забывают. Но перед задам вопрос читателю: вспомните функцию has_many. Подумайте, какие её параметры вы знаете и какими активно пользуетесь? Перечислите их в уме, посчитайте… а теперь вопрос: знаете ли вы сколько их на самом деле?

Ответ
24 штуки в Rails3 и 12 в Rails4. Разницу в 12шт составляют методы типа where, group и тд, а так же методы для работы с чистым SQL, которые в Rails4 передаются в блоке, а не в хэше.

Зачем я это спросил? Да чтобы очень приблизительно оценить Ваш уровень и сказать, что ежели большую часть опций Вы знаете, то и нижеизложенное вряд ли принесёт Вам новые знания. Оценка эта очень условная, поэтому, уважаемый Читатель, не гневайся сильно, ежели она показалась Тебе нелепой/несостоятельной/странной/etc (нужное подчеркнуть).

Рецепт номер раз


Итак, теперь пойдём по-порядку. Про update_attributes и update_attribute знают все (или не все?). Первый — массово обновляет поля с вызовом валидаций и колбэков. Ничего интереного. Второй — пропускает все валидации, запускает колбэки, но может обновить значение только одного выбранного поля(кому-то больше по душе save(validate: false)). А вот про update_column и update_all почему-то часто забывают. Эти метод пропускают и валидации, и колбэки и пишут прямо в базу без всяких предварительных ласк.

Рецепт номер два


В комментариях напомнили про замечательный метод touch. О нём тоже частенько забывают и пишут что-то вроде

@product.updated_at = DateTime.now
@product.save

или

@product.update_attribute(:updated_at, DateTime.now)

Хотя, по хорошему, для таких целей проще сделать вот так:

@product.touch(:updated_at)  # в данном случае параметр можно опустить

К тому же для touch есть собственный колбэк after_touch, а так же опция :touch присутствует у метода belongs_to.

Как правильно итерировать


В хабе уже говорили про find_each, но я не могу не упомянуть его ещё раз, ибо конструкции

product.documents.map{…}

и им изоморфные, встречаются чуть более чем везде. Проблема в обычных итераторах, применённых на Relation только одна: они вытаскивают записи из БД поштучно. И это ужасно. В противоположность им find_each, по умолчанию, таскает по 1000 штук за раз и это просто прекрасно!

Совет про default_scope


Оборачивайте содержимое default_scope в блок. Пример:

default_scope where(nullified: false)  # плохо!
default_scope { where(nullified: false) }  # хорошо

В чём разница? В том, что первый вариант выполняется прямо при запуске сервера и если поля nullified в БД не оказалось, то и сервер не взлетит. То жесамое относится и к миграциям — они не пройдут из-за отсутствия поля, которое, скорее всего, мы как раз хотим добавить. Во втором случае, в силу того, что Ruby ленив, блок выполнится только в момент обращения к модели и миграции выполнятся штатно.

Has_many through


Ещё один часто встречающийся пациент это

product.documents.collect(&:lines).flatten

здесь продукт имеет много документов, которые имеют много строк. Часто бывает, что хочется получить все строки всех документов, относящихся к продукту. И в таком случае творят вышеописанную конструкцию. В данном случае можно вспомнить про опцию through для реляций и сделать для продукта следующее:

has_many :lines, through: documents

и затем выполнить

product.lines

Получается и нагляднее и эффективнее.

Немного про JOIN


В продолжение темы джоинов вспомним про includes. Что в нём особенного? Да то, что это LEFT JOIN. Довольно часто вижу, что левый/правый джоин пишут явно

joins("LEFT OUTER JOIN wikis ON wiki_pages.wiki_id=wikis.id")

это конечно тоже работает, но чистый SQL в RoR всегда был не в почёте.

Так же, не отходя от кассы, надо напомнить про разницу значений в joins и where при совместном использовании. Допустим у нас есть таблица users, а разные сущности, например products имеют поле author_id и реляцию author, кояя имеет под собой таблицу users.

has_one :author,
	     class: 'User',
	     foreign_key: 'author_id'   # не обязательно, но для наглядности

Следующий код для такого случая работать не будет

products.joins(:author).where(author: {id: 42})

Почему? Потому что в joins указывается имя реляции, которую джоиним, а в where накладывается условие на таблицу и надо говорить

where(users: {id: 42})

Избежать такого можно явным указанием ‘AS author’ в джоине, но это снова будет чистый SQL.

Далее посмотрим на джоины с другого ракурса. Что бы мы не джоинили, в итоге мы получаем объекты класса, с которого всё начиналось:

Product.joins(:documents, :files, :etc).first

В данном случае получаем продукт вне зависимости от количества джоинов. Некоторых это поведение огорчает, так как им хотелось бы получить поля из приджойненных таблиц. И они начинают делать этот же запрос с другой стороны: брать документы, джоинить их с продуктами, писать чистый SQL для связи с другими сущностями, вобщем изобретают велосипед, когда правильный и логичный код был написан в самом начале. Поэтому напомню самую основу:

Product.joins(:documents, :files, :etc).where(...).pluck('documents.type')

Здесь мы получаем массив с нужным полем из БД. Плюсы: минимум запросов, не создаётся AR-объектов. Минусы: в Rails 3 pluck принимает только 1(один) параметр и вот такое

pluck('documents.type', 'files.filename', 'files.path')

можно будет сделать только в Rails 4.

Build реляций


Теперь обратимся к рассмотрению работы с build-ом реляций. В общем случае всё довольно просто:

product.documencts.build(type: 'article', etc: 'etc').lines.build(content: '...')

После вызова product.save у нас будет происходить сохранение всех ассоциаций вместе с валидациями, преферансом и куртизанками. Во всём этом радостном действе есть один нюанс: всё это хорошо, когда product не readonly и/или нет иных ограничений на сохранение. В таких случаях многие устраивают огород, аналогичный огороду с joins в примере выше. То есть создают document, привязывают его к product и build-ят строки для документа. Получается кривова-то и дефолтное поведение, которое, обычно, завязано на обработку ошибок product не работает. Поэтому в довесок всё это сразу же обставляют костылями, пробрасывающими ошибки и получается довольно мерзко. Что делать в таком случае? Надо вспомнить про autosave и понять как он работает. Не вдаваясь в детали скажу, что работает он на callback-ах. Поэтому способ сохранить реляции для вышеописанного продукта есть:

product.autosave_associated_records_for_documents

В этом случае случится сохранение документа, вызовутся его колбэки для сохранения строк и т.д.

Несколько слов об индексах


На последок нужно сказать про индексы, ибо многие бились головой об твёрдые предметы из-за проблем на почве индексов. Сразу прошу прощения что мешаю в кучу ActiveRecord и возможности БД, но по личному убеждению: нельзя хорошо работать с AR, не осознавая что происходит в этот момент на стороне БД.

Проблема первая


Почему-то многие уверены что order на Relation не зависит от того, по какому столбцу мы сортируем. Разновидностью этого заблуждения является отсутствие понимания разницы между order Relation и order Array. Из-за этого можно встретить default_scope с ордером по VARCHAR полю и вопросы в духе: «А почему это у вас так медленно страница загружается? Там же всего пара записей извлекается из БД!». Проблема здесь в том, что дефолтная сортировка — это чертовски дорого, если у нас нет индекса на этом столбце. По умолчанию AR сортирует по pk. Это происходит когда мы делаем

Products.first

Но у pk есть индекс практически всегда и проблем нет. А вот когда мы говорим, что будет делать order(:name) при любом обращении к модели — начинаются проблемы.
Для справки: если объяснять «на пальцах», то при сортировке по индексированному столбцу реальной сортировки не происходит, она уже присутствует в базе и данные сразу отдаются в правильном порядке.

Проблема вторая


Составные индексы. Не все о них знают и ещё меньший круг лиц знает зачем они нужны. Если коротко, то составной индекс — это индекс на основе двух и более полей БД. Где он может пригодиться? Два частых места его использования:
  • polymorphic ассоциации
  • промежуточная таблица связей «много ко многим».
Про полиморфные связи было рассказано здесь. Для них, очень часто, удобно создавать составной индекс. Вот немного дополненный пример из офф.манула:

class CreatePictures < ActiveRecord::Migration
  def change
    create_table :pictures do |t|
      t.string  :name
      t.integer :imageable_id
      t.string  :imageable_type
      t.timestamps
    end
    
    add_index :pictures, [:imageable_id, :imageable_type] # вот он составной индекс
  end
end

Вот несколько слов про разницу обычного и составного индекса. Далее в подробности вдаваться не буду, ибо тема для отдельного хаба. К тому же, до меня уже всё расписали.
Теперь про промежуточную таблицу связей. Всем известный HBTM. Здесь, в некоторых случаях, уместно повесить составной индекс на assemblies_parts (см. ссылку на HBTM). Но надо помнить о том, что последовательность полей в составном индексе имеет знаение. Подробности тут.

Проблема третья


«Индексы нужны везде!». Встречается не так часто, но вызывает страшные тормоза всего и вся. Нужно помнить, что индекс — это не панацея и гарантированный х10-х100 к скорости, а инструмент, который нужно применять в правильных местах, а не махать им над головой и засовывать в каждую дырку. Вот тут можно почитать про типы индексов, а тут можно узнать зачем они вообще нужны.

За сим все


Спасибо что дочитали до конца. Про опечатки и неточности пишите в лс, буду рад исправить. Так же буду рад, если поделитесь своим «наболевшим» и своими опытом о том, что надо помнить и чем лучше пользоваться в разных ситуациях при разработке.