Практическое использование Rails: Часть 3. Оптимизация ActiveRecord (исходники)

Брюс Тэйт

Программирование в Ruby on Rails может избаловать. Динамично развивающаяся среда избавит от рутины, обычной при использовании других сред. Все идеи будут реализованы всего лишь в нескольких уже ставших привычными строчках кода. Вам понравится использовать ActiveRecord.

Как для закоренелого Java-программиста, ActiveRecord была для меня чем-то чужеродным. С помощью сред разработки Java я обычно строю отображение независимых моделей и схем. Среды такого типа называются средами отображения. В ActiveRecord я определяю только схему базы данных, либо на языке SQL, либо в классе Ruby, который называется миграцией. Если среда строит структуру объектной модели на основе структуры базы данных, то ее называют обертывающей средой разработки. Но, в отличие от большинства обертывающих сред, Rails способна получать характеристики объектной модели, делая запросы к таблице базы данных. Вместо того чтобы создавать сложные запросы, я могу использовать модель для изучения отношений в Ruby вместо SQL. Таким образом, у меня есть простота обертывающей среды и значительная часть функциональных возможностей среды отображения. ActiveRecord проста в использовании и в расширении. Иногда даже слишком проста.

Как и любая другая среда разработки баз данных, ActiveRecord предоставляет много возможностей, способных привести к возникновению проблем. Например, существует опасность выбрать слишком много полей или забыть про такие важные возможности структурирования базы данных, как индексы или ограничения null-значений. Я не говорю, что ActiveRecord является плохой средой разработки. Но вы должны знать, как повысить надежность приложения, если возникнет необходимость его масштабирования. В данной статье будет рассказано о некоторых очень важных способах оптимизации, которые могут понадобиться при работе с необычной по своей устойчивости средой Rails.

Разберёмся с основами

Создание модели на основе схемы - это такая же простая задача, как и написание небольшого фрагмента кода с помощью script/generate model model_name. Как известно, данная команда генерирует модель, миграцию, модульный тест и даже фиксированную величину, используемую по умолчанию. Она пытается заполнить несколько полей данных в миграции и внести некоторые тестовые данные, написать несколько тестов, добавить проверки правильности и, в конце концов, сообщить, что все выполнено. Но будьте внимательны! Необходимо также принять во внимание структуру всей базы данных. Запомните:

  • Rails не оградит от основных проблем производительности базы данных. Для эффективной работы базе данных необходима информация зачастую в виде индексов.
  • Rails не оградит от проблем обеспечения целостности данных. Хотя большинство Rails-разработчиков не любят задавать ограничения в самой базе данных, все-таки необходимо учитывать такие вещи, как, например, поля, допускающие пустые значения (null).
  • Для большинства элементов в Rails есть удобные значения по умолчанию. Случается так, что значения атрибутов по умолчанию, например, длина текстового поля, слишком велики для большинства практических приложений.
  • Rails не заставит вас разрабатывать эффективную структуру базы данных.

Перед тем, как начать утомительный процесс полного погружения в ActiveRecord, необходимо убедиться, что для этого есть прочная база: удостоверьтесь, что структура индексов позволяет справиться с задачей. Если заданная таблица отличается большим размером, если приходится осуществлять поиск по какому-либо полю кроме id, если индекс может оказаться полезным (для получения более подробной информации прочтите документацию по системе управления базой данных: различные базы данных используют индексы по-разному), то создайте этот индекс. Для создания индекса вовсе не нужно овладевать SQL, можно просто воспользоваться миграцией. Индекс легко создать с помощью миграции create_table, или можно создать дополнительную миграцию, создающую индекс. Ниже приведен пример миграции, создающей индекс и используемой в ChangingThePresent.org:

Листинг 1. Создание индекса при миграции

                
class AddIndexesToUsers < ActiveRecord::Migration
  def self.up
    add_index :members, :login
    add_index :members, :email
    add_index :members, :first_name
    add_index :members, :last_name
  end

  def self.down
    remove_index :members, :login
    remove_index :members, :email
    remove_index :members, :first_name
    remove_index :members, :last_name
  end
end


ActiveRecord сам позаботится об индексе по id, поэтому я явно добавлю те индексы, которые будут использоваться в различных поисках, потому что таблица очень большая, редко обновляется и интенсивно используется для поиска. Зачастую перед тем, как принять какие-либо меры, мы ждем оценки проблемы с помощью запроса. Такая стратегия позволяет избежать домысливания механизма СУБД. Однако в случае с пользователями известно, что таблица очень быстро разрастается до миллиона записей и при отсутствии индексов для тех полей, в которых часто происходит поиск, становится просто неэффективной.

Еще две часто возникающих проблемы также связаны с миграциями. Если есть строки и поля, которые не должны быть пустыми, то необходимо удостовериться, что в коде миграции это учтено. Большинство администраторов баз данных могут прийти к выводу, что в Rails задано неправильное значение по умолчанию для пустых полей: по умолчанию, поля могут быть пустыми (нулевыми). Если нужно создать поле, которое не может быть пустым, то необходимо явно добавить параметр :null => false. И, если есть строковое поле, необходимо убедиться в том, что прописано соответствующее ограничение длины. По умолчанию, миграции опишут поле string как varchar(255). Обычно этого более чем достаточно. Следует сделать все возможное, чтобы задать структуру базы данных, отражающую само приложение. Если приложение ограничивает учетную запись 10 символами, то вместо того, чтобы оставлять поле, содержащее учетную запись, неограниченным, следует корректно построить базу данных, как показано в листинге 2:

Листинг 2. Написание миграций с ограничениями и непустыми полями

                
t.column :login, :string, :limit => 10, :null => false

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

Проблема N+1

По умолчанию, отношения ActiveRecord отложены. Это означает, что среда не будет обращаться к информационному объекту, на который указывает отношение, до тех пор, пока вы явно к нему не обратитесь. Возьмем, например, участника, у которого есть адрес. Можно открыть консоль и набрать команду : member = Member.find 1. К логу будет добавлена запись, показанная в листинге 3:

Листинг 3. Лог Member.find(1)

                
^[[4;35;1mMember Columns (0.006198)^[[0m   ^[[0mSHOW FIELDS FROM members^[[0m
^[[4;36;1mMember Load (0.002835)^[[0m   ^[[0;1mSELECT * FROM members WHERE
 (members.`id` = 1) ^[[0m

У Member есть связь с адресом, которая была определена при помощи макрокоманды has_one :address, :as => :addressable, :dependent => :destroy. Обратите внимание, что при загрузке Member ActiveRecord в логе не видно поля адреса (address). Однако, если в консоли набрать member.address, то в development.log вы увидите содержимое листинга 4:

Листинг 4. Обращение к отношению приводит к принудительному обращению к базе данных

                
  ^[[36;2m./vendor/plugins/paginating_find/lib/paginating_find.rb:98:in `find'^[[0m
^[[4;35;1mAddress Load (0.252084)^[[0m   ^[[0mSELECT * FROM addresses WHERE
 (addresses.addressable_id = 1 AND addresses.addressable_type = 'Member') LIMIT 1^[[0m
  ^[[35;2m./vendor/plugins/paginating_find/lib/paginating_find.rb:98:in `find'^[[0m

Таким образом, ActiveRecord не выполняет запрос по адресу до тех пор, пока вы явно не обратитесь к member.address. Как правило, такое отложенное решение отлично работает, так как персистентной среде не требуется пересылать слишком много информации для загрузки данных участника. Однако если предположить, что необходимо получить доступ к списку участников и их адресам, то код будет выглядеть, как показано в листинге 5:

Листинг 5. Получение списка участников с их адресами

                
Member.find([1,2,3]).each {/member/ puts member.address.city}

Так как необходимо увидеть запрос к каждому адресу, то, с точки зрения производительности, результаты будут не очень удовлетворительными, как показано в листинге 6:

Листинг 6. Запросы для проблемы N+1

                
^[[4;36;1mMember Load (0.004063)^[[0m   ^[[0;1mSELECT * FROM members WHERE
 (members.`id` IN (1,2,3)) ^[[0m
  ^[[36;2m./vendor/plugins/paginating_find/lib/paginating_find.rb:98:in `find'^[[0m
^[[4;35;1mAddress Load (0.000989)^[[0m   ^[[0mSELECT * FROM addresses WHERE
 (addresses.addressable_id = 1 AND addresses.addressable_type = 'Member') LIMIT 1^[[0m
  ^[[35;2m./vendor/plugins/paginating_find/lib/paginating_find.rb:98:in `find'^[[0m
^[[4;36;1mAddress Columns (0.073840)^[[0m   ^[[0;1mSHOW FIELDS FROM addresses^[[0m
^[[4;35;1mAddress Load (0.002012)^[[0m   ^[[0mSELECT * FROM addresses WHERE
 (addresses.addressable_id = 2 AND addresses.addressable_type = 'Member') LIMIT 1^[[0m
  ^[[35;2m./vendor/plugins/paginating_find/lib/paginating_find.rb:98:in `find'^[[0m
^[[4;36;1mAddress Load (0.000792)^[[0m   ^[[0;1mSELECT * FROM addresses WHERE
 (addresses.addressable_id = 3 AND addresses.addressable_type = 'Member') LIMIT 1^[[0m
  ^[[36;2m./vendor/plugins/paginating_find/lib/paginating_find.rb:98:in `find'^[[0m

Как я и обещал, результаты безобразны. Всего выполняется один запрос для всех участников, и еще один запрос для каждого из адресов. У нас выбирается 3 участника, и для этого используется 4 запроса. N участников - N+1 запросов. Данная проблема называется проблемой N+1. Большинство персистентных сред решает данную проблему с помощью "энергичных ассоциаций" (eager associations). Rails не является исключением из этого правила. Если известно, что возникнет необходимость в доступе по отношению, можно включить его в исходный запрос. Для этого ActiveRecord использует параметр :include. Если изменить запрос на Member.find([1,2,3], :include => :address).each {/member/ puts member.address.city}, то результат будет гораздо лучше:

Листинг 7. Решение проблемы N+1

                
^[[4;35;1mMember Load Including Associations (0.004458)^[[0m   ^[
   [0mSELECT members.`id` AS t0_r0, members.`type` AS t0_r1,
   members.`about_me` AS t0_r2, members.`about_philanthropy`

   ...

   addresses.`id` AS t1_r0, addresses.`address1` AS t1_r1,
   addresses.`address2` AS t1_r2, addresses.`city` AS t1_r3,

   ...

   addresses.`addressable_id` AS t1_r8 FROM members
   LEFT OUTER JOIN addresses ON addresses.addressable_id
   = members.id AND addresses.addressable_type =
   'Member' WHERE (members.`id` IN (1,2,3)) ^[
   [0m
 ^[[35;2m./vendor/plugins/paginating_find/lib/paginating_find.rb:
  98:in `find'^[[0m


Такой запрос будет работать гораздо быстрее. Перед вами один запрос, который отображает данные обо всех участниках с их адресами. Именно таким образом работают "энергичные ассоциации".

С помощью ActiveRecord параметр :include можно представить в форме вложения, но допускается только один уровень вложения. Например, представим, что у участника Member есть много контактов contacts, а у каждого контакта Contact только один адрес address. Если требуется отобразить все города, в которых проживают контакты данного участника, то можно использовать код, приведенный в листинге 8:

Листинг 8: Получение всех городов для контактов участника

                
member = Member.find(1)
member.contacts.each {/contact/ puts contact.address.city}

Представленный код будет работать, но придется запрашивать участника, каждый контакт и адрес каждого контакта. Производительность можно слегка увеличить, если добавить :contacts с помощью :include => :contacts. Еще лучше включить и то, и другое, как показано в листинге 9:

Листинг 9: Получение всех городов для контактов участника

                
member = Member.find(1)
member.contacts.each {/contact/ puts contact.address.city}

Оптимальным вариантом в данной ситуации будет использование возможности вложенного включения:

                
member = Member.find(1, :include => {:contacts => :address})
member.contacts.each {/contact/ puts contact.address.city}

Данное вложенное включение указывает Rails "энергично" (eagerly) включить отношения contacts и address. Можно использовать "энергичню загрузку " всякий раз, когда вы знаете, что в данном запросе будут использоваться отношения. Именно этот алгоритм оптимизации производительности чаще всего используется нами в ChangingThePresent.org, но у него есть ограничения. При необходимости соединения более двух таблиц лучше всего использовать SQL. Если необходимо создавать отчеты, то будет лучше просто перехватить подключение базы данных и обойти ActiveRecord вместе с ActiveRecord::Base.execute("SELECT * FROM..."). В общем случае "энергичных ассоциаций" будет более чем достаточно. А теперь, перейдем к еще одной острой проблеме для Rails-разработчиков - наследованию.

Наследование и Rails

При первом знакомстве Rails просто очаровывает большинство разработчиков. Это же так просто. Вы просто создаете поле type в таблице базы данных и наследуете любой подкласс от его родителя. А обо всем остальном позаботиться Rails. Например, есть таблица с названием Customer, которая является наследником класса Person. В customer (клиент) есть все поля Person (человек), а так же индекс лояльности и история заказов. В листинге 10 показана вся красота и простота данного решения. В основной таблице есть все поля родительского элемента и все подклассы.

Листинг 10. Реализация наследования

                
create_table "people" do /t/
  t.column "type", :string
  t.column "first_name", :string
  t.column "last_name", :string
  t.column "loyalty_number", :string
end

class Person < ActiveRecord::Base
end

class Customer < Person
  has_many :orders
end

В большинстве случаев данное решение отлично работает. Код элементарен и не повторяется. Запросы достаточно просты и отличаются высокой производительностью, так как нет необходимости в организации соединений для доступа к нескольким подклассам, а для определения возвращаемых записей ActiveRecord может использовать поле type.

Тем не менее, в некоторых случаях наследование ActiveRecord имеет ограничения. Если имеющаяся иерархия наследования слишком широка, то наследование даст сбой. Например, в ChangingThePresent имеется несколько типов контента, у каждого из которых есть название, короткое и подробное описание, некоторые общие атрибуты представления и несколько специальных атрибутов. Необходимо, чтобы категории, некоммерческие проекты, подарки, участники, стимулы, журналы учета и многие другие типы объектов были унаследованы от общего базового класса для того, чтобы можно было одинаково обрабатывать все типы контента. Это невозможно, так как модель Rails будет хранить суть всей объектной модели в одной таблице, что просто нереально.

Обзор альтернатив

Мы поэкспериментировали с тремя возможными решениями данной проблемы. Первое решение - у каждого собственного класса есть собственная таблица, а для построения общей таблицы контента используются представления. Данное решение было отклонено практически сразу же, так как Rails не очень хорошо работает с представлениями базы данных.

Второе решение - использование простого полиморфизма. При организации данного подхода у каждого подкласса есть своя собственная таблица, и в каждую таблицу добавляются одинаковые поля. Например, необходим базовый класс Content, у которого есть только одно свойство name и подклассы Gift, Cause, и Nonprofit. У Gift, Nonprofit, и Cause будет свойство name. Так как в Ruby типы определяются динамически, то можно не создавать их наследование от базового класса. Они должны отвечать на один и тот же набор методов. В ChangingThePresent полиморфизм применяется в нескольких случаях для обеспечения одинакового поведения, в частности, при работе с изображениями.

И третий вариант - это реализация общего свойства, но вместо наследования используются ассоциации. В ActiveRecord есть функция, которая называется "полиморфные ассоциации" и является идеальной для реализации единого поведения класса без использования наследования. Пример полиморфной ассоциации был приведен в примере с Address. Ту же самую технологию можно использовать вместо наследования и для назначения общих атрибутов при управлении контентом. Давайте рассмотрим класс ContentBase. Как правило, для того, чтобы установить ассоциацию между данным классом и еще одним классом, будет использоваться отношение has_one и простой внешний ключ. Однако может возникнуть желание, чтобы ContentBase работал с несколькими классами. Для этого необходим внешний ключ и поле, которое будет определять тип целевого класса. Именно таким образом и работают полиморфные ассоциации ActiveRecord. Взгляните на классы, показанные в листинге 11.

Листинг 11. Обе стороны отношения для контента сайта

                
class Cause < ActiveRecord::Base
  has_one :content_base, :as => :displayable, :dependent => :destroy
  ...
end

class Nonprofit < ActiveRecord::Base
  has_one :content_base, :as => :displayable, :dependent => :destroy
  ...
end


class ContentBase < ActiveRecord::Base
  belongs_to :displayable, :polymorphic => true
end

Как правило, отношение belongs_to относится только к одному классу, но отношение в ContentBase является полиморфным. Во внешнем ключе содержится не только идентификатор для определения записи, но и тип для определения таблицы. Применяя данную технологию, можно воспользоваться всеми преимуществами наследования. Одинаковая функциональность реализована в рамках одного класса. Однако есть и некоторые дополнительные преимущества. Нет никакой необходимости в том, чтобы все поля Cause и Nonprofit находились в одной таблице.

Некоторые администраторы баз данных недолюбливают полиморфные ассоциации потому, что они не используют настоящие внешние ключи, но мы применяем их в ChangingThePresent без каких-либо ограничений. На практике модель данных не будет так изящна, как это описано в теории. Невозможно использовать такие функции базы данных, как поддержание целостности на уровне ссылок, и нельзя воспользоваться механизмами обнаружения отношений на основе названий полей. Однако лично для нас такое достоинство, как простая и понятная объектная модель, перевешивает все проблемы и недостатки данного подхода.

                
create_table "content_bases", :force => true do /t/
  t.column "short_description",          :string

  ...

  t.column "displayable_type", :string
  t.column "displayable_id",   :integer
end

Заключение

ActiveRecord является в высшей степени работоспособной и устойчивой средой. C ее помощью можно построить масштабируемые, надежные системы, однако так же, как и при работе с любой средой баз данных, необходимо уделять внимание SQL-кодам, которые генерируются этой средой. При возникновении какой-либо проблемы необходимо вносить поправки в используемый подход. Индексы, применение ускоренной загрузки при помощи include и использование полиморфных ассоциаций вместо наследования - это всего лишь три способа усовершенствования кода. В следующем месяце я покажу вам еще один практический пример работы с Rails.


Страница сайта http://test.interface.ru
Оригинал находится по адресу http://test.interface.ru/home.asp?artId=10220