
Многие верят что кеш, да еще и распределенный, — это такой простой способ сделать всё быстрее и круче. Но, как показывает практика, неправильное применение кеша часто, если не всегда, делает всё только хуже.
Под катом вас ожидает пара историй о том, как неправильно прикрученный кеш убил перфоманс.
- Все описанные в статье события вымышлены, любые совпадения с реальными деплойментами случайны.
- Описанная в статье логика работы Apache Ignite намеренно упрощена, важные подробности опущены.
- Не пытайтесь повторять подобное в продакшене без вдумчивого чтения официальной документации!
- Некоторые из описанных паттернов проектирования являются антипаттернами, помните об этом перед внедрением :)
Моя база тормозит, а с вашем кешем тормозит еще больше :(
Как выглядит среднестатистический вебсервис?
Сервлет ⟷ База данных
Когда база данных перестает справляться нужно сделать что?
Правильно! Купить новое железо!
А когда не поможет?
Правильно! Настроить кеш, желательно распределенный!
Сервлет ⟷ Кеш ⟷ База данных
Будет быстрее? Не факт!
Почему? Допустим, вы добились невероятного параллелизма и можете принять тысячи запросов одновременно. Но, все они придут по одному ключу — горячему предложению с первой страницы.
Далее, все потоки будут следовать одной и той же логике: «значения в кеше нет, пойду за ним в базу».
Что произойдет в итоге? Каждый поток сходит в базу и обновит значение в кеше. В итоге, система потратит больше времени, чем если бы кеша не было в принципе.
А решение проблемы очень простое — синхронизация запросов по одним и тем же ключам.
Apache Ignite предлагает простое кеширование через Spring Caching с поддержкой синхронизации.
@Cacheable("dynamicCache")
public String cacheable(Integer key) {
// Каждый поток имеет шанс оказаться здесь :(
return longOp(key);
}
@Cacheable(value = "dynamicCache", sync = true)
public String cacheableSync(Integer key) {
// Здесь, по одному ключу, окажется максимум один поток
// Все остальные потоки, по этому же ключу,
// дождутся результата от первого, вместо выполнения кода longOp(key)
return longOp(key);
}
Функционал синхронизации был добавлен в Apache Ignite в версии 2.1.
Теперь быстро! Но всё ещё медленно :(
Эта история является прямым продолжением предыдущей и призвана показать, что разработчики кешей тоже не всегда используют их правильно.
Итак, фикс, добавляющий синхронизацию по кешам, оказался в продакшене и… не помог.
Никто не ломанулся за горячим товаром с первой страницы, а все 1000 потоков пошли по разным ключам одновременно, и узким местом стали механизмы синхронизации.
В предыдущей статье я рассказывал, как работали средства синхронизации в Apache Ignite раньше.
Информация обо всех средствах синхронизации раньше хранилась в кеше ignite-sys-cache
, по ключу DATA_STRUCTURES_KEY
в виде Map<String, DataStructureInfo>
и каждое добавление синхронизатора выглядело как:
// Берем глобальный лок по ключу
lock(cache, DATA_STRUCTURES_KEY);
// Добавляем новый синхронизатор
Map<String, DataStructureInfo> map = cache.get(DATA_STRUCTURES_KEY);
map.put("Lock785", dsInfo);
cache.put(DATA_STRUCTURES_KEY, map);
// Отпускаем лок
unlock(cache, DATA_STRUCTURES_KEY)
Итого при создании необходимых синхронизаторов все потоки стремились изменить значение по одному и тому же ключу.

Снова быстро! Но надежно ли?
Apache Ignite избавился от «самого важного» ключа в версии 2.1, начав сохранять информацию о синхронизаторах раздельно. Пользователи получили прирост больше 9000% в реальном сценарии.

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

В Apache Ignite, начиная с версии 2.1, появилась собственная реализация Persistence.
Но, сама по себе Persistence не дает гарантий получения консистентного состояния при рестарте, т.к. данные синхронизируются (сбрасываются на диск) периодически, а не в реальном времени. Основной задачей Persistence является возможность хранить и эффективно обрабатывать на одном компьютере больше данных, чем влезает в его память.
Гарантия консистентности достигается использованием подхода Write-ahead logging. Говоря проще, данные сначала записываются на диск как логическая операция, а потом уже сохраняются в распределенном кеше.
При рестарте сервера на диске мы будем иметь состояние, актуальное на какой то момент времени. Достаточно на это состояние накатить WAL, и система снова работоспособна и консистентна (полный ACID). Восстановление занимает секунды, в худшем случае — минуты, но не часы или дни, требуемые для прогрева кеша актуальными запросами.
Теперь надежно! Быстро ли?
Persistence замедляет систему (на запись, не на чтение). Включенный WAL замедляет систему ещё бо��ьше, это плата за надежность.
Есть несколько уровней журналирования, дающих разные гарантии:
— DEFAULT — полные гарантии по сохранению данных при любом уровне нагрузки
— LOG_ONLY — полные гарантии, кроме случая выхода из строя операционной системы
— BACKGROUND — гарантий нет, но Apache Ignite будет стараться :)
— NONE — журнал операций не ведется.
Разница в скорости DEFAULT и NONE на средневзвешенной системе достигает 10 раз.
Вернемся к нашей ситуации. Допустим, мы выбрали режим BACKGROUND, который в 3 раза медленней NONE и теперь не боимся потерять разогретый кеш (можем потерять операции из последних нескольких минут перед крашем, но не более того).
В таком режиме мы работали несколько месяцев, всякое случалось и мы легко восстанавливали систему после крашей. Все вокруг довольны и счастливы.
Если бы не одно «но», сегодня, 20-го декабря, в пик продаж, мы поняли, что серверы загружены на 80% и вот-вот рухнут под нагрузкой.
Выключение WAL (перевод в NONE) дало бы нам снижение нагрузки в 3 раза, но для этого нужно рестартовать весь кластер Apache Ignite и потерять возможность восстановиться на таком кластере в случае чего — возвращаемся к пункту «быстро и ненадежно»?
В Apache Ignite, начиная с версии 2.4, появилась возможность отключать WAL без перезапуска кластера, а потом включать его с восстановлением всех гарантий.
SQL
// Выключение
ALTER TABLE tableName NOLOGGING
// Включение
ALTER TABLE tableName LOGGING
Java
// Текущее состояние
ignite.cluster().isWalEnabled(cacheName);
// Выключение
ignite.cluster().disableWal(cacheName);
// Включение
ignite.cluster().enableWal(cacheName);
Теперь и быстро и надежно, но если что — есть варианты ...
Теперь, если нам нужно отключить журналирование (к черту гарантии, главное пережить нагрузку!) мы можем временно отключить WAL и включить его в любой удобный нам момент.
А для быстрой загрузки огромного набора исторических данных на старте системы.
Также стоит упомянуть, что отключение возможно в рамках только определенных кешей, а не всей системы целиком, т.е. в случае чего данные в кешах, для которых отключение не проводилось, не пострадают.
При этом, после включения WAL, система гарантирует поведение согласно выбранному режиму WAL.
Кеш, даже распределенный, не панацея!
Ни одна технология, какая бы навороченная и продвинутая она не была, не сможет решить все ваши проблемы. Неправильно использованная технология, скорее всего, сделает только хуже, а использованная правильно, вряд ли закроет собой все щели.
Кеш, в том числе распределенный, — это механизм, дающий ускорение только при грамотном использовании и вдумчивой настройке. Помните об этом перед внедрением его в ваш проект, проводите замеры до и после во всех связанных с ним кейсах… и да пребудет с вами перфоманс!