Сегодня предлагаю Вам разобрать одну практическую задачу о гонке клиентских запросов, с которой я столкнулся в МаксимаТелеком при разработке 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 велись в рамках одной сессии.

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


  1. fzn7
    30.07.2019 12:19
    -1

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

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


    1. uchonyy Автор
      30.07.2019 12:36

      «При старте клиентское приложение асинхронно отправляет «пачку» запросов к API» — это ситуация, когда авторизация уже пройдена или вообще отсутсвует.
      В примерах Client — это сущность устройства пользователя, оно может меняться при тех же учетных данных.


      1. fzn7
        30.07.2019 13:25
        +1

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


  1. vvm13
    30.07.2019 12:30
    +2

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

    Так вроде напрашивается повторить
    client = clientRepository.findByClientId(clientId);
    после этого исключения.


    1. 0x1fff
      30.07.2019 14:46
      +1

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


      1. vvm13
        30.07.2019 21:29

        А я бы плюнул на эстетику и примерно так и пошёл бы. Некрасиво, но просто. Разве что, перенёс бы логику в базу.
        client = clientRepository.findByClientId(clientId); не давал бы исключения integrity constraint violation в принципе и вызывал бы хранимую процедуру/функцию, которая и выполняла что-то вроде

        1. select…
        2. если непусто, возвращаем и завершаем
        3. insert.., игнорируем integrity constraint violation
        4. select ..., возвращаем и завершаем
        Полагаю, что почти всегда будет работать только 1 и 2.


        1. vvm13
          31.07.2019 15:53

          Нет, как ни странно кому-то может показаться, после integrity constraint violation в третьем пункте не факт, что select в пункте 4 что-то вернёт. На каких-то СУБД на каких-то уровнях изоляции может сработать не так, как мне хотелось. Проблема интереснее, чем мне первоначально подумалось.


    1. uchonyy Автор
      30.07.2019 14:46

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


      1. vvm13
        30.07.2019 21:30

        «необходимость обращения к нескольким другим сервисам до коммита» — а это для меня странно.


        1. DiabloNT
          30.07.2019 21:39

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


          1. vvm13
            31.07.2019 13:12

            Абстрактно — да, но конкретно — что? С использованием ClientID, которого ещё в базе нет?


  1. time2rfc
    30.07.2019 13:58

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


    1. uchonyy Автор
      30.07.2019 17:07

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


  1. KOTaSYS
    30.07.2019 14:32

    Хорошая статья. Спасибо


  1. DiabloNT
    30.07.2019 16:40

    Полезно. Спасибо!


  1. sergey-gornostaev
    30.07.2019 20:24

    А просто synchronizeOnSession вам бы не помог?


    1. DiabloNT
      30.07.2019 21:46

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


      1. sergey-gornostaev
        31.07.2019 05:29

        Кстати, никогда не заглядывал в код для проверки того, как конкретно синхронизация на сессиях устроена. Интересно, как она себя поведёт в случае распределённого хранилища сессий?


    1. uchonyy Автор
      31.07.2019 10:10

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


  1. itblogger
    31.07.2019 06:11

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


  1. Throwable
    31.07.2019 11:08
    -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)


    1. uchonyy Автор
      01.08.2019 09:48

      сервис singleton

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

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

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

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

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

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

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

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

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


      1. Throwable
        01.08.2019 14:34

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

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


  1. Daniyar94
    31.07.2019 15:32

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


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


    1. uchonyy Автор
      01.08.2019 09:53
      +1

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

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