Поделиться через


Антишаблон без кэширования

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

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

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

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

Примеры антишаблона с отсутствием кэширования

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

public class PersonRepository : IPersonRepository
{
    public async Task<Person> GetAsync(int id)
    {
        using (var context = new AdventureWorksContext())
        {
            return await context.People
                .Where(p => p.Id == id)
                .FirstOrDefaultAsync()
                .ConfigureAwait(false);
        }
    }
}

Полный пример см. здесь.

Этот антишаблон обычно возникает в следующих случаях:

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

Как устранить антишаблон с отсутствием кэширования

Самые распространенные стратегии кэширования: по запросу или кэш на стороне.

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

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

public class CachedPersonRepository : IPersonRepository
{
    private readonly PersonRepository _innerRepository;

    public CachedPersonRepository(PersonRepository innerRepository)
    {
        _innerRepository = innerRepository;
    }

    public async Task<Person> GetAsync(int id)
    {
        return await CacheService.GetAsync<Person>("p:" + id, () => _innerRepository.GetAsync(id)).ConfigureAwait(false);
    }
}

public class CacheService
{
    private static ConnectionMultiplexer _connection;

    public static async Task<T> GetAsync<T>(string key, Func<Task<T>> loadCache, double expirationTimeInMinutes)
    {
        IDatabase cache = Connection.GetDatabase();
        T value = await GetAsync<T>(cache, key).ConfigureAwait(false);
        if (value == null)
        {
            // Value was not found in the cache. Call the lambda to get the value from the database.
            value = await loadCache().ConfigureAwait(false);
            if (value != null)
            {
                // Add the value to the cache.
                await SetAsync(cache, key, value, expirationTimeInMinutes).ConfigureAwait(false);
            }
        }
        return value;
    }
}

Обратите внимание, что теперь метод GetAsync вызывает класс CacheService вместо непосредственного вызова базы данных. Класс CacheService сначала пытается получить элемент из кэша Redis для Azure. Если значение не найдено в кэше, CacheService вызывает лямбда-функцию, полученную от вызывающей стороны. Лямбда-функция отвечает за получение данных из базы данных. Эта реализация отделяет репозиторий от конкретного решения кэширования и разрывает связь между CacheService и базой данных.

Рекомендации по стратегии кэширования

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

  • Приложения, которые кэшируют динамические данные, должны поддерживать итоговую согласованность.

  • Поддержку кэширования на стороне клиента для веб-API можно включить, добавив заголовок Cache-Control в сообщения запроса и ответа, а также используя теги eTag для идентификации версий объектов. Дополнительные сведения см. в статье API implementation (Реализация API).

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

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

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

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

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

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

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

  • Если отсутствие кэширования является узким местом, то добавление кэширования может увеличить объем запросов настолько, что веб-интерфейс будет перегружен. Клиенты могут начать получать ошибки HTTP 503 (служба недоступна). Это означает, что нужно горизонтально увеличить масштаб внешнего интерфейса.

Как обнаружить шаблона с отсутствием кэширования

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

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

  2. Инструментируйте приложение и выполните мониторинг активной системы, чтобы узнать, как часто приложение получает данные или вычисляет сведения.

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

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

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

Пример диагностики

В следующих разделах эти шаги применяются к примеру приложения, описанному ранее.

Инструментирование приложения и мониторинг активной системы

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

На следующем рисунке показан мониторинг данных, полученных с помощью New Relic во время нагрузочного теста. В этом случае единственной выполняемой операцией HTTP GET является Person/GetAsync. Но в активной рабочей среде на основе относительной частоты выполнения каждого запроса можно понять, какие ресурсы необходимо кэшировать.

Запросы сервера для приложения CachingDemo при использовании New Relic

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

Нагрузочное тестирование приложения

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

Результаты тестовой нагрузки производительности для сценария без кэширования

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

Обзор статистики получения доступа к данным

Статистика получения доступа к данным и другая информация, предоставляемая хранилищем данных, могут содержать полезные сведения (например, какие запросы наиболее часто повторяются). Например, в Microsoft SQL Server административное представление sys.dm_exec_query_stats содержит статистические данные о недавно выполненных запросах. Текст каждого запроса доступен в представлении sys.dm_exec-query_plan. Чтобы запустить следующий SQL-запрос и определить, как часто выполняются запросы, можно использовать средство SQL Server Management Studio.

SELECT UseCounts, Text, Query_Plan
FROM sys.dm_exec_cached_plans
CROSS APPLY sys.dm_exec_sql_text(plan_handle)
CROSS APPLY sys.dm_exec_query_plan(plan_handle)

Столбец UseCount в результатах показывает частоту выполнения каждого запроса. На следующем рисунке показано, что третий запрос выполнялся более 250 000 раз (гораздо больше, чем любой другой запрос).

Результаты запроса динамических административных представлений на сервере управления SQL Server

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

(@p__linq__0 int)SELECT TOP (2)
[Extent1].[BusinessEntityId] AS [BusinessEntityId],
[Extent1].[FirstName] AS [FirstName],
[Extent1].[LastName] AS [LastName]
FROM [Person].[Person] AS [Extent1]
WHERE [Extent1].[BusinessEntityId] = @p__linq__0

Это запрос, который генерирует платформа Entity Framework в методе GetByIdAsync, представленном выше.

Реализация решения по стратегии кэширования и проверка результата

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

Результаты нагрузочного теста производительности для сценария с кэшированием

Количество успешных тестов по-прежнему достигает плато, но при более высокой пользовательской нагрузке. Частота запросов при этой нагрузке значительно больше, чем ранее. Среднее время выполнения теста по-прежнему возрастает по мере увеличения нагрузки, но максимальное время отклика составляет 0,05 мс (по сравнению с 1 мс, что в 20 раз лучше).