Введение в проблему; Обзор устройства кэширования бэкeнда ЛК; Что такое эффективность кэширования?; Анализ эффективности; Мероприятия для увеличения эффективности и их результаты. БД – база данных; Биллинг - комплекс процессов и решений, ответственных за сбор информации об использовании телекоммуникационных услуг, их тарификацию,выставление счетов абонентам, обработку платежей; Hazelcast – in-memory БД; Prometheus - это база данных временных рядов. Используется для хранения и получения метрик; Grafana – платформа для визуализации, анализа данных и мониторинга; ДЦ – дата центр; Кросс-ДЦ – взаимодействие между дата центрами; Cache region – название map в hazelcast. По мере расширения функциональности сервиса и роста его аудитории мы неизбежно сталкиваемся с узкими местами в производительности. Прежде чем масштабировать ресурсы для эксплуатации, следует понять, насколько эффективно эксплуатируется текущая конфигурация.  Одним из таких узких мест может стать ваше распределенное хранилище для кэша. Все мы привыкли к тому, что оно нас спасает от тяжелых запросов в БД или обращенийк внешним системам с большой задержкой. Но рано или поздно может возникнуть ситуация, когда конфигурация этого хранилища будет на грани своей оптимальной производительности и в случае высоких нагрузок (аварий, спровоцированных наплывом пользователей или рекламными кампаниями) хранилище может подвести нас. Как определить, что утилизация ресурсов кэширования происходит оптимально? Что если довольно большая часть нагрузки не приносит реальной пользы, и от нее с легкостью можно избавиться, тем самым разгрузив хранилище? В рамках этой статьи мы оценим эффективность кэширования бэкeнда ЛК МегаФон и расскажем о результатах проведенных мероприятий для оптимизации. Бэкeнд для мобильных/web-клиентов ЛК МегаФон представляет из себя по большей части витрину данных. Характерной чертой витрин является большое количество интеграций к разного рода внешним системам. К примеру: Биллинги; Платежный шлюзы; Центры уведомлений; Системы партнерских предложений. По сути основной задачей такого бэкенда является сбор информации из N систем, её небольшая обработка и предоставление клиентам в упрощенном виде. Но нередко бывает так, что для разных компонентов (читай API) бэкeнда необходим один и тот же источник данных (читай API внешних систем). В этом случае кэшировать ответы внешних систем. Это позволяет: Не перегружать внешние системы; Ускорять выдачу информации клиентам. В качестве инструмента для хранения кэша мы используем IMDB Hazelcast. Как видно на схеме, наш кластер hazelcast гео-распределен. В целом такой подход не рекомендуется из-за неустойчивости транспорта по WAN. Для снижения издержек кросс-ДЦ взаимодействия мы отключили репликацию коллекций, которые используются для кэша. Сам по себе кластер используется не только для кэша, но и для, к примеру, хранения сессий пользователей. Описать процесс кэширования на бэкeнде можно по этой блок-схеме: Как понять, что наш кэш является эффективным? Тут стоит отталкиваться от проблемы, которая решает вводимый функционал кэширования для конкретного источника данных. Это может быть: Уменьшение задержки получения информации; Разгрузка первичного источника данных. В зависимости от цели эффективность будет выражаться по-разному. Если для нас важно разгрузить первичный источник данных, мы можем пренебречь задержкой, главное, чтобы мы часто попадали в кэш. А если нас волнует низкая задержка, для этого не всегда достаточно иметь высокий коэффициент попаданий. Далее мы посчитаем эффективность по этим двум критериям и сделаем выводы. Для анализа эффективности мы использовали математику, prometheus и grafana. С помощью библиотеки micrometer разметили код счетчиками и таймерами. Определить коэффициент попаданий в кэш можно с помощью следующей формулы: После построения получили сделующую картину в grafana: Здесь видно, что 76% нагрузки на распределенное хранилище работает с коэффициентом попаданий выше 30%. Эффективно ли это? Опять же зависит от целей, которые были поставлены для конкретного кэша. Мы еще вернемся к этому. Теперь попробуем ответить на вопрос: насколько хорошо у нас получается уменьшать задержку получения данных? Для этого мы ввели коэффициент ΔT, который отражает, в какой мере снижаются временные затраты, если мы получаем данные из кэша, а не из первичного источника: Тут стоит пояснить, что мы не стали учитывать время, затраченное на PUT кэша. Это связано с несколькими причинами: PUT на стороне бэкeнда происходит асинхронно (затраты в микросекундах) и существенно не влияет на время выполнения запроса от пользователя; Как правило, отношение PUT’ов к GET’ам очень низкое — на один PUT приходится много GET’ов. Вычислять это отношение достаточно трудоемко, так как оно наверняка потребовало бы предварительной агрегации с вычислением. В нашем случае все вычисления происходят в реальном времени по запросу в prometheus. Хотя в целом, соглашусь, картина была бы точнее. Полученные результаты: Так выглядит картина в grafana: Здесь мы получили картину хуже, чем коэффициенты попаданий. Есть выбросы, которые дают околонулевые коэффициенты. И, кажется, эти выбросы являются следствием кросс-ДЦ-взаимодействия между узлами как кластера ЛК МегаФон, так и кластера hazelcast, потому что происходят выбросы в задержках на операции с хранилищем. Ниже на скриншоте наглядно видна описанная ситуация: Результаты анализа показали, что не все кэши работают эффективно: встречаются коэффициенты попаданий ниже 5% и оконулевые показатели эффективности по ΔT, в связи с чем необходимы дополнительные мероприятия. В ходе детального анализа процесса кэширования мы обнаружили некоторые изъяны: в рамках одного запроса или при асинхронном получении данных из внешних источников, которые кэшируюстя, мы можем несколько раз ходить в распределенное хранилище кэша или даже в первичный источник данных; мы каждый раз получаем из распределенного хранилища справочные данные, которые неперсонализированы и обновляются относительно редко. Для решения первой проблемы мы внедрили несколько уровней кэширования данных из первичного источника: L1 — хранилище кэша на уровне запроса. Этот кэш распределяется между всеми потоками, которыми пользуется поток от запроса (для асинхронных вычислений). В конце выполнения запроса этот кэш сбрасывается в L2 хранилище. L2 — распределенное хранилище. Ниже будет представлена схема работы просто с распределенным хранилищем. На ней как раз видны издержки несинхронизированной параллельности: Можем несколько раз сходить в хранилище кэша и понять, что его там нет; А позже два раза сходим ещё в первичный источник данных (при отсутствии кэша). Кроме параллельности, издержки могут возникнуть и на последовательном выполнении кода, так, к примеру, два косвенно связанных компонента кода могут нуждаться в одном и том же внешнем источнике данных. В этом случае мы бы несколько раз обратились в распределенное хранилище кэша, в то время как можно было ограничиться лишь одним. Ниже приведена схема, которую мы реализовали: Схема кэширования с L1 и L2 хранилищами кэшаЗдесь и блокировки для параллельности, и хранилище на уровне запроса, чтобы не ходить удаленно за данными несколько раз. На что стоить обратить внимание: Локи создаются по ключу: cache_region + key и хранятся в контексте запроса. То есть потоки, обслуживающие непосрдественно запросы, не конкурируют между собой; Хранилище L1 должно выдавать и принимать в себя только клоны объектов. Иначе есть риск, что в процессе обработки эти объекты будут мутированы, что приведет к тому, что в L2 хранилище уйдут не те объекты, которые вы получили из первичного источника данных. Такой трюк достаточно дорого может обходиться для памяти. Следует включать L1 только там, где это может реально принести пользу; в противном случае мы потратим память, но ничего не получим взамен; Стоит учитывать, что другие параллельные запросы на бэкeнд от того же пользователя могут инвалидировать некоторый кэш. Например, абонент сменил тариф и необходимо сбросить кэш, связанный с тарифами. Кэш мы можем удалить только на уровне L2, а на L1 для других запросов он останется. Это приведет к тому, что запросы при повторном обращении к хранилищу L1 получат устаревшие данные. Более того, в конце запроса L1 также сбросит потенциально устаревший кэш в L2 хранилище, что может привести к непредсказуемым последствиям. Здесь я рекомендую заранее оценить потенциальные риски и работать только с L2, но с блокировками.  Внедрение L1 хранилища с блокировками полностью оправдало ожидания. Замечу, что L1 по умолчанию выключен; на данный момент он работает точечно, там, где это действительно необходимо и риски с невалидным кэшем отсутствуют. Что получили: Уменьшили общую нагрузку нашего hazelcast на 10%; Уменьшили количество обращений в первичный источник данных также на 10% на тех API, где включен L1; Повысили hit ratio и следовательно K∆T кэша в некоторых случаях более, чем на 20%. Издержки: Увеличилось потребление heap-памяти на узле примерно на 8%; Увеличилась утилизация CPU на 3-4% на узле. Внедрение локального хранилища кэша У нас есть кэш внешних систем, который представляет собой просто справочную информацию. Как правило, справочные данные:  достаточно редко обновляются;  неперсонализированы. В таких условиях нет необходимости удаленно ходить за данными каждый раз. Кажется достаточным хранить копию этих данных рядом (локально) и, в случае их отсутствия, ходить либо в распределенное хранилище кэша, либо в первичный источник. Но стоит заметить, что справочная информация не всегда обладает маленьким объемом. Бывает большое количество комбинаций и хранение копий большого объема на каждом узле становится достаточно проблематичным. Необходимо точно знать будущие размеры кэша перед включением его в локальное хранилище. Ниже представлена схема, к которой мы пришли. Что получили: Уменьшили общую нагрузку нашего hazelcast на 5%; Повысили K∆T кэша в некоторых случаях за счет значительного уменьшения средней задержки получения данных из кэша. Издержки: увеличилось потребление heap-памяти на узле примерно на 12%. Осталось разобарться с кэшем, который имеет неприлично низкий hit ratio или низкий коэффициент ∆T. На данном этапе необходимо решить, зачем этот кэш нужен и какую проблему он решает.  После определения цели думаем, как можно оптимизировать. К примеру, для повышения hit ratio можно: Увеличить TTL; Пересмотреть сценарии cache evicts; Понаблюдать, насколько хорошо работают алгоритмы вытеснения (подходят ли они нам, не слишком ли жесткие ограничения мы выставили по размеру или объему); Возможно, сделать ключ менее уникальным для сценария. Для повышения ∆T могут подойти мероприятия: Увеличение hit ratio; Уменьшение задержек для получения данных из распределленного храналища (хотя это довольно сложно). Эти мероприятия индивидуальны для каждого проекта, сценария. Поэтому мы не будем останавливаться на деталях, а просто перечислим те шаги, которые мы предприняли для оптимизации кэширования: Увеличили TTL некоторых кэшей; Пересмотрели ключи на некоторых кэшах (вместо айди сессии взяли номер абонента); А что-то и вовсе перестали кэшировать. Что получили: уменьшили общую нагрузку нашего hazelcast еще на 7%; Издержки: незначительны (где-то немного увеличилось обращение в первичный источник данных, где-то увеличился размер кэша в распределенном хранилище). Научились оценивать в реальном времени, насколько эффективно работает кэш бэкeнда; Уменьшили общую нагрузку нашего hazelcast в целом на 20-25%; Eсть положительная динамика по увеличению hit ratio, из этого следует, что есть разгрузка первичных источников данных; Eсть положительная динамика по увеличению  KΔT, значит, наше API хоть и немного, но стало быстрее.