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

  • 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, что рано или поздно приведет к OutOfMemory.

Решение 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.

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

UPD 1. Решение от читателей 1


В данном решении предлагается не делать синхронизацию запросов, а в случае возникновения ошибки вида:
integrity constraint violation: unique constraint or index violation; UK_BFJDOY2DPUSSYLQ7G1S3S1TN8 table: CLIENT

необходимо ее обработать и повторно вызвать
 
client = clientRepository.findByClientId(clientId);

или сделать это через spring-retry:
@Retryable(value = { SQLException.class }, maxAttempts = 3, backoff = @Backoff(delay = 1000))
@Transactional
public Client getOrCreateUser(String clientId)

(за пример спасибо Throwable)
В данном случае будут «лишние» запросы к БД, но на практике создание сущность Client будет происходить не часто, и если синхронизация нужна только, чтобы решить проблему со вставкой в БД, то можно обойтись данным вариантом решения.

UPD 2. Решение от читателей 2


В данном решении предлагается сделать синхронизацию через сессию:
 
HttpSession session = request.getSession(false);
if (session != null) {
    Object mutex = WebUtils.getSessionMutex(session);
    synchronized (mutex) {
        ...
    }
}

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

Комментарии 26

    0
    Я может что-то не понимаю, но «При старте клиентское приложение асинхронно отправляет «пачку» запросов к API» — без аутентификации / регистрации все такие запросы должны быть завернуты в 403 и отправлены обратно на клиент. Дальше все стандартно

    UPD. Проблему распределенных запросов всю жизнь решали липкой сессией и любой java app сервер это умеет из коробки. В Spring по умолчанию tomcat: tomcat.apache.org/tomcat-6.0-doc/cluster-howto.html
      0
      «При старте клиентское приложение асинхронно отправляет «пачку» запросов к API» — это ситуация, когда авторизация уже пройдена или вообще отсутсвует.
      В примерах Client — это сущность устройства пользователя, оно может меняться при тех же учетных данных.

        +1
        Ок, я кажется понял о чем идет речь. Вариант 4 на мой взгляд оптимален. Вы можете использовать этот ключ устройства и настроить роутинг клиента на один и тот-же сервер, что-бы избежать накладных расходов на дистрибуцию ключа по сети и вернуться к варианту 3. Это может незначительно ускорить ответ на первоначальную пачку запросов, т.к. в худшем случае N запросов улететь на N разных нод и придется ждать пока они синхронизируют состояние между собой
    • НЛО прилетело и опубликовало эту надпись здесь
        +1
        Обработка таких исключений является антипаттерном, как с точки зрения производительности, так и с точки зрения стиля кода. Более того, лишние запросы к БД создают накладные расходы, что тоже не очень хорошо. При нескольких потоках, стоимость обработки таких исключений увеличивается в разы.
        • НЛО прилетело и опубликовало эту надпись здесь
          • НЛО прилетело и опубликовало эту надпись здесь
              0

              Надо сделать ClientId праймари ключом, тогда достаточно селекта + insert (или find + saveorupdate), в случае constraint violation — просто вернуть аргументы процедуры, т.к. сущность уже создана. Второй вариант — дописать ON CONFLICT/ON DUPLICATE KEY/IGNORE или подобное прямо в процедуре.


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


              Не знаю как там дальше используется ClientId, но нужен ли он вообще как отдельная сущность?

          0
          Действительно можно сделать так, а если реализация репозитория делается в ручную, то можно при insert использовать конструкции вида «ON CONFLICT» / «ON DUPLICATE KEY». Единственное, при данном подходе будет кратно больше запросов к БД, что не всегда хорошо на нагруженных сервисах. Но конкретно в нашей ситуации мы пошли в сторону синхронизации даже не по этому — проблемным местом в создании сущности Client являлась необходимость обращения к нескольким другим сервисам до коммита, и без синхронизации мы бы порождали дополнительную нагрузку еще и на нах.
          • НЛО прилетело и опубликовало эту надпись здесь
              0

              Назовём это XA. Обращаемся в какой-то сервис, если все ок — коммитим. Если там какой-то запрет — роллбэк. Так же было бы, если второй сервис заменить на вторую бд.

              • НЛО прилетело и опубликовало эту надпись здесь
          0

          Спасибо за статью!
          Интереса ради — сколько времени у вас съедает блокировка на базе которая еще и по сети распределена ?

            0
            Сколько точно съедает сейчас уже, к сожалению, не припомню, но было в допустимых пределах. Наибольшая трудоемкость была не при работе с базой как таковой, а в обращении в сторонние сервисы в ходе создания
            0
            Хорошая статья. Спасибо
              0
              Полезно. Спасибо!
                0
                А просто synchronizeOnSession вам бы не помог?
                  0

                  А что делать в случае нескольких нод? В этом случае всегда нужен какой-то внешний арбитр.

                    0
                    Кстати, никогда не заглядывал в код для проверки того, как конкретно синхронизация на сессиях устроена. Интересно, как она себя поведёт в случае распределённого хранилища сессий?
                    0
                    Если синхронизироваться на сессиях, то есть еще такой неприятный момент — когда на api прилетит первая пачка запросов от одного клиента, они должны все «получить» одну сессию. Для того, чтобы это сделать, нужен специальный метод, который вызывается клиентом первым и который создает сессию, а далее остальные методы просто уже использую ее идентификатор. В приложениях с явной авторизацией этот метод всегда есть — это собственно метод авторизации. Если в приложении нет авторизации или она проходит один раз, а не при каждом старте, заставлять клиент сначала создать на сервере сессию, а только потом запрашивать бизнес методы приведет к увеличению времени запуска приложения.
                    0

                    В продакшене это не работает: там как минимум два сервера, а данная синхронизация работает только для одной JVM. Правильных решений три: переделать клиента, переделать API, использовать транзакции на стороне DB.

                      –1
                      Решение 1

                      А пардон, где написано, что ваш сервис singleton, чтобы синхронизироваться по this? Тогда уж поставьте syncronized(MyService.class). Не будет работать в кластере.


                      Решение 2
                      Менеджмент «протухания» clientId ложится в данном случае на плечи GС.

                      Вообще жесть! Зачем вы вводите людей в заблуждение? Где заявлено про "протухание" и GC? String.intern() складывает строки в constant pool, который лежит в permanent generation. И скоро вместо протухания вы получите снижение производительности и как финал OutOfMemory.
                      all: никогда так не делайте и вообще забудьте про intern()!


                      Решение 3

                      Не работает в кластере.


                      Решение 4

                      Лочить по сети N нод для каждого getUserById() — ну, ну. Кроме того, привлекается какое-то левое решение.


                      Решение 0


                      Единственно правильное — использовать старый добрый механизм транзакций базы данных с повтором. Например так:
                      https://www.baeldung.com/spring-retry


                      @Retryable(value = { SQLException.class }, maxAttempts = 3, backoff = @Backoff(delay = 1000))
                      @Transactional
                      public Client getOrCreateUser(String clientId)
                        0
                        сервис singleton

                        Сервис singleton — см. исходники. В случае использования scope prototype синхронизироваться по this действительно нельзя.

                        И скоро вместо протухания вы получите снижение производительности и как финал OutOfMemory

                        Согласен, данное решение на практике неприменимо.

                        Не работает в кластере

                        Да, я об этом указал в статье: «решения 1-3 вполне подойдут для небольших одноинстансных сервисов».

                        Лочить по сети N нод для каждого getUserById()

                        Блокировка будет не на каждый getUserById, а только на операцию создания Client и блокироваться будут только запросы конкретного клиента.

                        Решение 0 Единственно правильное

                        Если задача именно решить проблему со вставкой, то вполне можно использовать Ваш вариант. Основная цель статьи больше показать способы синхронизации запросов, возможно просто пример с integrity constraint violation не самый подходящий, так как эта проблема имеет решения и без синхронизации.
                          0
                          Основная цель статьи больше показать способы синхронизации запросов, возможно просто пример с integrity constraint violation не самый подходящий

                          Проблема синхронизации к Spring-у вообще не имеет отношения. Если у вас под низом rdbms, то самое натуральное — это использовать ее средства синхронизации (напр. select for update). Даже если операция ничего не сохраняет в базу, но требует консистенции, проще будет создать таблицу mutex-ов и синхронизироваться по ней. Если же база распределенная и без acid, а запросы делаются асинхронно, то тут универсального решения нет — требуется адаптировать архитектуру под преследуемые цели, и в любом случае это будет сложнее и хуже.

                        0

                        В моем проекте сначала попытался словить ConstraintIntegrityViolation exception и после пробовал получить entity ещё раз. Из-за производительности сильно не переживал т.к. у Hibernate есть first-level caching вшитый.


                        Потом перешёл на Retryable mechanism, более чисто и понятней.

                          +1
                          Hibernate есть first-level caching вшитый

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

                        Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

                        Самое читаемое