Синхронизация клиентских запросов в Spring

15 ИЮНЯ 2022

------------------------------------------------------------------------

Наш телеграм-канал: t.me/@mailsgun_online

Youtube: youtube.com/@mailsgun_online

------------------------------------------------------------------------

  • Tutorial
Сегодня предлагаю Вам разобрать одну практическую задачу о гонке клиентских запросов, с которой я столкнулся в МаксимаТелеком при разработке back-end для нашего мобильного приложения MT_FREE.

При старте клиентское приложение асинхронно отправляет «пачку» запросов к API. Приложение имеет идентификатор clientId, на основании которого можно различить запросы одного клиента от другого. На каждый запрос на сервере выполняется код вида:

  // получаем из репозитория данные клиента
  Client client = clientRepository.findByClientId(clientId);
  // если клиент не создан ранее
  if(client == null){
      client = clientRepository.save(new Client(clientId));
  }
  // далее обрабатываем запрос

где сущность Client имеет поле clientId, которое должно являться уникальным и имеет для этого в базе unique constraint. Так как в Spring каждый запрос будет выполнять данный код в отдельном потоке, даже если это запросы от одного и того же клиентского приложения, будет возникать ошибка вида:

integrity constraint violation: unique constraint or index violation; UK_BFJDOY2DPUSSYLQ7G1S3S1TN8 table: CLIENT

Ошибка возникает по очевидной причине: 2 или более потоков с одним clientId получают сущность client == null и начинают ее создавать, после чего при коммите получают ошибку.

Задача:

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

Решение 1

  // если клиент не создан ранее
  if(client == null){
    // выполняем синхронизацию
     synchronized (this){
         // выполняем повторную проверку
         client = clientRepository.findByClientId(clientId);
         if(client == null){
               client = clientRepository.save(new Client(clientId));
         }
     }
  }

Данное решение является работающим, но весьма дорогим, так как блокируются все запросы (потоки), которым нужно выполнить создание, даже если они будут создавать Client с разными clientId и никак друг с другом не конкурируют.

Обратите внимание, что сочетании синхронизации с аннотацией @Transactional

@Transactional
public synchronized Client getOrCreateUser(String clientId){
// получаем из репозитория данные клиента
  Client client = clientRepository.findByClientId(clientId);
  // если клиент не создан ранее
  if(client == null){
      client = clientRepository.save(new Client(clientId));
  }
  return client;
}

опять возникнет та же ошибка. Причина в том, что сначала освободится монитор (synchronized) и следующий поток войдет в синхронизированную область, а только после в прокси-объекте произойдет коммит транзакции первым потоком. Решить эту проблему просто — нужно чтобы монитор освобождался после коммита, следовательно, synchronized необходимо вызывать выше:

 synchronized (this){
   client = clientService.getOrCreateUser(clientId);
 }

Решение 2

Очень хотелось бы использовать конструкцию вида:

synchronized (clientId)

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

Client client = clientRepository.findByClientId(clientId);
// если клиент не создан ранее
if(client == null){
  // выполняем синхронизацию
 synchronized (clientId.intern()){
    // выполняем повторную проверку
    client = clientRepository.findByClientId(clientId);
     if(client == null){
       client = clientRepository.save(new Client(clientId));
     }
  }
}

В данном решении используется java string pool, соответственно, запросы с эквивалентным clientId, вызвав clientId.intern(), получат один и тот же объект. Менеджмент «протухания» clientId ложится в данном случае на плечи GС. Данный подход работает только для конструкции synchronized, что, к сожалению, не всегда достаточно.

Решение 3

Для того, чтобы использовать ReentrantLock, необходим пул вида:

private final ConcurrentMap<String, ReentrantLock> locks;

и тогда:

Client client = clientRepository.findByClientId(clientId);
// если клиент не создан ранее
if(client == null){
  // выполняем синхронизацию
  ReentrantLock lock = locks.computeIfAbsent(clientId, (k) -> new ReentrantLock());
  lock.lock();
  try{
    // выполняем повторную проверку
    client = clientRepository.findByClientId(clientId);
    if(client == null){
       client = clientRepository.save(new Client(clientId));
    }
  } finally {
     // отпускаем лок
     lock.unlock();
  }
}

Единственной проблемой остается менеджмент «протухания» clientId, ее можно решить использованием нестандартной реализации ConcurrentMap, которая уже поддерживает expire, для примера берем guava Cache:

locks = CacheBuilder.newBuilder()
            .concurrencyLevel(4)
            .expireAfterWrite(Duration.ofMinutes(1))
            .<String, ReentrantLock>build().asMap();

Решение 4

Приведенные ранее решения осуществляют синхронизацию запросов в рамках одного инстанса. Что же делать если ваш сервис крутится на N нодах и запросы могут попасть одновременно на разные? Для данной ситуации отлично подойдет в качестве решения использование библиотеки Redisson:

  Client client = clientRepository.findByClientId(clientId);
   // если клиент не создан ранее
   if(client == null){
      // выполняем синхронизацию
      RLock lock = redissonClient.getFairLock(clientId);
      lock.lock();
      try{
       // выполняем повторную проверку
         client = clientRepository.findByClientId(clientId);
         if(client == null){
              client = clientRepository.save(new Client(clientId));
          }
       } finally {
           // отпускаем лок
            lock.unlock();
       }
}

Библиотека решает задачу «distributed locks», используя в качестве хранилища redis.

Заключение

Какое решение применить безусловно зависит от масштаба задачи: решения 1-3 вполне подойдут для небольших одноинстансных сервисов, решение 4 нацелено уже на распределенные сервисы. Также отдельно стоит заметить, что решение данной задачи с использованием Redisson или аналогов (например классического Zookeeper) это, безусловно, частный случай, так как они рассчитаны на куда больший круг задач для распределенных систем.

В нашем случае мы остановились на решении 4, так как наш сервис является распределенным и интеграция Redisson была наиболее простой в сравнении с аналогами.

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

Кстати, мы постоянно расширяем штат разработки, актуальные вакансии можно найти на нашей карьерной странице.

Source: habr1

Ну и напоследок напоминаю вам о нашем новом проекте vkserfing bot.