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


Под катом вас ожидает пара историй о том, как неправильно прикрученный кеш убил перфоманс.



Нетрадиционный дисклеймер
  • Все описанные в статье события вымышлены, любые совпадения с реальными деплойментами случайны.
  • Описанная в статье логика работы 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.


Кеш, даже распределенный, не панацея!


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


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