Реальный пример использования Spring Global Lock

Меня зовут Максим Вороной, я — Consulting Engineer в GlobalLogic (Харьков). С 2000 года я занимаюсь архитектурой ПО, в круг моих интересов входят распределенные и облачные системы, а также построение систем с элементами искусственного интеллекта. Кроме этого, я веду учебный курс для разработчиков по современной архитектуре программных приложений.

Недавно во время GlobalLogic Kharkiv Java Conference 2018 я сделал доклад об использовании Spring Global Lock и написал эту статью на его основе.

Как мы получили проект

Для начала небольшая реальная история, которая началась в западной Европе. Компания из богатой европейской страны решила заказать софт у одной очень известной южной страны. Южная страна что-то изготовила, зарелизила, сторонние тестировщики это проверили...
Но после запуска в продакшн европейская страна пожаловалась: все, что вы создали и протестировали, не работает в ситуации, когда система обрабатывает данные, связанные с одновременным приходом нескольких пользователей.

Специалисты южной страны сказали: «Да! Точно! Мы знаем, в чем причина!». Разработчики добавили парочку временных задержек в коде, установили для пользователя ограничение на длительность сеанса работы, сказали: «Теперь все работает!», — и выпустили второй релиз. Европейская страна проверила, и оказалось, пока в системе два человека, — все прекрасно, а на десяти начинаются уже известные проблемы.

Южная страна выпустила второй, третий релиз, разработчики добавили кое-что «волшебное»: еще больше увеличили задержки, добавили пару лишних инстансов серверов JBoss. Тестировщики опять добросовестно проверили базовые бизнес-сценарии, по которым все работало. А европейская страна сказала: нет, ребята, не работает — пожалуй, на этом мы с вами и расстанемся.

Так проект попал в Украину. В начале мы восторгались «классным» кодом: его прекрасным колоритом, наличием задержек и даже изобретением пузырьковой сортировки. По словам заказчика, в целом в системе все было хорошо — кроме того, что многопользовательский сценарий не работал, и всего лишь надо было подправить код.

Первое, что мы попытались сделать — это избавиться от JBoss, от которого ничего, кроме Web-сервера не использовалось. Но DevOps заказчика сказали, что на продакшене все настроено именно под него, и специально для нас ничего менять не будут (вы же выпустите пустяковый фикс). Картина для нас складывалась печальная: мы были жестко ограничены возможностью выбора платформы, не могли использовать оптимальные паттерны и готовые облачные решения. В таких условиях мы начали оживлять код.

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

Case Study: книжный магазин

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

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

Как было

Дизайн таблиц базы данных для задачи тривиальный:

Таблица Store содержит все доступные для покупки книги с первичным ключом isbn.

Таблица Payment содержит результат совершенной покупки, где: payment — первичный ключ, amount — сумма, которую нужно заплатить за приобретаемые книги, transaction — атрибут, отправляемый банком.

Таблица BookOrder, реализует связку 1:N между Store и Payment (1 платеж за N книг).

Южная страна с давними традициями программирования написала следующий код (для простоты приводятся только используемые SQL-запросы в порядке выполнения):

public void buyBooks(List<Book> books, Payment userPayment){
// Validate:
final String checkSql =
"SELECT 1 FROM Store WHERE isbn = ? AND amount > 1";
// Modify business entities
final String insertPaymentSql =
"INSERT INTO Payment (amount, transaction) VALUES ...";
final String insertOrderSql =
"INSERT INTO BookOrder (payment, isbn) VALUES ...";
final String updateStoreSql =
"UPDATE Store SET amount = amount -1 WHERE isbn = ?";
}

Валидация заключается в простом запросе: SELECT 1 FROM Store WHERE isbn = ? AND amount > 1. Таким образом мы проверяем, имеется ли книга на складе. Затем создаем «платежку» в таблице Payment, добавляем новую запись в таблице BookOrder и в финальной строке кода уменьшаем количество экземпляров книги на складе на единицу.

Три подхода к решению

Реализация уровня junior

Стоит подчеркнуть, что эта проверка — несколько искусственная, но она наиболее эффективно показывает проблемы с данным кодом. Наша команда получила его именно в таком виде. Для начала мы поручили исправление проблемы программисту junior-уровня, который предложил решение, добавив в начало следующее описание: @Transactional

@Transactional // (!)
public void buyBooks(List<Book> books, Payment userPayment){

В этом месте читатель, должно быть, улыбнется: понятно, что проблема решена не была.

К счастью, код-ревью у нас поставлен хорошо, и опытный старший программист сказал джуниору: «Парень, это не поможет».

Способы организации транзакций

Тут я напомню о двух из пяти вариантов изоляции транзакций в базах данных:

Read committed — транзакция читает только фиксированные данные (от завершенных транзакций). Этот уровень изоляции используется в большинстве существующих баз данных по умолчанию (Oracle, SQL, MySQL и пр.). Оптимальное соотношение между производительностью и изоляцией, но подвержено т. н. фантомному чтению.

Serializable — гарантия невмешательства в данные. Минус — время ожидания пользователя в очереди увеличивается.

Любые «игры» с описанными уровнями изоляции, к сожалению, наш код не спасали. Даже если мы выберем Serializable, следующая строка останется проблемным местом системы:

UPDATE Store SET amount = amount -1 WHERE isbn = ?

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

Более удачная реализация — уровень middle

Как можно было бы устранить проблему в коде? Мы оставляем сигнатуру транзакции, остальные три запроса остаются как есть, но мы добавляем одно волшебное ключевое слово (в синтаксисе MS SQL):

@Transactional
public void buyBooks(List<Book> books, Payment userPayment){
// Validate:
final String checkSql =
"SELECT 1 FROM Store WHERE isbn = ? AND amount > 1 FOR UPDATE";
// Modify business entities
final String insertPaymentSql =
"INSERT INTO Payment (amount, transaction) VALUES ...";
final String insertOrderSql =
"INSERT INTO BookOrder (payment, isbn) VALUES ...";
final String updateStoreSql =
"UPDATE Store SET amount = amount -1 WHERE isbn = ?";
}

Существует, похожий синтаксис для JPA спецификации:

em.find(isbn, LockMode.PESSIMISTIC_READ )

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

Анализируем дальше.

К сожалению, даже к этому коду есть множество претензий.

Рассмотрим процесс покупки поэтапно:

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

Зададимся вопросом, что в частности произойдет, если получение подтверждения банка займет более 40 секунд (время, установленное для обрыва транзакций в большинстве баз данных по умолчанию)?

Если за 40 секунд транзакция не закончилась, база данных «решает», что система подвисла и прерывает транзакцию. Это время можно увеличить, но смысл?.. Банковский https-запрос, который выполняет проверку платежеспособности данного пользователя, может существенно поломать код.
Следующий вопрос: что мы будем делать, если производительности этого кода недостаточно? Он компактен и прекрасно ложится в уровень архитектуры небольшого приложения. Но если возникнет увеличение нагрузки — на складе, в закупке, в платежных системах? Одним из решений может стать масштабирование системы на облако и использование микросервисов. Следует повторно оговориться, что наш пример искусственный и с микросервисами мы будем вынуждены разбить монолитный код, описанный в нашем примере.

Реализация уровня Senior

Что же может предложить для устранения этой проблемы «сеньор» или архитектор?

Здесь на сцену выходит поставляемая в базовом наборе Spring библиотека Spring Global Lock, позволяющая полностью решить нашу проблему. Как это будет выглядеть?

Autowired
private LockRegistry lockRegistry;
@Transactional
public void buyBooks2(List<Book> books, Payment userPayment){
    for(Book b: books){
        Lock lock = lockRegistry.obtain(b.getIsbn());
        lock.lock(); // lock.tryLock();
        try{
            doInsertAndUpdate(b);
        } catch (InterruptedException e1){
            //give up
            Thread.currentThread().interrupt();
        } finally{
            lock.unlock();
        } //try
    } //for
}

Есть некоторый класс LockRegistry — создадим его Autowired instance.

Блок finally нужно включать в код обязательно и всегда обрабатывать interrupt exception, так как при принудительном завершении текущего потока код остается валидным.

Покупка книги в данной по версии 2 будет выглядеть следующим образом:

  • в виде JSON-файла приходит список книг, которые хочет купить пользователь, а также информация, достаточная для того, чтобы осуществить платеж;
  • из LockRegistry получаем всем известный java.util.concurrent.locks.Lock (стандартный интерфейс библиотеки Java), на котором устанавливается блокировка — можно использовать по желанию методы lock или tryLock;
  • после этого добавляем классический код: try, catch и finally (finally гарантирует разблокировку по окончанию вне зависимости от ошибок);
  • покупка книг происходит в стороннем методе doInsertAndUpdate.

Для любопытных приведу дополнительно пару зависимостей, как они выглядят в Maven:

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-integration</artifactId>
</dependency>
<dependency>
    <groupId>org.springframework.integration</groupId>
    <artifactId>spring-integration-core</artifactId>
</dependency>

Как видите, получился простой и компактный код, гарантирующий отсутствие проблем.

Ключ getIsbn в коде позволит точечно заблокировать только необходимую книгу. Что именно вы используете в качестве ключа для Spring значения не имеет: можно указать глобальный идентификатор для блокировки всей системы либо точечный Isbn, блокирующий одну запись. Spring принимает любой Object, который и будет использован в качестве ключа.

Что под капотом

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

LockRegistry в базовой поставке библиотеки SpringLock имеет пять основных вариантов реализации:

  1. PassThruLockRegistry.
  2. GemfireLockRegistry.
  3. RedisLockRegistry.
  4. JdbcLockRegistry.
  5. ZookeeperLockRegistry.

PassThruLockRegistry не делает вообще никакой блокировки — это пустая заглушка, применяется для тестирования.

GemfireLockRegistry — как и RedisLockRegistry — используют соответственно Gemfire и Redis базы для реализации глобальных блокировок.

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

Но что произойдет, если в базе сделана запись о блокировке, а код, который запустил транзакцию, «умер» (был намеренно закрыт пользователем либо завершил работу по ошибке)? Естественно, больше никто не сможет уже заблокировать этот ключ. Для разрешения таких ситуаций разработчики предусмотрели поле when. Поле позволяет ограничить нашу транзакцию по времени.

Но даже наличие поля when не спасает нас от другой проблемы. Рассмотрим диаграмму, взятую из статьи Мартина Клепмана.

На изображении выше вы видите сервис блокировки и два клиента. Таблица Lock в данном случае реализует JdbcLockRegistry.

  • Клиент 1 захватывает и удерживает блокировку в течение длительного времени (допустим, у этого клиента в какой-то момент включился Garbage Collector, который затормозил всю Java).
  • Cервис блокировок отслеживает для нас наступление time-out при помощи поля when и отменяет блокировку. Пока все корректно.
  • В игру вступает Клиент 2. Он забирает на себя блокировку, успешно завершает собственную бизнес-транзакцию.
  • Тут Garbage Collector Клиента 1 заканчивает работу, и Клиент 1 точно так же благополучно записывает свои данные в базу.

И это полное фиаско. Все данные транзакции Клиента 2 были перетерты.

Для разрешения подобных ситуаций в JdbcLockRegistry добавляется целочисленное поле version.

Теперь картина выглядит так:

  • На момент, когда происходит захват транзакции, Клиент 1 получает токен (в примере на картинке — 33).
  • Вступает в работу Клиент 2. Он делает захват уже после обрыва блокировки, ему выдается токен 34, Клиент 2 записывает данные.
  • Garbage Collector Клиента 1 просыпается, но когда Клиент 1 пытается произвести какие-то действия с базой, оказывается что устаревший токен 33, конфликтует с токеном 34. В результате Клиент 1 получает ошибку.

Такой код с полем when и version обеспечивает корректное бизнес-поведение в сложных распределенных сценариях.

Вернемся к рассмотренному ранее компактному рабочему коду:

public void buyBooks2(List<Book> books, Payment userPayment){
    for(Book b: books){
        Lock lock = lockRegistry.obtain(b.getIsbn());
        lock.lock(); // lock.tryLock();
        try{
            doInsertAndUpdate(b);
        } catch (InterruptedException e1){
            //give up
            Thread.currentThread().interrupt();
        } finally{
            lock.unlock();
        } //try
    } //for
}

Где здесь обработка экспирации? Каким образом Spring понимает, что отведенное время закончилось? В Spring ответственность за правила экспирации возложена на программиста (предусмотрена даже возможность ввести вечную блокировку). Можно указать явное описание, в течение какого времени данная блокировка должна быть отпущена. Мы вычеркиваем Autowired-код с простым lockRegistry и заменяем его на ExpirableLockRegistry. В этом случае при конфигурации lockRegistry программисту необходимо будет использовать более гибкий подход, создав Scheduler: в нашем примере это fixedDelay, равный 50 секундам, и метод, который очищает все существующие в системе блокировки, превысившие время ожидания — expireUnusedOlderThan (также с указанием 50 000 миллисекунд).

@Autowired
private LockRegistry lockRegistry;
@Autowired
private ExpirableLockRegistry lockRegistry;
@Scheduled(fixedDelay=50000)
public void cleanObsolete(){
    lockRegistry.expireUnusedOlderThan(50000);//that are not currently locked
}

Теперь давайте уделим еще немного внимания реализации ZookeeperLockRegistry .

Apache Zookeeper — иерархическое распределенное хранилище информации. Это не база данных, но может использоваться в качестве базы (хотя это и не совсем удобно). Zookeeper активно используется при реализации крупных распределенных систем, и, например, широкоизвестная Apache Kafka, хранит в нем такие важные для себя настройки как:

  • список разрешенных пользователей;
  • кто выступает лидером;
  • какой уровень избыточности и пр.

Информация в Zookeeper организована в виде дерева, в узлах которого могут храниться какие-либо данные. Zookeeper — практически «неубиваемая» система, которая поднимает несколько экземпляров, для обеспечения требуемого уровень репликации информации. В терминах CAP-теоремы Zookeeper — это типичная СР-система (Consistency — Partition Tolerance): хорошо распределяется в сети и гарантирует, что информация будет надежно сохранена.

Таким образом использование ZookeeperLockRegistry подразумевает, что информация о блокировках будет храниться в распределенном иерархическом хранилище.

Как еще можно использовать Spring Global Lock

Современные распределенные системы постоянно вынуждены решать одну и ту же задачу — «договориться» между собой, кто из них лидер. Это необходимо при проектировании микросервисов, создании fault-tolerant или high-available кластеров и прочих подобных задачах.
Расскажу об алгоритме Algorithm of Leader Election, который работает в нескольких компонентах Spring Cloud (более подробно можно прочитать в Spring Cloud Release Notes).

Реализация алгоритма спрятана в компоненте LockRegistryLeaderInitiator.

Эта компонента:

  • Использует глобальную блокировку с обработкой time-out.
  • Допускает существование только одного лидера.
  • Допускается отсутствие лидера в течение коротких промежутков времени.

Один из примеров применения Algorithm of Leader Election — Zookeeper (написанное на чистой Java без использования Spring). Zookeeper не использует Spring Lock, но с идеей Spring Lock мы можем порассуждать, каким образом реализовывать механизм leader election.

В более простых приложениях роли явные прописываются администратором системы. Например, вы организуете транзакцию в схеме Master-Slave. Данные распространяются от Master к Slave по явно прописанным правилам, кто Master, а кто Slave.

В случае с несколькими экземплярами Zookeeper они самостоятельно должны «решить», кто из них является лидером. Это нужно для того, чтобы понять, кто обладает наиболее достоверной информацией.

В нашей мысленной реализации мы берем Spring Lock и на некоторое время блокируем всю систему, используя в качестве ключа какой-либо абсолютно глобальный идентификатор. Когда система блокируется, один из экземпляров Zookeeper прописывает себя главным. Остальные, не имея возможности захватить блокировку, ждут. Таким образом, реализуется чрезвычайно простой алгоритм выбора лидера.

Почему Zookeeper является именно СР, а не АР-системой? Именно потому, что на момент блокировки мы делаем временно всю систему недоступной. Система глобальной блокировки гарантирует, что если во время выбора какой-либо экземпляр прекратил работу, блокировка со временем будет снята и другой лидер выдвинет себя в качестве главного. Минус такого подхода — на какое-то время (время захвата Global Lock или восстановления по time-out) система может оказаться недоступной.

Подведем итоги

Плюсы

Использование Global Lock — это всегда гарантированное распределенное окружение (например, микросервисное). При этом не стоит забывать о решении уровня Middle — это хорошее, добротное решение. Если вы используете чистую базу данных и есть возможность делать блокировку на уровне таблиц, на уровне записи, оно имеет полное право на жизнь.

Минусы

К сожалению, задержки все же присутствуют и могут быть довольно большими (например, в случае многократного падения системы по тайм-ауту). Global Lock может замедлить систему — особенно при неудачном выборе ключа — и об этом нельзя забывать.

Если вам интересно развитие в Java-направлении, рекомендую также посмотреть материалы GlobalLogic Kharkiv Java Conference 2018.

Похожие статьи:
Минулого тижня відбувся фандрейзинг для «Повернись живим», де вдалося зібрати 30 млн грн за добу. Тож ми не могли це не обговорити:...
Перш ніж перейти до гарячої теми, розповім про себе. Я працюю проєктним менеджером, маю трошки більше як три роки досвіду....
У Вашей профессии нет перспектив, и Вы хотите изменить свою жизнь, перейдя в IT-сферу? Тогда курс по тестированию ПО, как...
UPD від 3.09. У понеділок, 2 вересня, більшістю голосів наглядової ради звільнили голову НЕК «Укренерго» Володимира...
Директор Google Ukraine Дмитро Шоломко пішов із компанії, про це повідомило видання AIN, посилаючись на власні джерела....
Яндекс.Метрика