Всем привет! Есть достаточно распространенная задача: нужно вставить новую запись в БД, но если она уже там есть, то её следует обновить. Эта ситуация может встретиться, например, если у вас есть 2 источника данных, каждый из которых передает половину информации об объекте, и ваш сервис должен склеить части вместе по какому‑то внешнему идентификатору. Но вы не знаете, в каком порядке к вам попадут эти половинки. Предлагаю посмотреть, как это можно сделать, если сервис использует Spring Data JPA.

А в чем вообще проблема?

Возьмем очевидную реализацию этого сервиса на Spring Data JPA:

@Transactional
public Item saveOrUpdate(ItemDto dto) {
  Item item = repository.findItemByExternalId(dto.getExternalId())
    .map(existed -> updateEntity(existed, dto))
    .orElseGet(() -> createNewEntity(dto));

  return repository.save(item);
}

Здесь мы ищем сущность по внешнему идентификатору, используя JpaRepository. Если находим — обновляем ее данными из DTO‑объекта (в методе updateEntity), если не находим — создаем новую (вызываем метод createNewEntity). В обоих случаях сохраняем результат в БД, вызывая repository.save.

Что случится, если этот метод будет вызван одновременно для одного и того же внешнего идентификатора? Ну, один из возможных ответов — все будет ОК, мы тут используем механизмы EntityManager’а, который такие проблемы успешно порешает за нас (спойлер — не порешает). Если запись в БД есть — EntityManager использует update, если нет — использует insert. Удивительно, но так в интернетах думают достаточно многие — вот первые ссылки по запросу JPA upsert из гугла:

Ни в одной из них даже не рассматривается вариант, что мы получим какие‑то проблемы. Чтобы немного засомневаться в том, так ли это, достаточно подумать, что сможет сделать EntityManager, если у нас несколько инстансов? Он же так или иначе работает на базе своего внутреннего кэша, поэтому 2 разных инстанса сходят в базу, увидят, что объекта с таким идентификатором там еще нет, и оба попробуют выполнить insert. На самом деле, и один инстанс сервиса сделает то же самое, потому что кэш EntityManager’а ограничен текущей транзакцией. В итоге, первый insert выполнится успешно, а второй — свалится с ошибкой вставки по уникальному ключу (если вы, конечно, создали для внешнего идентификатора уникальный индекс).

Lock'и

Окей, у JPA и у Spring Data есть функциональность работы с блокировками (Lock). Они же нам помогут? На самом деле, нет. Блокировки действительно решают проблему конкуретных обновлений данных, но для этого данные должны уже быть в базе. Использование PESSIMISTIC_LOCK обычно сводится к запросу SELECT FOR UPDATE, оптимистичные блокировки используют специальное поле версии при обновлении, и убеждаются что версия данных в БД не отличается от той, что в памяти. Но когда записи в базе еще нет, то оба подхода приведут к выполнению двух insert’ов.

Внешняя блокировка

Перейдем к вариантам, которые будут работать. Первый является продолжением попыток на чем‑нибудь синхронизироваться. Если мы не можем синхронизироваться на самой обновляемой сущности, потому что ее еще нет в БД, давайте синхронизируемся на чем‑нибудь еще, что там уже есть. Сделаем в БД отдельную таблицу для блокировок, вставим туда строку, которая будет соответствовать нашей таблице для Item. Если мы хотим что‑то изменить в таблице сущностей, сначала получим блокировку на соответствующей строке в таблице блокировок.

Как это сделать менее примитивно

Тут есть простор для разных оптимизаций. Например, в начальном варианте мы можем обновлять таблицу сущностей только в один поток. Перебор, ведь мы только хотим запретить одновременную работу с одним идентификатором. Можно сделать чуть лучше, если использовать для одной обновляемой таблицы в БД несколько строк в таблице блокировок, разбитых по диапазонам идентификаторов. Например, чтобы разрешить обновлять сущности в 8 потоков, создадим 8 строк в таблице блокировок, нужную строку будем выбирать по остатку от деления ключа на 8 (или по остатку от деления хэша ключа на 8).

Следующий момент — заполнение самой таблицы блокировок. В первом варианте она заполняется отдельно от работы самого приложения. Это не очень удобно, если мы хотим этот механизм переиспользовать в разных местах. Можно научить сервис получения блокировок создавать строки в таблице блокировок, если их там нет. Но тут опять встает вопрос, а как это делать безопасным образом. Для этого можно таблицу блокировок предзаполнить единственной записью, которая отвечает за саму таблицу блокировок. То есть получение блокировки для Item будет выглядеть так:

  1. идем в таблицу блокировок за строкой для таблицы Item;

  2. если такой строки нет, идем в таблицу блокировок за строкой для самой таблицы блокировок (она всегда есть);

  3. еще раз ищем строку для таблицы Item — вдруг ее кто‑то добавил, пока мы получали блокировку для таблицы блокировок;

  4. добавляем нужную строку для таблицы Item;

  5. отпускаем блокировку таблицы блокировок;

  6. получаем блокировку для таблицы Item.

Сложно? Тогда есть вариант не изобретать свои велосипеды. Мы же используем Spring — там есть все. Даже если вы хотите в своем приложении реализовать черта лысого, наверняка найдется уже готовая спринговая библиотека с ним внутри (и еще starter‑версия с автоконфигурацией). В частности, в этой задаче может помочь интерфейс LockRegistry и реализация JdbcLockRegistry в spring‑boot‑starter‑integration и spring‑integration‑jdbc соответственно. Там используется подход, который отличается от того, который я описал. В частности, блокировка происходит по ключу, а не по типу сущности, но суть одна — создать возможность залочиться на объекте БД, не связанном с основной бизнес‑логикой.

@Transactional
public Item saveOrUpdate(ItemDto dto) {
  Lock lock = lockRegistry.obtain(dto.getExternalId().toString());
  try {
    if(lock.tryLock(250, TimeUnit.MILLISECONDS)) {
      try {
        Item item = repository.findItemByExternalId(dto.getExternalId())
          .map(existed -> updateEntity(existed, dto))
          .orElseGet(() -> createNewEntity(dto));

        return repository.save(item);
      } finally {
        lock.unlock();
      }
    } else {
      String errorMsg = "Could not acquire lock for " + dto.getExternalId();
      log.error(errorMsg);
      throw new DBException(errorMsg);
    }
  } catch (InterruptedException e) {
    String errorMsg = "Lock acquiring interrupted for " + dto.getExternalId();
    log.error(errorMsg);
    throw new DBException(errorMsg);
  }
}
Как сделать, чтобы JdbcLockRegistry заработал

При использовании JdbcLockRegistry нужно немного поработать напильником, потому что когда этот бин попадает в контекст JPA‑приложения, могут возникнуть некоторые side‑эффекты. Вам нужно в Configuration приложения добавить следующее:

@Bean
public LockRepository lockRepository(DataSource dataSource) {
    var lockRepository = new DefaultLockRepository(dataSource);
    lockRepository.setTransactionManager(new DataSourceTransactionManager(dataSource));
    return lockRepository;
}

@Bean
public LockRegistry lockRegistry(LockRepository lockRepository) {
    return new JdbcLockRegistry(lockRepository);
}

Суть упражнения - заменить TransactionManager у LockRepository. По умолчанию, он бы использовал JPATransactionManager, и при невозможности получить блокировку, откатывалась бы основная бизнес-транзакция. При этом сам LockRegistry написан так, что невозможность получить блокировку - нормальное явление, он просто попробует сделать это позже. Но ваша бизнес-транзакция все равно откатится.

Native Query

Использование Native Query, наверное, самый очевидный способ решения задачи. Наша проблема связана с тем, что стандартные средства Spring Data и JPA не позволяют выполнить проверку наличия записи в БД и вставку/обновление в одно действие. Поэтому нам приходится как‑то обеспечивать корректное согласованное выполнение этих двух операций. Но если операция будет одна, то проблему удастся переложить на БД. Большинство вендоров имеют возможности для того, чтобы выполнить такую операцию за один вызов. В случае PostgreSQL, это выражение insert on conflict do update. Вот так может выглядеть кастомный метод в репозитории:

@Query(value = "insert into item (external_id, foo, bar) " +
               "  values (:#{#entity.external_id}, :#{#entity.foo}, :#{#entity.bar}) " + 
               "  on conflict(external_id) do update set foo = coalesce(excluded.foo, item.foo), " +
               "    bar = coalesce(excluded.bar, item.bar) " +
               "  returning *", nativeQuery = true)
Item saveOrUpdate(Item entity);

Оригинальный запрос не использовал returning * и возвращал количество измененных строк. Поменял его, после того как @oxff в комментариях указал на возможность сразу получать обновленную сущность в Native Query

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

В целом, подход с Native Query эффективно решает проблему, но имеет свои недостатки:

  • логика обновления оказывается на стороне базы (обратите внимание на coalesce в приведенном примере — это как раз выбор, оставить старые данные или использовать новые);

  • NativeQuery возвращает количество измененных строк, а не сохраненный объект. Чтобы получить этот объект, нам надо выполнять отдельный запрос в БД, и при этом нет гарантий, что мы получим ровно то, что сами только что вставили;

  • код приложения зависит от используемой БД (этот недостаток принято преувеличивать. Я ни разу не встречал ситуацию, когда переезд на другую базу действительно бы выполнялся по щелчку пальцев. Это всегда была отдельная трудоемкая активность. Поэтому так ли важно пытаться сохранять «базонезависимость» в коде, вместо того, чтобы использовать специфику БД точечно там, где она нужна?);

  • такой подход противоречит идее Spring Data — вы используете технологию, которая освобождает даже от написания JPQL, и тут же пишете на Native SQL. Вернее, он не то, чтобы противоречит, но заставляет задуматься, а правильно ли вы выбрали инструментарий. Хорошо когда на 99 стандартных вызовов репозитория приходится один такой хитросделанный, но если у вас на 10 вызовов 8 содержат какой‑то тюнинг, то зачем вам вообще Spring Data?

  • требуются более глубокие знания о той БД, с которой работает приложение.

Повтор операций

Наименее распространенный способ, на мой взгляд. Если вы получили ошибку при выполнении второго insert'а из‑за ограничений целостности, просто повторите операцию еще раз. При втором запуске приложение получит Item из базы и успешно выполнит обновление.

Уровни изоляции транзакций

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

По умолчанию, большинство БД предоставляют уровень изоляции READ COMMITTED. Нам нужно защититься от того, что результат выполнения запроса одной транзакцией, оказался бы в конце транзакции невалиден в результате того, что другая транзакция закоммитила данные, влияющие на результат первого запроса (добавила новые записи или удалила существующие). Это похоже на фантомное чтение, но оно предполагает, что первая транзакция действительно повторяет запрос. Отсюда вопрос — а поможет ли нам вообще повышение уровня изоляции? Самый строгий уровень SERIALIZABLE должен защищать от фантомных чтений, но у нас их и так фактически нет. Ответ — все зависит от того, что ваша БД подразумевает под уровнем SERIALIZABLE (вообще обратите внимание, что именно ваша БД подразумевает под каждым уровнем изоляции, это только кажется стандартом). В частности, в PostgreSQL повышение уровня транзакции до SERIALIZABLE даст нужный нам эффект, а вот в Oracle, судя по всему, нет.

Также надо сказать несколько слов о том, что значит «защищает» или «дает гарантии», когда речь идет об уровнях изоляции. Распространенный подход к пониманию уровней изоляции такой: «более строгие уровни изоляции медленнее работают, но предотвращают влияние транзакций друг на друга и делают необязательными использование специальных выражений типа SELECT FOR UPDATE». Это подразумевает, что повышение уровня транзакции точно вам ничего не сломает в приложении, но может его замедлить. На самом деле, базы данных могут вести себя не так. Вместо того, чтобы фоново блокировать и переупорядочивать конфликтные операции, они могут отслеживать действия, которые нарушают гарантии текущего уровня изоляции, и в случае обнаружения таких действий, откатывать транзакцию. Так делает PostgreSQL. Таким образом, повышение уровня изоляции очень даже легко может сломать ваше приложение. Для работы на более высоких уровнях вы должны в своем коде предусмотреть такие откаты и корректно их обрабатывать. Чаще всего — просто повторять операцию.

В случае Spring‑приложений у нас есть удобный механизм повтора — аннотация Retryable (не забудьте при ее использовании указать в конфигурации @EnableRetry). Если произошло нарушение уникального индекса операцию можно повторять сразу же. Если вы решаете проблемы с повтором операции из‑за уровня изоляции, то надо быть более осторожным: с настройками по умолчанию обе конкурирующие транзакции откатятся и после одинаковой задержки в 1 секунду одновременно повторятся. Поэтому с большой вероятностью опять случится конфликт. Чтобы этого избежать, можно использовать случайную задержку при повторе.

@Retryable(backoff = @Backoff(delay = 1, maxDelay = 100, random = true))
@Transactional(isolation = Isolation.SERIALIZABLE)
public Item saveOrUpdate(ItemDto dto) ...

Итоги

Upsert с использованием Spring Data JPA можно реализовать несколькими способами, у каждого из которых свои достоинства и недостатки:

  1. Внешние блокировки усложняют код, снижают производительность и увеличивают нагрузку на БД, но при этом приложение не снижает свой уровень абстракции. Для этого вообще не обязательно использовать БД, Spring предоставляет реализацию с Redis'ом. Также этот подход может применяться для корректного конкурентного доступа не только к БД, но и к любым ресурсам;

  2. Native Query полагается на способность БД эффективно реализовать слияние данных за одну операцию. И поэтому сильно зависит от того, какую БД вы используете. Кроме этого, обновление и чтение обновленных данных все равно окажутся разными операциями;

  3. Повтор операций увеличивает время отклика и заставляет быть внимательным к side‑effect'ам повторяемого метода. И при этом, в некоторых случаях, вообще нет гарантии, что он поможет. Но зато он крайне прост в реализации.

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


  1. slvABTOP
    00.00.0000 00:00
    +3

    Целая статья вместо того, что бы на sql написать insert on conflict do update


    1. mrfloony
      00.00.0000 00:00
      +1

      не все хотят видеть нативщину в коде


    1. brastak Автор
      00.00.0000 00:00
      +1

      ну так-то любой туториал можно сократить до букв RTFM ) я как раз и хотел написать про то, что еще можно сделать, кроме добавления в код нативного SQL


    1. ascjke
      00.00.0000 00:00

      Для меня эта статья полезная, т.к. только изучаю java, spring. Про существование "insert on conflict do update" в postgres не знал


  1. Kassiy_Pontiy_Pilat
    00.00.0000 00:00
    +2

    Скромно упомяну про возможность использования @Version в entity классе


    1. brastak Автор
      00.00.0000 00:00

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


  1. LaRN
    00.00.0000 00:00
    +1

    В итоге, первый insert выполнится успешно, а второй — свалится с ошибкой вставки по уникальному ключу (если вы, конечно, создали для внешнего идентификатора уникальный индекс).

    По идее если используется сиквенса или UUID для внешнего идентификатора, то не будет дубликатов по ключу, но будет две одинаковые записи с разными ключами.


    1. brastak Автор
      00.00.0000 00:00
      +1

      В статье я имею ввиду, что внешний идентификатор выдает не наша база, а какой-то сторонний сервис (поэтому он и внешний). Вы можете использовать как первичный ключ его, а можете сделать, чтобы первичный ключ генерировала база (я предпочитаю этот вариант). Но и во втором случае поле для внешнего ключа имеет смысл сделать уникальным, как раз чтобы ни при каких обстоятельствах не создались 2 записи с одинаковым внешним ID


  1. oxff
    00.00.0000 00:00
    +1

    NativeQuery возвращает количество измененных строк, а не сохраненный объект. Чтобы получить этот объект, нам надо выполнять отдельный запрос в БД, и при этом нет гарантий, что мы получим ровно то, что сами только что вставили;

    Вы используете синтаксис Postgres. Просто допишите в конец "returning *" и DML запрос вернёт строки, подвергшиеся изменению.


    1. brastak Автор
      00.00.0000 00:00

      Да, если мы используем PreparedStatement. Ну или вернее так - со Spring Data JPA это не сработает. При попытке объявить в репозитории модифицирующий метод, который возвращает не void и не int / Integer, вы получите ошибку, которая, собственно, и говорит:

      Modifying queries can only use void or int/Integer as return type!


      1. oxff
        00.00.0000 00:00
        +1

        А вы уберите @Modifying. Строго говоря, тут уже не совсем DML, но микс из DML+DQL. Данная аннотация просто изменяет способ интерпретации результата запроса.

        Можете нагуглить на эту тему много интересного и на stackoverflow и github issues. У меня такой код есть в продакшн, и никаких блокировок не нужно.


        1. brastak Автор
          00.00.0000 00:00
          +1

          Хм, прикольно, не знал. Вернее, знал, что если мы хотим сделать insert / update с JPQL, то нам не дадут, потому что Not supported for DML operations [update ...] и ошибочно эктраполировал это и на nativeQuery. Но, да, с Native Query таких проверок нет и все работает. Спасибо!