Хабр, привет! Меня зовут Вартанян Артур, я работаю в практике Java компании “Рексофт”. Сейчас я состою в команде по разработке Корпоративного портала, который, помогает оптимизировать рабочие процессы между сотрудниками и менеджментом, а также налаживать корпоративную жизнь и культуру:)). Об этом мы тоже как-нибудь напишем в блоге компании, а сейчас будет пост про HazelCast.

HazelCast IMDG
HazelCast IMDG

О чем эта статья?

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

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

Что такое HazelCast?

HazelCast IMDG - это библиотека с открытым исходным кодом на Java, которая представляет собой In-Memory Data Grid решение. Благодаря ему мы можем без труда создавать распределенные объекты и вычисления, кешировать данные, работать с транзакциями и очередями. Это далеко не все возможности данной библиотеки, ведь помимо перечисленного выше, HazelCast - это еще и возможность построения легко масштабируемых и отказоустойчивых систем.

Зачем использовать HazelCast, если есть альтернативные решения?

Имея приложение с монолитной архитектурой, часто приходится запускать его на нескольких нодах. Ввиду этого приходится сталкиваться с некоторыми проблемами: одна из очевидных — это работа методов, которые отрабатывают не по запросу, а вследствии работы условных триггеров. Примером для этого могут служить scheduled-методы, которые запускаются исходя из заранее запланированного времени. В таком случае, имея две ноды, метод отработает два раза, в случае наличия трех нод — три раза и т.д.. Проблему можно решить, создав “синхронизированный” метод между нодами, используя локеры и распределенные объекты HazelCast.

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

Использование варианта с HazelCast - это в первую очередь зависимость только от одной библиотеки, решение которой полностью будет интегрировано в Java-код и никак не будет зависеть от ошибочного поведения базы данных (БД), некорректной работы с файловой системой и т.д.

К слову, держать глобальную переменную, которая свои значения будет хранить в БД не всегда целесообразно, ведь мы получаем зависимость и излишние запросы в БД. Вариант с очередями не всегда подходит, так как в очередь нужно всё равно синхронизировано складывать данные. Да и ко всему прочему, оборачивать метод в 3-4 строки кода куда приятнее и чище, чем создавать целые классы и методы для создания настроек и отдельных job.

Что нам потребуется?

  1. HazelCast FencedLocks - это распределенный, линеаризованный вариант реализации java.util.concurrent.locks.Lock. Он гарантирует, что нужная секция будет выполняться одним и только одним потоком(в нашем случае нодой) во всем кластере. Именно благодаря ему мы сможем получить синхронизацию.

  2. 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());
    }
}
  1. Для начала добавим зависимости HazelCast в проект:

implementation group: 'com.hazelcast', name: 'hazelcast', version: '5.0.2'
  1. Настроим конфигурационный файл и класс (файл можно настроить либо через 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");
    }
}
  1. Добавим 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();
            }
        }
    }
}

Пробуем запустить приложение на двух разных портах одновременно. В консоли нам должно вывестись сообщение об удачном создании кластера из наших нод. В моем случае кластер из двух нод будет отображаться следующим образом (причем в консолях обоих нод):

Кластер из двух нод
Кластер из двух нод

Спустя несколько секунд, мы можем увидеть, как в консоли печатается наше сообщение, которое мы выводим через System.out.println() метод, но если понаблюдать за каждой консолью по отдельности чуть подольше, можно заметить, как в отдельных случаях метод срабатывает в одну и ту же секунду по 2 раза. Для наглядности посмотрим на вывод:

Консоль ноды №1
Консоль ноды №1
Консоль ноды №2
Консоль ноды №2

Можно утверждать, что обе ноды синхронизировались, но иногда у нас проскакивают дубляжи. Заметим, что нода под №1 напечатала все сообщения с интервалом в 5 секунд, в то время как вторая ничего не напечатала, кроме двух дубляжей (при данном раскладе она не должна была ничего печатать, если только не замещала бы ноду №1).

  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
Консоль ноды №2
Консоль ноды №2

Результат: метод отрабатывает четко по 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-код и одну библиотеку, при этом не выполняя лишних запросов в БД, не перебирая файловую систему и не используя очереди. Вдобавок ко всему получили распределение нагрузки между нодами.

Надеюсь статья была полезна!

Полноценный репозиторий с проектом можно посмотреть по ссылке.

Комментарии (8)


  1. deilux
    19.01.2022 21:27
    +1

    Спасибо! Пока бы подобное не бомбануло в проде, сам бы вряд ли догадался!


  1. Al81ru
    20.01.2022 20:23

    выглядит не очень чисто с добавлением секунд. И распределения практически нет. С тем же успехом можно просто не отпускать лок, который станет ролью ноды


    1. rogue06 Автор
      20.01.2022 20:48

      Не совсем понятно, что имеется ввиду в последнем предложении. Лок не должен все время держать метод, ведь его задача задержать его лишь на несколько секунд, пока нода №2 не обламается. И к тому же, стоит отметить, что интервал в 5 секунд для sheduler'а - это тестовое значение, ведь на практике 5 секундных триггеров встречается крайне мало, в основном это интервалы раз в день или раз в час. Как минимум IMAP - это гарантия того, что метод отработает один раз. Как я написал выше, в статье, исходя из моего опыта реализации других решений, вариант с распределенной картой показал лучший результат, в том числе и по чистоте, хотя до начала работы с ним мне показалось, что это больше "костыльное" решение. Плюс ко всему HazelCast предоставляет UI management-center для работы со всей библиотекой, что очень удобно использовать в отладке, ведь там можно в режиме live смотреть состояния объектов, в том числе и распределенных.


  1. ppavel832
    20.01.2022 20:49

    Спасибо за интересную статью. Несколько вопросов. Не кажется ли, что для двух нож решение со sleep(4000), просто заблокирует вызов метода на второй ноде? Т.к. она будет всегда опаздывать? И второй вопрос - для случая нескольких нод второй вариант со sleep() так же на первый взгляд не оптимальный, т.к. каждой ноде придётся задавать свою дельту. Тогда проще вообще не стартовать этот метод, кроме как на первой ноде.


    1. rogue06 Автор
      20.01.2022 21:04

      Павел, спасибо!

      1) В этом и заключается смысл работы метода sleep(4000). По факту, нам ни OC, ни Spring не будут гарантировать выполнение метода с точностью до миллисекунд, исходя из этого одна из нод всегда будет опаздывать и стучаться в метод повторно, как показано на первых скринах консоли. Усыпляя один из потоков на 4 секунды, мы предовтращаем возможность повторного доступа к методу для опаздывающего потока.

      2) IMap как раз эту проблему решает. Решение намного лучше, чем использование sleep(). Ведь успыпление потока не будет 100% гарантией, потому что второй может опоздать и на больше, чем 4000мс - например, у него случился GC. Можно еще организовать голосование между нодами, чтобы они выбрали лидера. Тогда в момент срабатывания расписания, каждый проверяет, лидер он или нет, и только лидер делает работу. Но по сравнению с IMap - это как из пушки по воробьям.


  1. Antharas
    20.01.2022 22:03

    Тащить в проект inMemory бд ради того что бы не хранить в ней данные, а использовать распределенные блокировки.. слишком много оверхеда. Для каждой задачи есть свой инструмент, и этот не подходит под вашу задачу. Нужны распределенные воркеры, которые будут запускаться поочередно на каждой ноде - quartz в помощь. Даже банальная таблица синхронизации в реляционной базе с запросами вида select for update, решает эту задачу.


    1. rogue06 Автор
      21.01.2022 14:52
      -1

      Вы можете не тащить inMemory к себе в проект, а воспользоваться другим, более оптимальным для себя решением. Есть проекты, где HazelCast уже присутствует, и не вижу ни одной причины почему не использовать его для решения подобных задач. Вариант с библиотекой Quartz был, но отпал, так как мне не понравилась его "многословность", слишком разветвленное API и малое количество документации. В моем кейсе я решил не отказываться от Spring Scheduler, где все решение упрощается в одну аннотацию.


  1. sandersru
    20.01.2022 23:50

    Приветствую, на проблему с атомарными записями в HZ кластер еще не наступили?

    Там возникают задержки для put/get/etc иногда на секунды на синхронизацию нод.

    Разработчики в качестве w/a предлагали только putAll и подобное использовать.

    Так что аккуратнее :)