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

<% for author in @authors %>
  <%= author.name %> has written
  <%= author.books.count %> books.<br>
<% end %>

Изменяя author.books.count на author.books_count и используя кеш счетчика в своих моделях, вы избегаете выполнения SQL-запроса COUNT для каждой строки. Никаких вычислений, ни лишнего SQL, просто простое чтение атрибута в записи автора при выводе вашего списка.

Поддержание этого подсчета также очень эффективно. В классе Book устанавливаются ловушки, которые с помощью простого оператора SQL UPDATE увеличивают или уменьшают значение поля кэша счетчика. Он даже не пересчитывает общую сумму, а просто увеличивается или уменьшается на единицу. Кроме того, этот удар полностью выполняется БД. Поговорим об эффективности!

Затраты на сложность кешей счетчиков

Хотя все это замечательно эффективно, это не обходится без сложных затрат.

  • Это требует добавления нового поля в схему.
  • Вы должны написать код для инициализации значения для существующих данных.
  • Он использует обратные вызовы ActiveRecord для запуска этого увеличения счетчика. Это добавляет веса вашим моделям даже при работе с разделами приложения, не связанными с отображением счетчиков (например, тесты).
  • Он может рассинхронизироваться из-за простой стратегии обновления. Я видел, как это происходило как из-за ошибок в Rails (теперь исправленных), так и из-за ошибок в приложениях.
  • Это довольно простая реализация, означающая, что она не может поддерживать такие вещи, как условные подсчеты, многоуровневые кеши счетчиков, суммирование вместо подсчета и т. Д. Гем counter_culture выполняет эти функции, но это добавляет еще больше сложности.
  • В зависимости от характера вашего приложения, кеш-счетчик может сильно загружаться. Если дочерние модели создаются / уничтожаются / обновляются часто, мы постоянно меняем кеш (возможно, даже чаще, чем нам действительно нужно читать кеш). Если ваше приложение восприимчиво к этому, вы можете получить больше конфликтов блокировок в вашей БД, чем хотелось бы. Записи могут увеличить нагрузку на вашу БД, чем вы получаете от простого чтения.

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

Альтернатива SQL

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

class Author < ApplicationRecord
  scope :with_counts, -> {
    select <<~SQL
      authors.*,
      (
        SELECT COUNT(books.id) FROM books
        WHERE author_id = authors.id
      ) AS books_count
    SQL
  }
end

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

@authors = Author.with_counts.all

На ваш взгляд, поместите тот же код, что и реализация кеша счетчика (т.е. считайте из books_count объекта автора, а не из books.count).

Преимущества

По сути, мы все еще выполняем подсчет для каждой строки, но мы позволяем БД делать это при извлечении списка. Не так эффективен, как кеш счетчика, но все же выполняется очень быстро. За это небольшое увеличение сложности чтения мы получаем:

  • нет нового поля
  • нет AR-хуков, поддерживающих данные
  • всегда синхронно и в реальном времени
  • нет записей
  • только затраты на производительность при фактическом использовании счетчика (т.е. без затрат на обратный вызов)

Хотя прицел может показаться немного сложным, на самом деле это не так. Думаю, достоинства выше стоят.

Доступ к отдельной записи

Использование SQL для счетчика отлично подходит для списка. Как насчет того, чтобы отобразить только одну запись и количество с ней? I.E. пользователь нашел элемент в списке и хочет развернуть его. Вы также хотите отобразить счетчик в подробной записи.

Ваш первый вариант - просто выполнить несколько запросов, поместив в свое представление следующее:

@author.books.count

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

@author = Author.find params[:id]

Вы хотите использовать:

@author = Author.with_counts.find_by id: params[:id]

Как и в случае со списком, мы все еще проводим подсчет, но мы делаем это с загрузкой автора и заставляем БД сделать это, что очень быстро.

Более сложные подсчеты

В дополнение к преимуществам подхода SQL мы также можем легко реализовать более сложные подсчеты. Хотите вывести общее количество страниц, написанных автором (например, SUM вместо COUNT)? Просто настройте SQL:

scope :with_counts, -> {
  select <<~SQL
    authors.*,
    (
      SELECT SUM(pages) FROM books
      WHERE author_id = authors.id
    ) AS pages_total
  SQL
}

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

scope :with_counts, -> {
  select <<~SQL
    publishers.*,
    (
      SELECT COUNT(books.id)
      FROM books JOIN authors ON books.author_id = authors.id
      WHERE authors.publisher_id = publishers.id
    ) AS books_count
  SQL
}

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

Эффективность

Ключевой целью кеширования счетчика является эффективность. Насколько эффективно мы теряем с помощью динамической области видимости счетчика на основе SQL? Рассмотрим три ситуации:

  • Умеренные наборы данных
  • Наборы данных в большом масштабе
  • Использование в реальном мире

Приведенный ниже сценарий будет использоваться для генерации соответствующих данных:

AUTHOR_COUNT = 100
BOOKS_PER_AUTHOR = (1..15).to_a
ActiveRecord::Base.transaction do
  AUTHOR_COUNT.times do
    books_count = BOOKS_PER_AUTHOR.sample
    author_id = Author.connection.insert <<-SQL
      INSERT INTO authors (books_count)
      VALUES (#{books_count})
    SQL
    Book.connection.execute <<-SQL
      INSERT INTO books (author_id)
      VALUES #{("(#{author_id})," * books_count).chop}
    SQL
    print '.'
  end
end
puts

Изменяя константы, мы можем переключаться между «умеренными» и «крупномасштабными» наборами данных. Начнем с умеренного (100 авторов по 1–15 книг).

Счетчик производительности кеша

Во-первых, давайте посмотрим, сколько времени уходит на запрос только моделей авторов (все, что может потребоваться при использовании традиционного кеша счетчика). Обычно на моей машине БД выполняла запрос примерно за 0,7 мс.

Динамический подсчет с помощью подзапроса SQL

Стратегия SQL обычно занимала на моей машине около 1,2 мс. Это увеличение на 70%, но мы все еще говорим о дополнительных 0,5 мс. Каждая миллисекунда на счету, когда пытаешься добраться до стекла за 100 мс, но я думаю, что для многих приложений дополнительные полмиллисекунды стоят преимуществ использования динамического подсчета.

Крупномасштабные

Для крупномасштабного теста представим себя Amazon с 500 000 авторов, у каждого из которых от 1 до 50 книг. В этом сценарии реализация кэша базового счетчика на моем компьютере обычно занимает около 150–200 мсек, в то время как динамическая версия занимает около 3,7 с. Ой! Неужели наша динамическая версия обречена на большие масштабы? Я так не думаю. Давай перейдем в реальный мир.

Реальный мир

В большинстве случаев при отображении информации на веб-странице мы не выводим все записи в БД (даже в большом масштабе). Информация фильтруется, разбивается на страницы и т. Д. Хотя у нас может быть 500 000 авторов с 1–50 книгами в каждой, мы можем отображать только 25 из этих авторов одновременно. Предложение SQL LIMIT, добавленное, например, разбиением на страницы, означает, что БД необходимо выполнить подзапрос только для выбранных документов.

Подобная разбивка на страницы означает, что даже с крупномасштабной базой данных это занимает всего около 1 мс (по сравнению с 0,4 мс с использованием кеша счетчика), а для умеренных наборов данных это занимает всего около 0,7 мс (по сравнению с примерно 0,4 мс с использованием кеша счетчика). ИМХО, версия с динамическим SQL в большинстве ситуаций может работать даже в больших масштабах.

Работа на счетах

Есть одна ключевая область, где SQL-подход терпит неудачу, поскольку он работает с подсчетом. Например, показать всех авторов с более чем 10 книгами (т.е. подзапрос находится в предложении WHERE вместо SELECT). Вы можете сделать это с помощью:

scope :published_at_least, ->(min) {
  subquery = <<~SQL
    SELECT COUNT(books.id) FROM books
    WHERE author_id = authors.id
  SQL
  where "(#{subquery}) >= ?", min
}

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

scope :by_proliferantness, -> {
  subquery = <<~SQL
    SELECT COUNT(books.id) FROM books
    WHERE author_id = authors.id
  SQL
  order "(#{subquery}) DESC"
}

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

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

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