Кэширование с Rails: Обзор

Это руководство является введением в ускорение вашего приложения Rails с помощью кэширования.

Кэширование означает хранение контента, генерируемого в цикле запрос-ответ, и повторное использование его при ответе на подобные запросы.

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

Rails предоставляет набор функций кэширования из коробки. Это руководство научит вас областям кэширования и целям каждой области. Освойте эти приемы и ваши Rails приложения смогут обслужить миллионы просмотров без запредельного времени отклика сервера или счетов за сервер.

После прочтения этого руководства, вы узнаете:

  • О кэшировании фрагмента и кэшировании матрешкой (Russian doll caching).
  • Как управлять зависимостями кэширования.
  • Об альтернативных хранилищах кэша.
  • Об условной поддержке GET.

1. Основы кэширования

Это введение в три типа техники кэширования: кэширование страницы, экшна и фрагмента. По умолчанию Rails предоставляет кэширование фрагмента. Чтобы использовать кэширование страницы и экшна, нужно добавить actionpack-page_caching и actionpack-action_caching в свой Gemfile.

По умолчанию кэширование включено только в среде production. Чтобы начать работать с кэшированием локально, нужно включить кэширование в локальной среде, установив config.action_controller.perform_caching в true в соответствующем файле config/environments/*.rb:

config.action_controller.perform_caching = true

Изменение значения config.action_controller.perform_caching повлияет только на кэширование, предоставленное Action Controller. Например, это не повлияет на низкоуровневое кэширование, которое мы рассмотрим ниже.

1.1. Кэширование страницы

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

Кэширование страниц было убрано из Rails 4. Обратитесь к гему actionpack-page_caching.

1.2. Кэширование экшна

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

Кэширование экшна было убрано из Rails 4. Обратитесь к гему actionpack-action_caching. Также взгляните на статью DHH по прекращению кэша, основанного на ключе, как более предпочтительного способа.

1.3. Кэширование фрагмента

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

Кэширование фрагмента позволяет фрагменту логики вьюхи быть обернутым в блок кэша и обслуженным из хранилища кэша для последующего запроса.

Например, если хотите кэшировать каждый продукт на странице, можно использовать этот код:

<% @products.each do |product| %>
  <% cache product do %>
    <%= render product %>
  <% end %>
<% end %>

Когда приложение получит самый первый запрос на эту страницу, Rails запишет новую закэшированную запись с уникальным ключом. Ключ может выглядеть так:

views/products/1-201505056193031061005000/bea67108094918eeba42cd4a6e786901

Число в середине — это product_id, с последующим значением временной метки в атрибуте updated_at записи продукта. Rails использует значение временной метки, чтобы убедиться, что он не отдает устаревшие данные. Если значение updated_at изменится, будет сгенерирован новый ключ. Затем Rails запишет новый кэш с этим ключом, а старый кэш, записанный со старым ключом, больше никогда не будет использован. Это называется прекращением, основанным на ключе.

Фрагменты кэша также могут быть прекращены, если изменяется фрагмент вьюхи (например, если изменяется HTML во вьюхе). Строка символов в конце ключа является дайджестом дерева шаблона. Это MD5 хэш, вычисленный на основе содержимого фрагмента вьюхи, которую вы кэшируете. Если вы измените фрагмент вьюхи, MD5 хэш изменится, прекращая существующий кэш.

Хранилища кэша, такие как Memcached, автоматически удалят старые файлы с кэшем.

Если хотите кэшировать фрагмент по определенным условиям, можно использовать cache_if or cache_unless:

<% cache_if admin?, product do %>
  <%= render product %>
<% end %>

1.3.1. Кэширование коллекции

Хелпер render может также кэшировать отдельные шаблоны, отображающие коллекцию. В рассмотренном ранее примере с each можно считать все кэши шаблонов за один раз, а не по одному. Это делается передавая cached: true при рендеринге коллекции:

<%= render partial: 'products/product', collection: @products, cached: true %>

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

1.4. Кэширование матрешкой

Можно вкладывать кэшированные фрагменты в другие кэшированные фрагменты. Это называется кэшированием матрешкой.

Преимуществом кэширования матрешкой является то, что если обновляется отдельный продукт, другие внутренние фрагменты могут быть повторно использованы при регенерации внешнего фрагмента.

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

Например, возьмем следующую вьюху:

<% cache product do %>
  <%= render product.games %>
<% end %>

Которая, в свою очередь, рендерит эту вьюху:

<% cache game do %>
  <%= render game %>
<% end %>

Если изменится любой атрибут game, у значения updated_at будет установлено текущее время, тем самым прекращая. Однако, так как updated_at не изменится для объекта product, этот кэш не будет прекращен и ваше приложение отдаст устаревшие данные. Чтобы это починить, мы свяжем модели вместе с помощью метода touch:

class Product < ApplicationRecord
  has_many :games
end

class Game < ApplicationRecord
  belongs_to :product, touch: true
end

С помощью touch, установленного в true, любой экшн, изменяющий updated_at для записи game, будет также изменять его для связанного product, тем самым прекращая кэш.

1.5. Кэширование общих партиалов

Существует возможность делиться партиалами и связанным кэшированием между файлами с разными типами mime. Например, кэширование общих партиалов позволяет разработчикам шаблонов делить партиал между файлами HTML и JavaScript. Когда шаблоны собираются в шаблонном распознавателе путей файла, они включают только расширение языка шаблона и не включают тип mime. Из-за этого шаблоны можно использовать для нескольких типов mime. Оба запроса, HTML и JavaScript, будут отвечать на следующий код:

render(partial: 'hotels/hotel', collection: @hotels, cached: true)

Будет загружен файл с именем hotels/hotel.erb.

Другим вариантом является включение полного имени файла партиала для рендеринга.

render(partial: 'hotels/hotel.html.erb', collection: @hotels, cached: true)

Будет загружен файл с именем hotels/hotel.html.erb в любом типе файла mime, например, можно включить этот партиал в файл JavaScript.

1.6. Управление зависимостями

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

1.7. Неявные зависимости

Большинство зависимостей шаблонов могут быть вычислены из вызовов render в самом шаблоне. Вот несколько примеров вызовов render, которые ActionView::Digestor знает как понять:

render partial: "comments/comment", collection: commentable.comments
render "comments/comments"
render 'comments/comments'
render('comments/comments')

render "header" переводится в render("comments/header")

render(@topic)         переводится в render("topics/topic")
render(topics)         переводится в render("topics/topic")
render(message.topics) переводится в render("topics/topic")

С другой стороны, некоторые вызовы нужно изменить, чтобы кэширование работало верно. Например, если вы передаете нестандартную коллекцию, вам нужно изменить:

render @project.documents.where(published: true)

на:

render partial: "documents/document", collection: @project.documents.where(published: true)

1.8. Явные зависимости

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

<%= render_sortable_todolists @project.todolists %>

Вам необходимо использовать специальный формат комментариев для вызова их извне:

<%# Template Dependency: todolists/todolist %>
<%= render_sortable_todolists @project.todolists %>

В некоторых случаях, например, при установке наследования в одной таблице (STI, single table inheritance), вы можете иметь кучу явных зависимостей. Вместо написания каждого шаблона, вы можете использовать знак звездочку, чтобы подходил любой шаблон из каталога:

<%# Template Dependency: events/* %>
<%= render_categorizable_events @person.events %>

Как и для кэширования коллекций, если партиал начинается не с явного вызова кэша, вы все-таки можете извлечь выгоду кэширования коллекций, добавив специальный формат комментария в любом месте шаблона, наподобие:

<%# Template Collection: notification %>
<% my_helper_that_calls_cache(some_arg, notification) do %>
  <%= notification.name %>
<% end %>

1.9. Внешние зависимости

Если вы используете метод хелпера, например, внутри кэшируемого блока, и затем обновляете хелпер, вам также нужно будет удалить кэш. Неважно как вы сделаете это, но MD5 файла шаблона должен измениться. Одна из рекомендаций, явно указать в комментарии, наподобие:

<%# Helper Dependency Updated: Jul 28, 2015 at 7pm %>
<%= some_helper_method(person) %>

1.10. Низкоуровневое кэширование

Иногда хочется закэшировать определенное значение или результат запроса вместо кэширования фрагментов вьюх. Механизм кэширования Rails отлично работает для хранения информации любого рода.

Наиболее эффективным способом реализации низкоуровневого кэширования является использование метода Rails.cache.fetch. Этот метод и читает, и пишет в кэш. Если передать только один аргумент, этот ключ извлекается и возвращается значение из кэша. Если передан блок, этот блок будет выполнен в случае отсутствия кэша. Возвращаемое значение блока будет записано в кэш под заданным ключом кэша, и это возвращаемое значение будет возвращено. В случае наличия кэша, будет возвращено закэшированное значение без выполнения блока.

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

class Product < ApplicationRecord
  def competing_price
    Rails.cache.fetch("#{cache_key}/competing_price", expires_in: 12.hours) do
      Competitor::API.find_price(id)
    end
  end
end

Отметьте, что в этом пример мы использовали метод cache_key, таким образом результирующий ключ кэша будет выглядеть наподобие products/233-20140225082222765838000/competing_price. cache_key генерирует строку на основе атрибутов id и updated_at модели. Это обычное соглашение, имеющее преимущество невалидности кэша, когда изменяется продукт. В основном при использовании низкоуровневого кэширования для информации на уровне экземпляра модели, необходимо генерировать ключ кэша.

1.11. Кэширование SQL

Кэширование запроса - это особенность Rails, кэширующая результат выборки по каждому запросу. Если Rails встретит тот же запрос (query) на протяжения текущего запроса (request), он использует кэшированный результат, вместо того, чтобы снова сделать запрос к базе данных.

Например:

class ProductsController < ApplicationController

  def index
    # Запускаем поисковый запрос
    @products = Product.all

    ...

    # Снова запускаем тот же запрос
    @products = Product.all
  end

end

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

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

2. Хранилища кэша

Rails предоставляет различные хранилища для кэшированных данных (кроме SQL кэширования и кэширования страниц).

2.1. Конфигурация

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

config.cache_store = :memory_store, { size: 64.megabytes }

Альтернативно можно вызвать ActionController::Base.cache_store вне конфигурационного блока.

К кэшу можно получить доступ, вызвав Rails.cache.

2.2. ActiveSupport::Cache::Store

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

Главные вызываемые методы это read, write, delete, exist? и fetch. Метод fetch принимает блок и либо возвращает существующее значение из кэша, либо вычисляет блок и записывает результат в кэш, если значение не существует.

Имеется несколько общих опций, используемых всеми реализациями кэша. Они могут быть переданы в конструктор или в различные методы для взаимодействия с записями.

  • :namespace - Эта опция может быть использована для создания пространства имен в хранилище кэша. Она особенно полезна, если приложение разделяет кэш с другим приложением.

  • :compress - Эта опция может быть использована для указания, что в кэше должно быть использовано сжатие. Это особенно полезно для передачи огромных записей кэша по медленной сети.

  • :compress_threshold - Эта опция используется в сочетании с опцией :compress для указания порога, до которого записи кэша не будут сжиматься. По умолчанию 16 килобайт.

  • :expires_in - Эта опция устанавливает время прекращения в секундах для записи кэша, когда она будет автоматически убрана из кэша.

  • :race_condition_ttl - Эта опция используется в сочетании с опцией :expires_in. Она предотвращает гонку условий при прекращении записи кэша, предотвращая несколько процессов от одновременного регенерировать одной и той же записи (также известного как dog pile effect). Эта опция устанавливает количество секунд, в течение которых прекращенная запись кэша может использоваться, пока не будет регенерирована новая запись. Считается хорошей практикой установить это значение, если используется опция :expires_in.

2.3. Произвольные хранилища кэша

Можно создать свое собственно хранилище кэша, просто расширив ActiveSupport::Cache::Store и реализовав соответствующие методы. Таким образом можно применить несколько кэширующих технологий в вашем приложении Rails.

Для использования произвольного хранилища кэша просто присвойте хранилищу кэша новый экземпляр класса.

config.cache_store = MyCacheStore.new

2.4. ActiveSupport::Cache::MemoryStore

Это хранилище кэша хранит записи в памяти в том же процессе Ruby. У хранилища кэша ограниченный размер, определенный опцией :size, указанной в инициализаторе (по умолчанию 32Mb). Когда кэш превышает выделенный размер, происходит очистка и самые ранние используемые записи будут убраны.

config.cache_store = :memory_store, { size: 64.megabytes }

Если запущено несколько серверных процессов Ruby on Rails (что бывает в случае использования Phusion Passenger или puma в кластерном режиме), то экземпляры ваших серверов Rails не смогут разделять данные кэша друг с другом. Это хранилище кэша не подходит для больших приложений. Однако, оно замечательно работает с небольшими сайтами с низким трафиком, с несколькими серверными процессами, или для сред development и test.

Новые проекты Rails настроены для использования этой реализации в development среде по умолчанию.

Поскольку процессы не делятся данными кэша при использовании :memory_store, то невозможно вручную считывать, записывать или очищать кэш через консоль Rails.

2.5. ActiveSupport::Cache::FileStore

Это хранилище кэша использует файловую систему для хранения записей. Путь к директории, в которой будут храниться файлы, должен быть определен при инициализации кэша.

config.cache_store = :file_store, "/path/to/cache/directory"

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

Так как кэш будет расти, пока не заполнится диск, рекомендуется периодически чистить старые записи.

Это реализация хранилища кэша по умолчанию (хранится в "#{root}/tmp/cache/"), если config.cache_store явно не указан.

2.6. ActiveSupport::Cache::MemCacheStore

Это хранилище кэша использует сервер Danga's memcached для предоставления централизованного кэша вашему приложению. Rails по умолчанию использует встроенный гем dalli. Сейчас это наиболее популярное хранилище кэша для работающих веб-сайтов. Оно представляет отдельный общий кластер кэша с очень высокими производительностью и резервированием.

При инициализации кэша необходимо указать адреса для всех серверов memcached в вашем кластере. Если ни один не определен, предполагается, что memcached запущен на localhost на порте по умолчанию, но это не идеальная настройка для больших сайтов.

Методы write и fetch на кэше принимают две дополнительных опции, дающие преимущества особенностей memcached. Можно определить :raw для отправки значения на сервер без сериализации. Значение должно быть строкой или числом. Прямые операции memcached, такие как increment и decrement, можно использовать только на значениях raw. Также можно определить :unless_exist, если не хотите, чтобы memcached перезаписал существующую запись.

config.cache_store = :mem_cache_store, "cache-1.example.com", "cache-2.example.com"

2.7. ActiveSupport::Cache::NullStore

Эта реализация хранилища кэша предполагает использование только в средах development или test, и никогда ничего не хранит. Это может быть полезным при разработке, когда у вас имеется код, взаимодействующий непосредственно с Rails.cache, но кэширование может препятствовать способности видеть результат изменений в коде. С помощью этого хранилища кэша все операции fetch и read приведут к отсутствующему результату.

config.cache_store = :null_store

3. Ключи кэша

Ключи, используемые в кэше могут быть любым объектом, отвечающим либо на cache_key, либо на to_param. Можно реализовать метод cache_key в своем классе, если необходимо сгенерировать обычные ключи. Active Record генерирует ключи, основанные на имени класса и id записи.

Как ключи хэша можно использовать хэши и массивы.

# Это правильный ключ кэша
Rails.cache.read(site: "mysite", owners: [owner_1, owner_2])

Ключи, используемые на Rails.cache не те же самые, что фактически используются движком хранения. Они могут быть модифицированы пространством имен, или изменены в соответствии с ограничениями технологии. Это значит, к примеру, что нельзя сохранить значения с помощью Rails.cache, а затем попытаться вытащить их с помощью гема memcache-client. Однако, также не стоит беспокоиться о превышения лимита memcached или несоблюдении правил синтаксиса.

4. Поддержка GET с условием (Conditional GET)

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

Это работает с использованием заголовков HTTP_IF_NONE_MATCH и HTTP_IF_MODIFIED_SINCE для передачи туда-обратно уникального идентификатора контента и временной метки, когда содержимое было последний раз изменено. Если браузер делает запрос, в котором идентификатор контента (etag) или временная метка последнего изменения соответствует версии сервера, то серверу всего лишь нужно вернуть пустой отклик со статусом not modified.

Это обязанность сервера (т.е. наша) искать временную метку последнего изменения и заголовок if-none-match, и определять, нужно ли отсылать полный отклик. С поддержкой conditional-get в Rails это очень простая задача:

class ProductsController < ApplicationController

  def show
    @product = Product.find(params[:id])

    # Если запрос устарел в соответствии с заданной временной меткой или значением
    # etag (т.е. нуждается в обработке снова), тогда запускаем этот блок
    if stale?(last_modified: @product.updated_at.utc, etag: @product.cache_key)
      respond_to do |wants|
        # ... обычное создание отклика
      end
    end

    # Если запрос свежий (т.е. не изменился), то не нужно ничего делать
    # Рендер по умолчанию проверит это, используя параметры,
    # использованные в предыдущем вызове stale?, и автоматически пошлет
    # :not_modified. И на этом все.
  end
end

Вместо хэша опций можно просто передать модель, Rails будет использовать методы updated_at и cache_key для настройки last_modified и etag:

class ProductsController < ApplicationController
  def show
    @product = Product.find(params[:id])

    if stale?(@product)
      respond_to do |wants|
        # ... обычное создание отклика
      end
    end
  end
end

Если отсутствует специальная обработка отклика и используется дефолтный механизм рендеринга (т.е. вы не используете respond_to или вызываете сам render), то можете использовать простой хелпер fresh_when:

class ProductsController < ApplicationController

  # Это автоматически отошлет :not_modified, если запрос свежий,
  # и отрендерит дефолтный шаблон (product.*), если он устарел.

  def show
    @product = Product.find(params[:id])
    fresh_when last_modified: @product.published_at.utc, etag: @product
  end
end

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

По умолчанию кэшированные отклики будут приватными, закэшированными только для браузера пользователя. Чтобы разрешить прокси кэшировать отклик, установите public: true чтобы обозначить, что они могут отдавать кэшированный отклик всем браузерам.

При использовании этого хелпера, заголовок last_modified устанавливается Time.new(2011, 1, 1).utc, и заголовок expires устанавливается 100 лет.

Используйте этот метод осторожно, так как браузер/прокси будут не в состоянии инвалидировать кэшированный отклик до тех пор, пока кэш браузера будет принудительно очищен.

class HomeController < ApplicationController
  def index
    http_cache_forever(public: true) do
      render
    end
  end
end

4.1. Сильные против слабых ETag

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

У слабых ETags имеется начальный W/, чтобы отличить их от сильных ETag.

  W/"618bbc92e2d35ea1945008b42799b0e7" → Слабый ETag
  "618bbc92e2d35ea1945008b42799b0e7" → Сильный ETag

В отличие от слабых ETag, сильные ETag подразумевают, что отклик должен быть в точности идентичным, каждый байт. Полезно, когда делается ряд запросов для больших файлов видео или PDF. Некоторые CDNs поддерживают только сильные ETag, такие как Akamai. Если вам абсолютно необходимо генерировать сильные ETag, это можно сделать следующим образом.

  class ProductsController < ApplicationController
    def show
      @product = Product.find(params[:id])
      fresh_when last_modified: @product.published_at.utc, strong_etag: @product
    end
  end

Также можно установить сильный ETag непосредственно на отклике.

  response.strong_etag = response.body # => "618bbc92e2d35ea1945008b42799b0e7"

5. Кэширование в development

Обычно требуется протестировать стратегию кэширования вашего приложения в development режиме. Rails предоставляет задачу rake dev:cache, чтобы легко включать и выключать кэширование.

$ bin/rails dev:cache
Development mode is now being cached.
$ bin/rails dev:cache
Development mode is no longer being cached.

6. Ссылки