Взлеты и падения технологии кеширования

За годы разработки продуктов в Amazon мы снова и снова разыгрывали разные варианты одного и того же сценария: команда создает новый сервис, и он должен совершать сетевые вызовы для удовлетворения своих запросов. Эти вызовы могут предназначаться реляционной базе данных или сервису AWS, например Amazon DynamoDB, либо другому внутреннему сервису. Во время простых тестов или при низком количестве запросов сервис работает превосходно. Проблемы начинаются позже. Причины могут быть разными: скорость вызова к другому сервису очень низкая или масштабировать базу данных под возрастающие запросы слишком дорого. Мы также заметили, что многие вызовы используют одинаковые подчиненные ресурсы или результаты выполнения. Поэтому нам и пришла в голову идея кэшировать такие данные, чтобы избежать проблем в будущем. После того как мы добавили кэш в свои сервисы, их продуктивность значительно возросла. В частности, задержка вызовов уменьшилась, расходы снизились, а доступность малых подчиненных ресурсов улучшилась. Теперь уже сложно и вспомнить, как мы вообще справлялись без кэша. Зависимые структуры уменьшили размеры своих групп кэширования, а базы данных – свой масштаб. Казалось, что технология сервисов налажена и работает хорошо. Но раз за разом мы сталкивались с новыми проблемами. Схемы трафика могли измениться внезапно, группы кэша переставали работать, возникали многие другие непредвиденные трудности, в следствие которых кэш становился «холодным», попросту говоря, плохо заполненным данными, или недоступным. Такие проблемы влекли за собой резкие всплески трафика в подчиненных сервисах, что приводило к перебоям в работе как зависимых структур, так и наших сервисов.

Мы только что привели пример типичного сервера, который на 100 % зависит от своего кэширования. Кэш случайно превратился из просто полезной функции сервиса в критически необходимую его часть, от которой зависит вся работоспособность. Вся загвоздка заключается в модальном поведении кэша, которое разнится в зависимости от того, кэшируется ли данный объект. Непредвиденные скачки в таком поведении могут потенциально привести к большим проблемам.

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

Когда нужно использовать кэширование

Когда вы принимаете решение о добавлении кэша в свою систему, нужно учитывать несколько факторов. Чаще всего сначала мы анализируем задержки зависимых структур или эффективность работы при текущей частоте запросов. Например, мы определяем, что зависимая структура начинает уходить в троттлинг (ограничивать нагрузку) или больше не способна поддерживать ожидаемый уровень нагрузки в следствие других факторов. Наша команда считает, что кэширование полезно, когда система сталкивается с неравномерными схемами запросов, которые приводят к ограничению нагрузки в «горячих» точках или секциях. Данные из этой зависимости действительно следует кэшировать, если после этого улучшится коэффициент попадания в кэш для всех запросов. Так результаты вызовов к зависимой структуре могут использоваться для множества запросов или операций. Если для каждого запроса обычно требуется уникальный вызов к зависимому сервису с уникальными результатами на один запрос, то кэш будет иметь низкий коэффициент попадания и станет бесполезным. Второй важный фактор – насколько хорошо команда сервиса и его клиенты могут адаптироваться к потенциальной непротиворечивости. Кэшируемые данные обязательно со временем будут противоречить источнику, поэтому кэширование может быть успешным только в том случае, если сервис и его клиенты будут компенсировать эти несоответствия. Скорость изменения исходных данных, а также правила кэширования для их обновления будут определять, насколько противоречивыми окажутся данные. Эти два пункта взаимосвязаны. Например, относительно статичные или медленно меняющиеся данные могут кэшироваться в течение более длительных периодов времени.

Локальные кэши

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

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

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

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

Внешний кэш

С помощью внешнего кэша можно решить многие из перечисленных выше проблем. Внешний кэш хранит кэшированные данные в отдельном парке с использованием, например, Memcached или Redis. Согласованность кэшированных данных улучшается, поскольку внешний кэш содержит значение, используемое всеми серверами в группах. (Учтите, что проблема не исчезает полностью, поскольку могут возникнуть ошибки при обновлении кэша.) Общая нагрузка на подчиненные сервисы сокращается в сравнении с кэшем в памяти, а также становится непропорциональной размерам групп. Проблемы с «холодным» запуском во время таких событий, как развертывания, отсутствуют, поскольку внешний кэш остается заполненным на протяжении всего процесса. Наконец, внешний кэш предоставляет больше места для хранения, чем кэш в памяти, уменьшая количество случаев вытеснения из-за ограничений пространства.

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

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

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

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

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

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

Сравнение линейного кэширования с кэшированием на стороне

Во время анализа различных подходов также необходимо рассмотреть линейное кэширование и кэширование на стороне. Линейное кэширование, т. е. кэширование с возможностью чтения и записи, позволяет встраивать функции управления кэшем в главный API доступа к данным. В качестве примера можно указать реализацию для определенных приложений Amazon DynamoDB Accelerator (DAX) или реализацию с использованием стандартов, такую как кэширование HTTP (с локальным клиентом кэширования или внешним сервером кэширования, таким как Nginx или Varnish). Кэширование на стороне – это хранилища обобщенных объектов, таких как в Amazon ElastiCache (Memcached и Redis), или библиотеки, как Ehcache и Google Guava для внутренней кэш-памяти. С помощью кэширования на стороне код приложения напрямую управляет кэшем после вызовов, сделанных к источнику данных, и перед ними. Он проверяет объекты кэширования перед тем, как выполнить выходящий вызов, и помещает объекты в кэш после того, как вызов завершится.

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

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

Окончание срока действия кэша

Одной из самых трудных задач во время настройки кэширования является выбор размера кэша, определение политики окончания срока действия и вытеснения данных. Политика окончания срока действия определяет продолжительность размещения объекта в кэше. Наиболее распространенное правило использует абсолютное значение времени для определения этого периода, т. е. оно устанавливает время жизни (TTL) объекта при его загрузке. Значение TTL устанавливается согласно запросу клиента. Например, можно регулировать правила для устаревших и статических данных, поскольку кэширование медленно меняющихся данных требует больших ресурсов. Чтобы выбрать идеальный размер кэша, необходимо знать ожидаемое количество запросов и понимать механизм распределения сохраненных в кэше объектов с помощью этих запросов. На основе этих параметров мы рассчитываем размер кэша, чтобы обеспечить высокую частоту попаданий в кэш с учетом этой схемы передачи данных. Политика вытеснения данных контролирует процесс удаления объектов из кэша, когда он переполняется. Чаще всего к данным применяется правило «дольше всего не используется» (LRU).

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

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

Чтобы улучшить отказоустойчивость, когда целевые сервисы недоступны, мы применяем два значения TTL: «мягкое» и «жесткое». «Мягкое» значение применяется, если клиент пытается обновить объекты, сохраненные в кэше, а целевой сервис недоступен или не отвечает на запросы. В этом случае данные, которые уже находятся в кэше, будут использоваться, пока не будет достигнуто «жесткое» значение TTL. Данный механизм применяется в работе клиента AWS Identity and Access Management (IAM).

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

Другие факторы

Важно учитывать, как будет работать кэш при получении ошибок от целевого сервиса. Один из способов решить эту проблему – отвечать клиентам, используя последние правильные значения из кэша, например «мягкие» и «жесткие» значения TTL, как это было описано выше. Также мы кэшируем сообщение об ошибке («негативный кэш»). При этом мы используем значение TTL вместо позитивных записей в кэш и распространяем ошибку для клиента. Выбор подхода зависит от конкретной ситуации и особенностей сервиса. Мы решаем, что лучше для клиента: видеть устаревшие данные или сообщения об ошибках. В любом случае при сбое кэш должен быть доступен и содержать данные. Если целевой сервис недоступен или не может обрабатывать определенные запросы (например, если удален целевой источник), подготовительные сервисы продолжат передавать данные и потенциально могут вызвать сбой или усугубить текущие неполадки. Наш опыт показывает, что неспособность кэшировать отрицательные ответы ухудшала производительность и вызывала сбои.

Важный аспект кэширования – безопасность. Когда мы настраиваем кэширование для сервиса, мы учитываем все возможные риски и предпринимаем меры по их предотвращению. Например, внешние серверы кэширования не используют кодирование для сериализованных данных и средства защиты для передачи данных. Это важно учитывать, если в кэше хранится конфиденциальная информация пользователей. В этом случае можно использовать Amazon ElastiCache for Redis с поддержкой шифрования данных при передачи и хранении. Серверы кэширования часто подвергаются атакам, созданным путем подделки записей кэша. Уязвимость протокола передачи данных позволяет злоумышленникам наполнить кэш необходимыми им значениями. Это усиливает воздействие атаки: при наличии в кэше данного значения для всех запросов будет отображаться значение злоумышленника. Кроме того, кэширование также подвержено атакам по времени по сторонним каналам. Кэшированные данные возвращаются быстрее, чем некэшированные, поэтому злоумышленник может использовать время ответа, чтобы получить информацию о запросах, которые делают другие клиенты или участники.

В заключение о ситуации, возникающей, когда несколько клиентов делают запросы, для которых требуется один и тот же ресурс для обработки некэшеруемых данных почти в одно и то же время. Это также может произойти, если сервер подключается к группе с незаполненным локальным кэшем. В результате возникает подчиненная зависимость обработки большого количества запросов для различных серверов, что может привести к ограничению производительности или отключению. Чтобы устранить эту проблему, мы используем объединение запросов: серверы или внешний кэш позволяют сделать только один запрос на рассмотрение для некэшируемых ресурсов. Некоторые кэш-библиотеки и серверы кэширования (например, Nginx или Varnish) поддерживают объединение запросов. Объединение запросов также может быть реализовано вместе с существующими кэшами. 

Рекомендации и ограничения Amazon

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

• Убедитесь в обоснованности использования кэширования. Как оно согласуется с затратами, задержками и/или доступностью. Проверьте возможность кэширования: убедитесь, что данные пригодны для использования при обработке нескольких клиентских запросов. Скрупулезно оцените преимущества и возможные риски использования кэша.
• Тщательно спланируйте использование кэширования, соблюдая такие же жесткие требования и придерживаясь тех же процессов, что и для остального комплекта услуг и инфраструктуры. Не стоит недооценивать эти меры. Сопоставьте данные использования кэша и частоты попаданий, чтобы проверить правильность настроек кэширования. Следите за ключевыми параметрами (например, ЦПУ и памятью), чтобы обеспечить надлежащую работу и масштабирование внешний платформы для кэширования. Настройте предупреждения для этих параметров. Убедитесь, что группа кэширования может быть увеличена без простоя или массовой инвалидации кэша (проверьте правильность работы функции согласованного хэширования).
• Взвешенно и последовательно выберите размер кэша и определите политики окончания срока действия и вытеснения данных. Для их проверки и настройки проведите тестирование и используйте показатели, указанные в предыдущем пункте.
• Проверьте отказоустойчивость сервиса в случае некорректной работы кэша, возможной при возникновении условий, которые приводят к проблемам обработки запросов с использованием кэшированных данных. Это могут быть «холодные» запуски, перебои в работе групп кэширования, изменения в схемах трафика или продолжительные отключения подчиненных сервисов. Во многих случаях это приводит к частичной потере доступности для обеспечения непрерывной работы ваших и подчиненных сервисов (например, за счет сброса нагрузки, ограничения запросов к зависимым сервисам или обслуживания устаревших данных). Для проверки запустите тесты на нагрузку с отключенными кэшами.
• Оцените аспекты безопасности обслуживания кэшированных данных, включая шифрование, безопасность пересылки при обмене данными с внешними группами кэширования, а также воздействие атак с отравлением кэша и атак по сторонним каналам.
• Разработайте формат хранения кэшируемых объектов с изменениями с течением времени (например, используйте номер версии) и напишите код преобразования в последовательную форму, способный читать более старые версии. Остерегайтесь попадания подделок записей кэша в механизм сериализации.
• Оцените обработку кэшем ошибок подчиненных сервисов, а также рассмотрите вопрос хранения негативного кэша с определенным временем жизни. Не вызывайте и не усугубляйте сбои в работе, неоднократно запрашивая один и тот же подчиненный ресурс и сбрасывая ответы об ошибках.

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


Об авторах

Мэтт – главный инженер отдела новых устройств Amazon. Он занимается разработкой ПО и сервисов для потребительских устройств, которые еще не вышли на рынок. Ранее он работал в AWS Elemental. Возглавляемая Мэттом команда запустила MediaTailor, сервис на стороне сервера для вставки индивидуально настроенных объявлений во время показа прямых трансляций и видео по требованию. Кроме того, он участвовал в запуске первого сезона трансляций NFL Thursday Night Footbal на сервисе Prime Video. До Amazon Мэтт 15 лет проработал в индустрии безопасности (в том числе в таких компаниях, как McAfee и Intel, а также в нескольких стартапах), занимаясь разработками в области управления корпоративной безопасностью и технологий для защиты от вредоносных программ, устранением уязвимостей ПО, аппаратной поддержкой безопасности и DRM.

Джас Чхабра – главный инженер в AWS. Он стал членом команды AWS в 2016 году. До этого Джас несколько лет работал над AWS IAM, а потом сменил специализацию, и теперь его деятельность связанна с AWS Machine Learning. До AWS он работал на различных технических должностях в Intel, связанных с системами контроля, идентификацией и безопасностью. Текущая сфера деятельности: машинное обучение, безопасность и крупномасштабные распределенные системы. Бывшая сфера деятельности: системы контроля, биткойны, идентификация и криптография. Имеет ученую степень в области информатики.

Работа распределенных систем без необходимости откатов Сброс нагрузки во избежание перегрузок Обеспечение безопасности отката во время развертывания