Прежде чем перейти к статье, хочу вам представить, экономическую онлайн игру Brave Knights, в которой вы можете играть и зарабатывать. Регистируйтесь, играйте и зарабатывайте!
Хабр, привет! Меня зовут Вартанян Артур, я работаю в практике Java компании “Рексофт”. Сейчас я состою в команде по разработке Корпоративного портала, который, помогает оптимизировать рабочие процессы между сотрудниками и менеджментом, а также налаживать корпоративную жизнь и культуру:)). Об этом мы тоже как-нибудь напишем в блоге компании, а сейчас будет пост про HazelCast.
![HazelCast IMDG HazelCast IMDG](https://habrastorage.org/r/w780q1/getpro/habr/upload_files/325/c03/1ac/325c031ace3664a4f235620b5ce8a237.jpg)
О чем эта статья?
В процессе синхронизации метода между несколькими нодами я решил поработать с библиотекой HazelCast IMDG. К моему удивлению, у данного проекта очень мало технической документации, особенно по сравнению с Redis. На официальном портале присутствует минимальное описание возможностей библиотеки вместе со списком конфигураций, где местами и вовсе нет комментариев к добавленным строкам кода. На просторах веба количество примеров с решенными кейсами встречается крайне редко, а сообщество слишком мало.
По этим причинам я решил поделиться своим опытом синхронизации метода между несколькими нодами, реализацией его практической части, а также показать вытекающую проблему и ее решение.
Что такое HazelCast?
HazelCast IMDG - это библиотека с открытым исходным кодом на Java, которая представляет собой In-Memory Data Grid решение. Благодаря ему мы можем без труда создавать распределенные объекты и вычисления, кешировать данные, работать с транзакциями и очередями. Это далеко не все возможности данной библиотеки, ведь помимо перечисленного выше, HazelCast - это еще и возможность построения легко масштабируемых и отказоустойчивых систем.
Зачем использовать HazelCast, если есть альтернативные решения?
Имея приложение с монолитной архитектурой, часто приходится запускать его на нескольких нодах. Ввиду этого приходится сталкиваться с некоторыми проблемами: одна из очевидных — это работа методов, которые отрабатывают не по запросу, а вследствии работы условных триггеров. Примером для этого могут служить scheduled-методы, которые запускаются исходя из заранее запланированного времени. В таком случае, имея две ноды, метод отработает два раза, в случае наличия трех нод — три раза и т.д.. Проблему можно решить, создав “синхронизированный” метод между нодами, используя локеры и распределенные объекты HazelCast.
Существует большое количество возможных решений данного кейса. Например: использовать стандартные очереди, создавать глобальные переменные, используя базу данных, считывать/изменять заранее прописанные параметры, созданные в файловой системе или в том же сервере приложений. У каждого способа есть свои плюсы и минусы.
Использование варианта с HazelCast - это в первую очередь зависимость только от одной библиотеки, решение которой полностью будет интегрировано в Java-код и никак не будет зависеть от ошибочного поведения базы данных (БД), некорректной работы с файловой системой и т.д.
К слову, держать глобальную переменную, которая свои значения будет хранить в БД не всегда целесообразно, ведь мы получаем зависимость и излишние запросы в БД. Вариант с очередями не всегда подходит, так как в очередь нужно всё равно синхронизировано складывать данные. Да и ко всему прочему, оборачивать метод в 3-4 строки кода куда приятнее и чище, чем создавать целые классы и методы для создания настроек и отдельных job.
Что нам потребуется?
HazelCast FencedLocks - это распределенный, линеаризованный вариант реализации java.util.concurrent.locks.Lock. Он гарантирует, что нужная секция будет выполняться одним и только одним потоком(в нашем случае нодой) во всем кластере. Именно благодаря ему мы сможем получить синхронизацию.
HazelCast IMap - это распределенная реализация обычного справочника (ключ : значение). IMap наследует Java-класс ConcurrentMap<K, V>, тем самым расширяет его, а значит расширяет и класс java.util.Map. Поведение распределенной карты такое же, как и у обычного словарика, с единственной разницей - в нее могут записывать/считывать значения несколько нод одного кластера. Нам она нужна будет для решения проблемы с временными интервалами в миллисекундах при запуске нод (см. более подробно при добавлении map в практической части).
Практическая часть
Для реализации данного кейса я решил взять за основу консольное Spring Boot приложение. Для сборки проекта использовал Gradle. Ну, и триггерами, собственно, у нас выступят Spring Schedulers.
Допустим, у нас имеется метод, который каждые 5 секунд на экран выводит сообщение: “I started at TIME”. Попробуем синхронизировать его:
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
import java.time.LocalDateTime;
@Component
public class MessageScheduler {
@Scheduled(cron = "0/5 * * * * *")
public void showMessage() {
System.out.println("I started at " + LocalDateTime.now());
}
}
Для начала добавим зависимости HazelCast в проект:
implementation group: 'com.hazelcast', name: 'hazelcast', version: '5.0.2'
Настроим конфигурационный файл и класс (файл можно настроить либо через xml, либо через yaml).
Создадим в ресурсах hazelCast.yaml и добавим следующий код:
hazelcast:
network:
join:
multicast:
enabled: true
Добавим класс конфигураций для HazelCast, где создадим все необходимые bean-ы для дальнейшей работы:
import com.hazelcast.config.Config;
import com.hazelcast.core.Hazelcast;
import com.hazelcast.core.HazelcastInstance;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import java.time.LocalDateTime;
@Configuration
public class HazelCastConfiguration {
@Bean
public com.hazelcast.config.Config hazelCastConfig() {
return new Config();
}
@Bean
public HazelcastInstance hazelcastInstance(Config hazelCastConfig) {
return Hazelcast.newHazelcastInstance(hazelCastConfig);
}
@Bean
public IMap<String, LocalDateTime> timeMap(@Qualifier("hazelcastInstance") HazelcastInstance hazelcastInstance) {
return hazelcastInstance.getMap("hazelcastTimeMap");
}
}
Добавим FencedLocks:
Lock-ом мы должны обернуть именно ту внутреннюю часть метода, которую мы хотим выполнить всего лишь раз, вне зависимости от количества нод. Сначала мы создаем Lock, после чего пробуем его “заблокировать”, если он еще доступен. После отработки метода нам необходимо его разблокировать в блоке finaly. В коде это будет выглядеть следующим образом:
import com.hazelcast.core.HazelcastInstance;
import com.hazelcast.cp.lock.FencedLock;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
import java.time.LocalDateTime;
@Component
public class MessageScheduler {
private final HazelcastInstance hazelcastInstance;
public MessageScheduler(HazelcastInstance hazelcastInstance) {
this.hazelcastInstance = hazelcastInstance;
}
@Scheduled(cron = "0/5 * * * * *")
public void showMessage() {
FencedLock lock = hazelcastInstance.getCPSubsystem().getLock("showMessageLock");
if (lock.tryLock()) {
try {
System.out.println("I started at " + LocalDateTime.now());
} finally {
lock.unlock();
}
}
}
}
Пробуем запустить приложение на двух разных портах одновременно. В консоли нам должно вывестись сообщение об удачном создании кластера из наших нод. В моем случае кластер из двух нод будет отображаться следующим образом (причем в консолях обоих нод):
![Кластер из двух нод Кластер из двух нод](https://habrastorage.org/r/w780q1/getpro/habr/upload_files/a61/042/a02/a61042a02b9bad3223cf053209752fbb.jpg)
Спустя несколько секунд, мы можем увидеть, как в консоли печатается наше сообщение, которое мы выводим через System.out.println() метод, но если понаблюдать за каждой консолью по отдельности чуть подольше, можно заметить, как в отдельных случаях метод срабатывает в одну и ту же секунду по 2 раза. Для наглядности посмотрим на вывод:
![Консоль ноды №1 Консоль ноды №1](https://habrastorage.org/r/w780q1/getpro/habr/upload_files/35c/abe/ae3/35cabeae3fd400aeb8fb0d1b82f28f91.jpg)
![Консоль ноды №2 Консоль ноды №2](https://habrastorage.org/r/w780q1/getpro/habr/upload_files/2a2/f70/c16/2a2f70c16e47517ee94e74588725fa60.jpg)
Можно утверждать, что обе ноды синхронизировались, но иногда у нас проскакивают дубляжи. Заметим, что нода под №1 напечатала все сообщения с интервалом в 5 секунд, в то время как вторая ничего не напечатала, кроме двух дубляжей (при данном раскладе она не должна была ничего печатать, если только не замещала бы ноду №1).
Добавим IMap:
Данная проблема возникает из-за того, что ноды, отрабатывают одновременно не с точностью до миллисекунд. Если еще раз посмотрим на вывод, мы можем увидеть, что первая нода напечатала сообщение в 19:12:35:022 (где последние 3 цифры это миллисекунды), а вторая нода отработала так же в 19:12:35:029, но с миллисекундами в :029. В подобном случае говорить, что синхронизация не удалась — неверно.
Чтобы решить данную проблему, необходимо создать распределенную карту, куда мы будем класть ключ : значение по принципу "название действия" : "время". Каждый раз, когда нода доберется до метода, она будет проходить лок, где после успешного прохождения будет смотреть в карту: если время последнего вывода на экран сообщения меньше текущего времени, мы выводим сообщение, иначе игнорируем.
После добавления карты, метод будет выглядеть следующим образом:
@Scheduled(cron = "0/5 * * * * *")
public void showMessage() {
FencedLock lock = hazelcastInstance.getCPSubsystem().getLock("showMessageLock");
if (lock.tryLock()) {
try {
LocalDateTime date = timeMap.get("message");
if (date == null || LocalDateTime.now().isAfter(date)) {
System.out.println("I started at " + LocalDateTime.now());
timeMap.put("message", LocalDateTime.now().plusSeconds(4));
}
} finally {
lock.unlock();
}
}
}
Добавив к текущей отработанной секунде еще 4 секунды сверху, мы избавимся от проблемы с дублями в миллисекундах. Пробуем заново запускаться с двух нод и длительное время наблюдаем за выводом обеих консолей.
![Консоль ноды №1 Консоль ноды №1](https://habrastorage.org/r/w780q1/getpro/habr/upload_files/f07/ce0/e1d/f07ce0e1daddb987edec70ebf4711416.jpg)
![Консоль ноды №2 Консоль ноды №2](https://habrastorage.org/r/w780q1/getpro/habr/upload_files/79d/d28/e76/79dd28e76960c1b31e57f6153941fe55.jpg)
Результат: метод отрабатывает четко по 1 разу каждые 5 секунд и дубляжей не бывает.
Дополнение:
Решение с IMap не единственное. Как альтернативу можно предложить вариант с удержанием лока подольше, используя усыпление потока(ноды), чтобы второй узел успел проснуться, попытаться взять лок и обломиться. Например, через метод sleep() класса java.util.concurrent.TimeUnit. В данном случае код выглядел бы следующим образом:
@Scheduled(cron = "0/5 * * * * *")
public void showMessage() throws InterruptedException {
FencedLock lock = hazelcastInstance.getCPSubsystem().getLock("showMessageLock");
if (lock.tryLock()) {
try {
TimeUnit.MILLISECONDS.sleep(4000);
System.out.println("I started at " + LocalDateTime.now());
} finally {
lock.unlock();
}
}
}
Вывод:
Мы получили синхронизированный метод между несколькими нодами, используя только сам Java-код и одну библиотеку, при этом не выполняя лишних запросов в БД, не перебирая файловую систему и не используя очереди. Вдобавок ко всему получили распределение нагрузки между нодами.
Надеюсь статья была полезна!
Полноценный репозиторий с проектом можно посмотреть по ссылке: https://github.com/University-and-Education/hazelcast-method-synch